恭喜发现宝藏!微信搜索公众号【TechGuide】关注更多新鲜好文和互联网大厂的笔经面经。
作者@TechGuide【全网同名】
点赞再看,养成习惯,您动动手指对原创作者意义非凡
当你的才华还撑不起你的野心时,你应该静下心去学习 。点赞再看,养成习惯 |
---|
随着近些年的互联网行业内卷不断加重,程序员面试也更加考察候选人对底层的了解,很多计网、数据库的知识是能溯源到底层的,操作系统作为直接控制硬件的中间层,重要性更是不言而喻。掌握操作系统知识的水平很好地反映了你对计算机组成原理的理解,面试官自然不肯放过。在这里引用一下wikipedia对它的定义。
An operating system (OS) is system software that manages computer hardware, software resources, and provides common services for computer programs.
在开始梳理之前,带着问题去复习知识,往往是最行之有效的学习方法之一,因为这样很容易让你把握住重点,并且试图在阅读中找出关联,融会贯通,先思考一下,上面列举的问题你是否已经理解透彻。
如果对于上面的问题,你都可以侃侃而谈的话,说明你对操作系统的基础掌握比较扎实,确实也没有读下去的必要了。但是如果没有的话,欢迎和我一起梳理一遍这些知识点,希望你有所收获。
作用:
功能:
进程和线程管理 ——进程线程的状态、控制、同步互斥、通信调度等
内存管理——分配/回收、地址转换、存储保护等
文件管理——文件目录、文件操作、磁盘空间、文件存取控制
设备管理——设备驱动、分配回收、缓冲技术等
用户接口——系统命令、编程接口
并行(parallel)与并发(concurrent)
并发性(concurrency),又称共行性,是指能处理多个同时性活动的能力,并发事件之间不一定要同一时刻发生。 【可以理解为共同出发】
并行(parallelism)是指同时发生的两个并发事件,具有并发的含义,而并发则不一定并行。【可以理解为同时进行】
比如,对单核CPU,因为一个CPU一次只能执行一条指令,是无法做到并行,只能做到并发。
进程(process)是一个程序在一个数据集中的一次动态执行过程,可以简单理解为“正在执行的程序”,它是系统进行资源分配和调度的基本单位。
进程一般由程序
、数据集
、进程控制块
三部分组成。我们编写的程序
用来描述进程要完成哪些功能以及如何完成;数据集
则是程序在执行过程中所需要使用的资源;进程控制块
用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。
线程(thread)包含在进程中,也叫轻量级进程。线程是进程中一个单一顺序的控制流,像“线”一样(或许是其译名的由来),它是系统进行运算调度(即如何分配CPU去执行不同任务)的基本单位,一个进程的多个线程在执行不同任务的同时共享进程的系统资源(如虚拟地址空间
,文件描述符
等)。线程由相关堆栈寄存器
和线程控制块
组成。
线程的出现是为了减少任务切换的消耗,提高系统的并发性,实现让一个进程也能执行多个任务。 例如一个文本程序需要获取键盘输入、显示文本内容并将文本内容保存到磁盘。如果使用多个进程来执行这些任务,需要频繁的进行上下文切换和进程间通信。考虑到这些任务是相互关联且共享资源的(它们都要用到文本内容),用一个进程中的多个线程来执行可以减少上下文切换和进程间通信的消耗。
文件描述符补充
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表
。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符
。
每一个文件描述符会与一个打开文件相对应,同时,不同的文件描述符也会指向同一个文件。相同的文件可以被不同的进程打开也可以在同一个进程中被多次打开。系统为每一个进程维护了一个文件描述符表,该表的值都是从0开始的,所以在不同的进程中你会看到相同的文件描述符,这种情况下相同文件描述符有可能指向同一个文件,也有可能指向不同的文件。
inode号
:储存文件的元信息的区域,比如文件的创建者、文件的创建日期、文件的大小等等,可以使用ls -i
命令查看当前目录中的inode号。
进程线程区别:
拥有资源:进程是拥有资源的一个独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源。进程所维护的是程序所包含的资源(静态资源), 如:地址空间
,打开的文件句柄集
,文件系统状态
,信号处理handler
等;线程所维护的运行相关的资源(动态资源),如:运行栈
,调度相关的控制信息
,待处理的信号集
等;
系统开销:在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。但是进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈
和局部变量
,但线程没有单独的地址空间
,一个进程死掉就等于所有的线程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。
进程的实现只能由操作系统内核来实现,而不存在用户态实现的情况。但是对于线程就不同了,线程的管理者可以是用户也可以是操作系统本身(分为用户级和内核级)。
协程,是一种比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。
当出现IO阻塞的时候,由协程的调度器进行调度,通过将数据流立刻yield()
掉(主动让出),并且记录当前栈上的数据,阻塞完后立刻再通过线程恢复栈,并把阻塞的结果放到这个线程上去跑。协程的目的就是当出现长时间的I/O操作时,通过让出目前的协程调度,执行下一个任务的方式,来消除Context Switch
上的开销。【详解】
关于进程资源的总结及补充:
线程共享的环境包括:
1.进程代码段
2.进程的公有数据(利用这些共享的数据,线程很容易的实现相互之间的通讯)
3.进程打开的文件描述符、信号的处理器、进程的当前目录和进程用户ID与进程组ID。
线程独立的资源包括:
1.线程ID
每个线程都有自己的线程ID,这个ID在本进程中是唯一的。进程用此来标识线程。
2.寄存器组的值
由于线程间是并发运行的,每个线程有自己不同的运行线索,当从一个线程切换到另一个线程上 时,必须将原有的线程的寄存器集合的状态保存,以便将来该线程在被重新切换到时能得以恢复。
3.线程的堆栈
堆栈是保证线程独立运行所必须的。线程函数可以调用函数,而被调用函数中又是可以层层嵌套的,所以线程必须拥有自己的函数堆栈, 使得函数调用可以正常执行,不受其他线程的影响。
4.错误返回码
由于同一个进程中有很多个线程在同时运行,可能某个线程进行系统调用后设置了errno值,而在该 线程还没有处理这个错误,另外一个线程就在此时被调度器投入运行,这样错误值就有可能被修改。所以,不同的线程应该拥有自己的错误返回码变量。
5.线程的信号屏蔽码
由于每个线程所感兴趣的信号不同,所以线程的信号屏蔽码应该由线程自己管理。但所有的线程都 共享同样的信号处理器。
6.线程的优先级
由于线程需要像进程那样能够被调度,那么就必须要有可供调度使用的参数,这个参数就是线程的优先级。
用户级线程和核心级线程
思考一下用户级线程和协程等价吗?欢迎评论说出你的见解。
用户级线程是指不需要内核支持而在用户程序中实现的线程,它的内核的切换是由用户态程序自己控制内核的切换(yield),不需要内核的干涉。但是它不能像内核级线程一样更好的运用多核CPU。
举个栗子,由于用户线程的透明性,操作系统是不能主动切换线程的,换句话讲,如果 A,B 是同一个进程的两个线程的话, A 正在运行的时候,线程 B 想要运行的话,只能等待 A 主动放弃 CPU,也就是主动调用 pthread_yield 函数。
核心级线程间的切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态。可以很好的运用多核CPU。【为了实现内核级线程,内核里就需要有用来记录系统里所有线程的线程表。当需要创建一个新线程的时候,就需要进行一个系统调用,然后由操作系统进行线程表的更新。】
1.先来先服务调度算法(FCFS,first come first served)
该算法既可用于作业调度,也可用于进程调度。当在作业调度中采用该算法时,每次调度都是从后备作业队列中选择一个或多个最先进入该队列的作业,将它们调入内存,为它们分配资源、创建进程,然后放入就绪队列。在进程调度中采用FCFS算法
时,则每次调度是从就绪队列中选择一个最先进入该队列的进程,为之分配CPU,使之投入运行。该进程一直运行到完成或发生某事件而阻塞后才放弃处理机。
2.最短作业优先算法(SJF,Shortest Job First)
短作业优先又称为短进程优先,这是对FCFS算法
的改进,目的是减少平均周转时间。对预计执行时间短的进程优先分派处理器
。如果一个进程正在执行,也就是执行时间越短的进程越先被执行,而且后来的短进程通常不会打断它。
3.最高响应比优先算法(HRRN,Hight Response Ratio Next)
最高响应比优先算法是对FCFS和SJF
的一种平衡算法。FCFS
只考虑了进程等待的时间长短,而SJF
考虑了进程执行的时间长短,因此这两种算法在某种程度上会降低系统调度性能。HRRN
这种算只法既会考虑每个进程的等待时间长短,也会考虑进程预计执行时间长短从中选出响应比最高的进程执行。这种算法是介于FCFS算法
和SJF算法
中间的一种这种算法。
4.时间片轮转算法(RR,Round-Robin)
该算法采用剥夺策略。每个进程都被分配好一个时间段,也就是它的时间片,这就是该进程允许允许的时间。如果该进程超过了时间片的时间,就会发生时间中断
,调度程序暂停当前进程的执行,将其送到就绪队列的末尾,并通过上下文切换执行当前的队首进程。进程可以未使用完一个时间片,就让出CPU(如阻塞)。
5.抢占式优先权调度算法
在这种方式下,系统同样是把处理机分配给优先权最高的进程,使之执行。但在其执行期间,只要又出现了另一个其优先权更高的进程,进程调度程序就立即停止(也有在基于事件中断的)当前进程(原优先权最高的进程)的执行,重新将处理机分配给新到的优先权最高的进程。
实现原理,具体应用场景【拓展】
进程间通信的基本原理:
每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信。
步骤:
(1)父进程调用pipe()函数
创建管道,得到两个文件描述符fd[0]、fd[1]
指向管道的读端和写端。
(2)父进程fork
出子进程,⼦进程也有两个⽂件描述符指向同⼀管道。
(3)父进程关闭fd[0]
,子进程关闭fd[1]
,即⽗进程关闭管道读端,⼦进程关闭管道写端(因为管道只支持单向通信)。⽗进程可以往管道⾥写,⼦进程可以从管道⾥读,管道是⽤环形队列实现的,数据从写端流⼊从读端流出,这样就实现了进程间通信。
限制(应用场景):
1). 只支持单向数据流,管道只允许单向通信。;
2). 只能用于具有亲缘关系的进程之间;
3). 没有名字;
4). 管道的缓冲区是有限的
5). 管道内部保证同步机制,从而保证访问数据的一致性。
有名管道不同于匿名管道之处在于它提供了一个路径名
与之关联,以有名管道的文件形式存在于文件系统
中,这样,即使与有名管道的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过有名管道相互通信,因此,通过有名管道不相关的进程也能交换数据。
总结:
- 管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
- 管道是特殊类型的文件,在满足先入先出的原则条件下可以进行读写,
- 内核缓冲区
(1)消息队列是由消息组成的链表,存放在内核中并由消息队列标识符标识。
(2)消息队列允许一个或多个进程向它写入与读取消息.
(3)管道和消息队列的通信数据都是先进先出的原则。
(4)消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比FIFO更有优势。
(5)消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
(6)目前主要有两种类型的消息队列:POSIX消息队列
以及System V消息队列
,系统V消息队列目前被大量使用。系统V消息队列是随内核持续的,只有在内核重起或者人工删除时,该消息队列才会被删除。
与管道的区别:
- 与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。
- 另外与管道不同的是,消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。
信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,而无需知道该进程的状态。
(a)不可靠信号: 也称为非实时信号,不支持排队,信号可能会丢失, 比如发送多次相同的信号, 进程只能收到一次. 信号值取值区间为1~31;
可靠信号: 也称为实时信号,支持排队, 信号不会丢失, 发多少次, 就可以收到多少次. 信号值取值区间为32~64
(b) 硬件方式产生信号:
用户输入:比如在终端上按下组合键ctrl+C
,产生SIGINT
信号;
硬件异常:CPU检测到内存非法访问等异常,通知内核生成相应信号,并发送给发生事件的进程;
软件方式产生信号:
通过系统调用,发送signal信号,例如 kill(),raise(),sigqueue(),alarm(),setitimer(),abort()
Linux系统中常用信号:
(1)SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程。
(2)SIGINT:程序终止信号。程序运行过程中,按Ctrl+C
键将产生该信号。
(3)SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\键将产生该信号。
(4)SIGBUS和SIGSEGV:进程访问非法地址。
(5)SIGFPE:运算中出现致命错误,如除零操作、数据溢出等。
(6)SIGKILL:用户终止进程执行信号。shell下执行kill -9
发送该信号。
(7)SIGTERM:结束进程信号。shell下执行kill 进程pid
发送该信号。
(8)SIGALRM:定时器信号。
(9)SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程。
信号生命周期和处理流程:
(1)信号被某个进程产生,并设置此信号传递的对象(一般为对应进程的pid),然后传递给操作系统;
(2)操作系统根据接收进程的设置(是否阻塞)而选择性的发送给接收者,如果接收者阻塞该信号(且该信号是可以阻塞的),操作系统将暂时保留该信号,而不传递,直到该进程解除了对此信号的阻塞(如果对应进程已经退出,则丢弃此信号),如果对应进程没有阻塞,操作系统将传递此信号。
(3)目的进程接收到此信号后,将根据当前进程对此信号设置的预处理方式,暂时终止当前代码的执行,保护上下文(主要包括临时寄存器数据,当前程序位置以及当前CPU的状态)、转而执行中断服务程序,执行完成后在回复到中断的位置。当然,对于抢占式内核,在中断返回时还将引发新的调度。
信号量本质上是一个计数器,用于多进程对共享数据对象的读取,主要是用来保护共享资源(信号量也属于临界资源),使得资源在一个时刻只有一个进程独享。
原理:
由于信号量只能进行两种操作等待和发送信号,即P(sv)和V(sv)。
在信号量进行PV操作时都为原子操作(因为它需要保护临界资源)
使得多个进程可以可以直接读写同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。
为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。
由于多个进程共享一段内存,因此需要依靠某种同步机制(如信号量)来达到进程间的同步及互斥。
为什么共享内存效率高?
因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。
UNIX/Linux 程序在执行任何形式的 I/O 操作时,都是在读取或者写入一个文件描述符。一个文件描述符只是一个和打开的文件相关联的整数,它的背后可能是一个硬盘上的普通文件、FIFO、管道、终端、键盘、显示器,甚至是一个网络连接。
我们可以通过socket()
函数来创建一个网络连接,或者说打开一个网络文件,socket()
的返回值就是文件描述符。有了文件描述符,我们就可以使用普通的文件操作函数来传输数据了,例如:
用read()
读取从远程计算机传来的数据;
用write()
向远程计算机写入数据。
" 请 注 意 , 网 络 连 接 也 是 一 个 文 件 , 它 也 有 文 件 描 述 符 ! " {\color{red}"请注意,网络连接也是一个文件,它也有文件描述符!"} "请注意,网络连接也是一个文件,它也有文件描述符!"
根据数据的传输方式,可以将套接字分成两种类型:
SOCK_STREAM
表示。SOCK_STREAM
是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。
SOCK_STREAM
有以下几个特征:(基于TCP)
数据在传输过程中不会消失;
数据是按照顺序传输的;
数据的发送和接收不是同步的。
强调快速传输而非传输顺序;(基于UDP)
传输的数据可能丢失也可能损毁;
限制每次传输的数据大小;
数据的发送和接收是同步的
客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。 socket 编程
,是站在传输层的基础上,所以可以使用 TCP/UDP 协议
,但是不能干诸如「访问网页」这样的事情,因为访问网页所需要的 http 协议位于应用层。
服务器端
(1)首先服务器应用程序用系统调用socket()
来创建一个套接字,它是系统分配给该服务器进程的类似文件描述符的资源,它不能与其他的进程共享。
(2)然后,服务器进程会给套接字起个名字,我们使用系统调用bind()
来给套接字命名 绑定一个专门的监听端口,比如serverSocket.bind(new InetSocketAddress(host, port));
。然后服务器进程就开始等待客户连接到这个套接字。
(3)接下来,系统调用listen()
来创建一个队列并将其用于存放来自客户的进入连接。
(4)最后,服务器通过系统调用accept
来接受客户的连接。它会创建一个与原有的命名套接不同的新套接字,这个套接字只用于与这个特定客户端进行通信,而命名套接字(即原先的套接字)则被保留下来继续处理来自其他客户的连接(建立客户端和服务端的用于通信的流,进行通信)。比如while ((socket = serverSocket.accept()) != null)
阻塞监听。
客户端
(1)客户应用程序首先调用socket
来创建一个未命名的套接字,然后将服务器的命名套接字作为一个地址来调用connect
与服务器建立连接。
(2)一旦连接建立,我们就可以像使用底层的文件描述符那样用套接字来实现双向数据的通信(通过流进行数据传输)
七态模型在五态模型(下面介绍)的基础上增加了挂起就绪态
(ready suspend)和挂起等待态
(blocked suspend)。其产生的原因是,当系统资源尤其是内存资源已经不能满足进程运行的要求时,必须把某些进程挂起,对换到磁盘对换区中,释放它占有的某些资源。
挂起就绪态
:进程具备运行条件,但目前在外存中,只有它被对换到内存才能被调度执行。挂起等待态
:表明进程正在等待某一个事件发生且在外存中。引起进程状态转换的具体原因如下:
等待态→挂起等待态
:操作系统根据当前资源状况和性能要求,可以决定把等待态进程对换出去成为挂起等待态。
挂起等待态→挂起就绪态
:引起进程等待的事件发生之后,相应的挂起等待态进程将转换为挂起就绪态
挂起就绪态→就绪态
:当内存中没有就绪态进程,或者挂起就绪态进程具有比就绪态进程更高的优先级,系统将把挂起就绪态进程转换成就绪态。
就绪态→挂起就绪态
:操作系统根据当前资源状况和性能要求,也可以决定把就绪态进程对换出去成为挂起就绪态。
挂起等待态→等待态
:当一个进程等待一个事件时,原则上不需要把它调入内存。但是在下面一种情况下,这一状态变化是可能的。当一个进程退出后,主存已经有了一大块自由空间,而某个挂起等待态进程具有较高的优先级并且操作系统已经得知导致它阻塞的事件即将结束,此时便发生了这一状态变化。
运行态→挂起就绪态
:当一个具有较高优先级的挂起等待态进程的等待事件结束后,它需要抢占 CPU,而此时主存空间不够,从而可能导致正在运行的进程转化为挂起就绪态。另外处于运行态的进程也可以自己挂起自己。
挂起进程等同于不在内存中的进程,因此挂起进程将不参与低级调度直到它们被调换进内存。
引起进程挂起的原因是多样的,主要有:
1.终端用户的请求。当终端用户在自己的程序运行期间发现有可疑问题时,希望暂停使自己的程序静止下来(比如手动调用wait()方法
)。亦即,使正在执行的进程暂停执行;若此时用户进程正处于就绪状态而未执行,则该进程暂不接受调度,以便用户研究其执行情况或对程序进行修改。我们把这种静止状态成为“挂起状态”。
2.父进程的请求。有时父进程希望挂起自己的某个子进程,以便考察和修改子进程,或者协调各子进程间的活动。
3.负荷调节的需要。当实时系统中的工作负荷较重,已可能影响到对实时任务的控制时,可由系统把一些不重要的进程挂起,以保证系统能正常运行。
4.操作系统的需要。操作系统有时希望挂起某些进程,以便检查运行中的资源使用情况或进行记账。
5.对换的需要。为了缓和内存紧张的情况,将内存中处于阻塞状态的进程换至外存上。
三态模型
运行态→等待态 往往是由于等待外设,等待主存等资源分配
或等待人工干预
而引起的。
等待态→就绪态 则是等待的条件已满足,只需分配到处理器后就能运行。
运行态→就绪态 不是由于自身原因,而是由外界原因使运行状态的进程让出处理器,这时候就变成就绪态。例如时间片用完,或有更高优先级的进程来抢占处理器
等。
就绪态→运行态 系统按某种策略选中就绪队列中的一个进程占用处理器,此时就变成了运行态。
五态模型:
NULL→新建态:执行一个程序,创建一个子进程。
新建态→就绪态:当操作系统完成了进程创建的必要操作,并且当前系统的性能和虚拟内存的容量均允许。
运行态→终止态:当一个进程到达了自然结束点,或是出现了无法克服的错误,或是被操作系统所终结,或是被其他有终止权的进程所终结。
运行态→就绪态:运行时间片到;出现有更高优先权进程。
运行态→等待态:等待使用资源;如等待外设传输;等待人工干预。
就绪态→终止态:未在状态转换图中显示,但某些操作系统允许父进程终结子进程。
等待态→终止态:未在状态转换图中显示,但某些操作系统允许父进程终结子进程。
终止态→NULL:完成善后操作。
进程互斥、同步的概念都是并发进程下存在的概念,有了并发进程,就产生了资源的竞争与协作,从而就要通过进程的互斥、同步、通信来解决资源的竞争与协作问题。
进程互斥实际上是进程同步的一种特殊情况,即逐次使用互斥共享资源,也是对进程使用资源次序上的一种协调。进程互斥是进程间竞争共享资源的使用权,例如,若干个进程要使用同一共享资源时,任何时刻最多允许一个进程去使用,其他要使用该资源的进程必须等待,直到占有资源的进程释放该资源。
进程同步:把异步环境下的一组并发进程,因直接制约而互相合作,互相等待,使得各进程按一定的速度运行的过程称为进程间的同步。
某些进程为完成同一任务需要分工协作,由于合作的每一个进程都是独立地以不可预知的速度推进,这就需要相互协作的进程在某些协调点上协 调各自的工作。当合作进程中的一个到达协调点后,在尚未得到其伙伴进程发来的消息或信号之前应阻塞自己,直到其他合作进程发来协调信号或消息后方被唤醒并继续执行。这种协作进程之间相互等待对方消息或信号的协调关系称为进程同步。
6.僵尸进程和孤儿进程产生的原因以及解决方式?
由
fork()
创建的新进程被称为子进程(child process)。该函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是0,而父进程的返回值则是子进程的进程 id。
先介绍一下问题产生的根源,unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是:
在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号,退出状态,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait() / waitpid()系统调用来取时才释放。 但这样就导致了问题,如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。 |
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程
(进程号为1)所收养,并由init进程
对它们完成状态收集工作,所以并不会有什么危害
。
僵尸进程:一个进程使用fork
创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid
获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。
总结:
任何一个子进程(init除外)在exit()
之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。这是每个子进程在结束时都要经过的阶段。如果子进程在exit()
之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令
就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。
值得注意的是:
严格地来说,僵尸进程并不是问题的根源,罪魁祸首是产生出大量僵尸进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵尸进程时,答案就是把产生大量僵尸进程的那个元凶枪毙掉(也就是通过kill
发送SIGTERM
或者SIGKILL
信号啦)。枪毙了元凶进程之后,它产生的僵尸进程就变成了孤儿进程,这些孤儿进程会被init进程
接管,init进程
会wait()
这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程就能瞑目而去了。
补充:
The SIGTERM can also be referred as soft kill because the process that receives the SIGTERM signal may choose to ignore it. ------------kill
The SIGKILL is used for immediate termination of a process. This signal cannot be ignored or blocked. ------------------kill -9
With SIGTERM, a process gets the time to send the information to its parent and child processes. It’s child processes are handled by init.
Use of SIGKILL may lead to the creation of a zombie process because the killed process doesn’t get the chance to tell its parent process that it has received a kill signal.
【死锁定义】
死锁是指多个进程在运行过程中因争夺资源而造成的一种僵局。
四个必要条件:
互斥:
进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
不可剥夺:
进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。
请求与保持:
进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
循环等待:
存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被链中下一个进程所请求。
如何避免?
预防死锁和避免死锁的区别:
预防死锁是设法至少破坏产生死锁的四个必要条件之一,严格的防止死锁的出现,而避免死锁则不那么严格的限制产生死锁的必要条件的存在,因为即使死锁的必要条件存在,也不一定发生死锁。避免死锁是在系统运行过程中注意避免死锁的最终发生。
1.有序资源分配法
这种算法资源按某种规则系统中的所有资源统一编号(例如打印机为1、磁带机为2、磁盘为3、等等),申请时必须以上升的次序。系统要求申请进程:
1、对它所必须使用的而且属于同一类的所有资源,必须一次申请完;
2、在申请不同类资源时,必须按各类设备的编号依次申请。
2.银行家算法放在本题最后详述。
银行家算法:
银行家算法的实质就是要设法保证系统动态分配资源后不进入不安全状态,以避免可能产生的死锁。 即每当进程提出资源请求且系统的资源能够满足该请求时,系统将判断满足此次资源请求后系统状态是否安全,如果判断结果为安全(即为安全序列),则给该进程分配资源,否则不分配资源,申请资源的进程将阻塞。,
思考什么情况会 转化:系统调用?中断?各种中断如何分类?
背景知识:
往往系统的资源是固定的,例如内存2G,CPU固定,磁盘2TB,网络接口固定。所以就需要操作系统对资源进行有效的利用。假设某个应用程序过分的访问这些资源,就会导致整个系统的资源被占用,如果不对这种行为进行限制和区分,就会导致资源访问的冲突。所以,Linux的设计的初衷:给不同的操作给与不同的“权限
”。Linux操作系统就将权限等级分为了2个等级,分别就是内核态和用户态。
操作系统通过系统调用将Linux整个体系分为用户态和内核态(或者说内核空间和用户空间)。那内核态到底是什么呢?其实从本质上说就是我们所说的内核,它是一种特殊的软件程序,特殊在哪儿呢?控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。内核控制并且管理硬件资源,包括进程的调度和管理、内存管理、文件系统管理、设备驱动管理、网络管理等等。并且提供应用程序统一的系统调用接口。这种分层的架构,极大的提升了系统的稳定性和扩展性,兼容性。
内核态与用户态区别:
用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”。一个进程可以运行在用户态也可以运行在内核态,那它们之间肯定存在用户态和内核态切换的过程。打一个比方:C库接口malloc
申请动态内存,malloc
的实现内部最终还是会调用brk()
或者mmap()
系统调用来分配内存。
内核态可以执行任意命令,调用系统的一切资源,而用户态只能执行简单的运算,不能直接调用系统资源。用户态必须通过系统调用,才能向内核发出指令。
从用户态到内核态切换可以通过三种方式:
CPU将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通的应用程序只能使用那些不会造成灾难的指令。Intel的CPU将特权级别分为4个级别:RING0(内核)、RING1、RING2、RING3(用户级)。
其实系统调用本身就是中断,但是软件中断,跟硬中断不同。
关于中断:
中断有两个属性,一个称为中断号
(从0开始),一个称为中断处理程序
(Interrupt Service Routine, ISR)。不同的中断具有不同的中断号,而同时一个中断处理程序对应一个中断号。在内核中,有一个数组称为中断向量表
(Interrupt vector table),这个数组的第n项包含了指向第n号中断的中断处理程序的指针。当中断到来时,CPU会暂时中断当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成以后,CPU会继续执行之前的代码。
通常意义上,中断有两种类型,一种称为硬件中断,这种中断来自于硬件的异常或其他事件的发生,如电源掉电、键盘被按下等;另一种称为软中断,软件中断通常是一条执行(i386下是int),带有一个参数记录中断号,使用这条指令可以手动触发某个中断并执行其中断处理函数。例如在i386下,int 0x80
这条指令会调用第0x80号中断的处理程序。
由于中断号是有限的,操作系统不舍得用一个中断号来对应一个系统调用,而倾向于用一个或少数几个中断号对应所有的系统调用。例如,i386下Windows里绝大多数系统调用都是由int 0x2e
来触发的,而linux则使用int 0x80
来触发所有的系统调用。
那么问题来了,对于同一个中断号,操作系统如何知道是哪一个系统调用要被调用呢?
和中断一样,系统调用都有一个系统调用号,每个系统调用号都唯一对应一个系统调用处理函数。例如Linux里fork
的系统调用号是2,这个系统调用号在执行int指令前会被放置在某个固定的寄存器里,对应的中断代码会受到这个系统调用号,并且调用正确的函数。【延伸】
当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常
。
缺页异常:
首先明确下什么是缺页异常,CPU通过地址总线可以访问连接在地址总线上的所有外设,包括物理内存、IO设备等等,但从CPU发出的访问地址并非是这些外设在地址总线上的物理地址,而是一个虚拟地址,由MMU
将虚拟地址转换成物理地址再从地址总线上发出,MMU
上的这种虚拟地址和物理地址的转换关系是需要创建的,并且MMU
还可以设置这个物理页是否可以进行写操作,当没有创建一个虚拟地址到物理地址的映射,或者创建了这样的映射,但那个物理页不可写的时候,MMU将会通知CPU产生了一个缺页异常。(这部分涉及到的连续内存分配、非连续内存分配中的分段、分页、段页式,以及虚拟内存、地址翻译、内存换入、换出的部分没有详细展开,后面有部分论述,以后有机会再更新。)
页面置换算法:当发生缺页中断时,如果操作系统内存中没有空闲页面,则操作系统必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。而用来选择淘汰哪一页的规则叫做页面置换算法。OPT、FIFO、LRU、NRU(CLOCK)算法【拓展】
操作系统的体系结构:大内核和微内核
大内核系统将操作系统的主要功能模块都作为一个紧密联系的整体运行在核心态,从而为应用提供高性能的系统服务。因为各管理模块之间共享信息,能有效利用相互之间的有效特性,所以具有无可比拟的性能优势。
微内核的体系结构将内核中最基本的功能(如进程管理等)保留在内核,而将那些不需要在核心态执行的功能移到用户态执行,从而降低了内核的设计复杂性。而那些移出内核的操作系统代码根据分层的原则被划分成若干服务程序,它们的执行相互独立,交互则都借助于微内核进行通信。
微内核结构有效地分离了内核与服务、服务与服务,使得它们之间的接口更加清晰,维护的代价大大降低,各部分可以独立地优化和演进,从而保证了操作系统的可靠性。
但是微内核结构的最大问题是性能问题,因为需要频繁地在核心态和用户态之间进行切换,操作系统的执行开销偏大。因此有的操作系统将那些频繁使用的系统服务又移回内核,从而保证系统性能。
思考并总结拷贝次数4次 ->3次 ->2次的优化历程,抓住基本的思想。
【拓展】
零拷贝(Zero-copy)技术指在计算机执行操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域,从而可以减少上下文切换以及 CPU 的拷贝时间。它的作用是在数据报从网络设备到用户程序空间传递的过程中,减少(CPU)数据拷贝次数,减少系统调用,实现 CPU 的零参与,彻底消除 CPU 在这方面的负载。实现零拷贝用到的最主要技术是 DMA 数据传输技术
和内存区域映射技术
。
Linux 提供了轮询、I/O 中断以及 DMA 传输这 3 种磁盘与主存之间的数据传输机制。其中轮询方式是基于死循环对 I/O 端口进行不断检测。I/O 中断方式是指当数据到达时,磁盘主动向 CPU 发起中断请求,由 CPU 自身负责数据的传输过程。
read
系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。基于IO中断的DMA传输:
DMA
(Direct Memory Access)传输则在 I/O 中断的基础上引入了 DMA 磁盘控制器,由 DMA 磁盘控制器负责数据的传输,降低了 I/O 中断操作对 CPU 资源的大量消耗。DMA是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。也就是说,基于 DMA 访问方式,系统主内存与硬盘或网卡之间的数据传输可以绕开 CPU 的全程调度。
有了 DMA 磁盘控制器接管数据读写请求以后,CPU 从繁重的 I/O 操作中解脱。
read
系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。传统基于IO操作的DMA传输(4次):
以下模拟了一次读取文件并通过socket发送到网络的过程。
在 Linux 系统中,传统的访问方式是通过 write()
和 read()
两个系统调用实现的,通过 read()
函数读取文件到到缓存区中,然后通过 write()
方法把缓存中的数据输出到网络端口,整个过程涉及 2 次 CPU 拷贝、2 次 DMA 拷贝总共 4 次拷贝,以及 4 次上下文切换。
零拷贝技术:
1)mmap + write(3次)
使用 mmap
的目的是将内核读缓冲区的地址与用户缓冲区进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区拷贝到用户缓冲区的过程,然而内核读缓冲区仍需将数据写到内核中的socket 缓冲区。
基于 mmap + write
系统调用的零拷贝方式,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
mmap
主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。
mmap
的拷贝虽然减少了 1 次拷贝,提升了效率,但也存在一些隐藏的问题。当mmap
一个文件时,如果这个文件被另一个进程所截获,那么write
系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,服务器可能因此被终止。
sendfile系统调用(3次)【拓展】
sendfile
系统调用在两个文件描述符之间直接传递数据(完全在内核中操作),从而避免了数据在内核缓冲区和用户缓冲区之间的拷贝。
sendfile
系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数(只有一次系统调用)。通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝。整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
sendfile()
系统调用利用 DMA 引擎将文件中的数据拷贝到操作系统内核缓冲区中,然后数据被拷贝到与 socket 相关的内核缓冲区中去。接下来,DMA 引擎将数据从内核 socket 缓冲区中拷贝到网卡设备中去。
sendfile() 系统调用不需要将数据拷贝或者映射到应用程序地址空间中去,所以 sendfile() 只是适用于应用程序地址空间不需要对所访问数据进行处理的情况。因为sendfile
传输的数据没有越过用户应用程序 / 操作系统内核的边界线,所以sendfile ()
也极大地减少了存储管理的开销。
相比较于mmap
内存映射的方式,sendfile
少了 2 次上下文切换,但是仍然有 1 次 CPU 拷贝操作。sendfile
存在的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。
sendfile + DMA gather copy(2次DMA拷贝,0次CPU拷贝)
DMA 拷贝
引入了 gather
操作。它将内核读缓冲区中对应的数据描述信息(内存地址
、地址偏移量
)记录到相应的网络缓冲区中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中,这样就省去了内核空间中仅剩的 1 次 CPU 拷贝操作。
sendfile
拷贝方式不再从内核缓冲区的数据拷贝到 socket 缓冲区,取而代之的仅仅是缓冲区文件描述符和数据长度的拷贝,这样 DMA 引擎直接利用 gather
操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似。整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝
sendfile + DMA gather copy
拷贝方式同样存在用户程序不能对数据进行修改的问题,而且本身需要硬件的支持(只拷贝fd和长度),它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。
splice系统调用
splice
系统调用,不仅不需要硬件支持,还通过可以在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道
(pipeline),从而避免了两者之间的 CPU 拷贝操作,实现了两个文件描述符之间的数据零拷贝。整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝
splice
拷贝方式也同样存在用户程序不能对数据进行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备。
写时复制
写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中。这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。这种方法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进行更改,那么也就永远不需要拷贝。
缓冲区共享
fbuf 的思想是每个进程都维护着一个缓冲区池,这个缓冲区池能被同时映射到用户空间(user space)和内核态(kernel space),内核和用户共享这个缓冲区池,这样就避免了一系列的拷贝操作。
总结:
重点掌握epoll,底层结构红黑树 + 链表,能够说出为什么在管理数百万连接的过程中效率还好,以及ET模式和LT模式。
再推荐一篇关于同步、异步、阻塞、非阻塞IO讲的较为清楚的文章。
缓存I/O:
数据会先被拷贝到操作系统的内核缓冲区中,然后才会从操作系统内核缓冲区拷贝到应用程序的地址空间。
同步I/O与异步I/O:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;
recvfrom
这个系统调用,kernel就开始了IO的第一个阶段:准备数据
(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝
到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。blocking IO的特点就是在IO执行的两个阶段都被block了
当用户进程发出read
操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read
操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read
操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
nonblocking IO的特点是用户进程需要不断的主动询问kernel数据是否已准备好。
select/epoll
的优势在于单个process可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll
这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read
操作,将数据从kernel拷贝到用户进程。
所以,如果处理的连接数不是很高的话,使用
select/epoll
的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll
的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,
select()
函数就可以返回。
用户进程发起read
操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read
操作完成了。
同步IO与异步IO区别:
是否有CPU深度的参与、是否将所有IO操作交给了内核。这里的同步和异步是针对用户和内核的交互性来看的。同步IO操作的模型里面,比如阻塞式IO、非阻塞式IO、IO复用、信号驱动式IO中,它们的第一步骤是不相同的,但是第二步都阻塞在了将数据从内核缓冲区拷贝到用户缓冲区的IO操作上,而真正的异步IO的过程只有发起和用户进程被等待通知,中间是没有任何阻塞的,它的IO操作需要CPU深度参与。所以按照严格定义的阻塞来看,异步是不存在阻塞的,而同步在真正的IO操作中将阻塞进程
I/O 多路复用之select、poll、epoll详解:
1.select:
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
select
函数监视的文件描述符分3类,分别是writefds
、readfds
、和exceptfds
。调用后select
函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有exceptions),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。
(1)每次调用select
,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select
都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
(3)单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
2.poll
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
struct pollfd {
int fd; /* file descriptor /
short events; / requested events to watch /
short revents; / returned events witnessed */
};
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select
函数一样,poll
返回后,需要轮询pollfd来获取就绪的描述符。
poll
本质上和select
没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
epoll:
1)调用epoll_create
()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl
向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait
收集发生的事件的连接
int epoll_create(int size);
//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1
//的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//函数是对指定描述符fd执行op操作。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int
timeout);
//等待epfd上的io事件,最多返回maxevents个事件。
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
epoll
的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以快速返回,并有效的将发生事件的句柄给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。所以,epoll_wait
非常高效。
这个准备就绪list链表是怎么维护的呢?
当我们执行epoll_ctl
时,除了把socket放到epoll
文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数
,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后,就来把socket插入到准备就绪链表里了。【拓展】
总结:
红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create
时,创建了红黑树和就绪链表,执行epoll_ctl
时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait
时立刻返回准备就绪链表里的数据即可。
LT模式和ET模式:
epoll
对文件描述符的操作有两种模式:LT
(level trigger)和ET
(edge trigger)。LT
模式与ET
模式的区别如下:
LT
模式:当epoll_wait
检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait
时,会再次响应应用程序并通知此事件。ET
模式:当epoll_wait
检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait
时,不会再次响应应用程序并通知此事件。使用LT模式(默认)意味着只要fd处于可读或者可写状态,每次epoll_wait都会返回该fd,这样的话会带来很大的系统开销,且处理时候每次都需要把这些fd轮询一遍,如果fd的数量巨大,不管有没有事件发生,epoll_wait都会触发这些fd的轮询判断。
在ET模式下,当有事件发生时,系统只会通知你一次,即在调用epoll_wait
返回fd后,不管这个事件你处理还是没处理,处理完没有处理完,当再次调用epoll_wait
时,都不会再返回该fd,这样的话程序员要自己保证在事件发生时要及时有效的处理完该事件。
但是,在使用ET模式的时,需要循环调用recv
,send
等处理函数,得保证其事件处理完毕,这样也会带来开销且容易出错。从 kernel 代码来看,ET/LT模式的处理逻辑几乎完全相同,差别仅在于LT
模式在 event 发生时不会将其从 ready list 中移除,略为增大了event 处理过程中 kernel space 中记录数据的大小。
总结:
在
select/poll
中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll
事先通过epoll_ctl
()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速把这个句柄放入list中,当进程调用epoll_wait
() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。
epoll的优点:
监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max
察看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。
IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。
如果没有大量的idle -connection或者dead-connection
,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection
,就会发现epoll的效率大大高于select/poll。
内存拷贝,利用mmap()
文件映射内存加速与内核空间的消息传递,即epoll使用mmap
(通过内核和用户空间共享一块内存来实现的)减少复制开销。【拓展】
示例代码(仅示意):
第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。
第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,
返回0标识成功,返回-1表示失败。
第三步:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。
#define IPADDRESS "127.0.0.1"
#define PORT 8787
#define MAXSIZE 1024
#define LISTENQ 5
#define FDSIZE 1000
#define EPOLLEVENTS 100
listenfd = socket_bind(IPADDRESS,PORT);
struct epoll_event events[EPOLLEVENTS];
//创建一个描述符
epollfd = epoll_create(FDSIZE);
//添加监听描述符事件
add_event(epollfd,listenfd,EPOLLIN);
//循环等待
for ( ; ; ){
//该函数返回已经准备好的描述符事件数目
ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
//处理接收到的连接
handle_events(epollfd,events,ret,listenfd,buf);
}
//事件处理函数
static void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf)
{
int i;
int fd;
//进行遍历;这里只要遍历已经准备好的io事件。num并不是当初epoll_create时的FDSIZE。
for (i = 0;i < num;i++)
{
fd = events[i].data.fd;
//根据描述符的类型和事件类型进行处理
if ((fd == listenfd) &&(events[i].events & EPOLLIN))
handle_accpet(epollfd,listenfd);
else if (events[i].events & EPOLLIN)
do_read(epollfd,fd,buf);
else if (events[i].events & EPOLLOUT)
do_write(epollfd,fd,buf);
}
}
//添加事件
static void add_event(int epollfd,int fd,int state){
struct epoll_event ev;
ev.events = state;
ev.data.fd = fd;
epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev);
}
//处理接收到的连接
static void handle_accpet(int epollfd,int listenfd){
int clifd;
struct sockaddr_in cliaddr;
socklen_t cliaddrlen;
clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);
if (clifd == -1)
perror("accpet error:");
else {
printf("accept a new client: %s:%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port); //添加一个客户描述符和事件
add_event(epollfd,clifd,EPOLLIN);
}
}
//读处理
static void do_read(int epollfd,int fd,char *buf){
int nread;
nread = read(fd,buf,MAXSIZE);
if (nread == -1) {
perror("read error:");
close(fd); //记住close fd
delete_event(epollfd,fd,EPOLLIN); //删除监听
}
else if (nread == 0) {
fprintf(stderr,"client close.\n");
close(fd); //记住close fd
delete_event(epollfd,fd,EPOLLIN); //删除监听
}
else {
printf("read message is : %s",buf);
//修改描述符对应的事件,由读改为写
modify_event(epollfd,fd,EPOLLOUT);
}
}
//写处理
static void do_write(int epollfd,int fd,char *buf) {
int nwrite;
nwrite = write(fd,buf,strlen(buf));
if (nwrite == -1){
perror("write error:");
close(fd); //记住close fd
delete_event(epollfd,fd,EPOLLOUT); //删除监听
}else{
modify_event(epollfd,fd,EPOLLIN);
}
memset(buf,0,MAXSIZE);
}
//删除事件
static void delete_event(int epollfd,int fd,int state) {
struct epoll_event ev;
ev.events = state;
ev.data.fd = fd;
epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev);
}
//修改事件
static void modify_event(int epollfd,int fd,int state){
struct epoll_event ev;
ev.events = state;
ev.data.fd = fd;
epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev);
}
关于底层数据结构的补充:
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
每一个
epoll
对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl
方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback
,它会将发生的事件添加到rdlist双链表中。
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:【拓展】
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
FINALLY,通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。
虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。
【拓展】
页面是主存物理空间中划分出来的等长的固定区域。分页方式的优点是页长固定,因而便于构造页表、易于管理,且不存在外碎片。但分页方式的缺点是页长与程序的逻辑大小不相关。例如,某个时刻一个子程序可能有一部分在主存中,另一部分则在辅存中。这不利于编程时的独立性,并给换入换出处理、存储保护和存储共享等操作造成麻烦。
另一种划分可寻址的存储空间的方法称为分段。段是按照程序的自然分界划分的长度可以动态改变的区域。通常,程序员把子程序、操作数和常数等不同类型的数据划分到不同的段中,并且每个程序可以有多个相同类型的段。
段页式存储组织是分段式和分页式结合的存储组织方法,这样可充分利用分段管理和分页管理的优点。
用户进程申请并访问物理内存(或磁盘存储空间)的过程总结如下:
虚拟内存是连接分段分页的桥梁!
用户角度,虚拟地址分成与程序逻辑相关的不同的段(段表),实际执行时,段又会分成不同的页,分布在内存或磁盘中,涉及到换入换出,这个过程由
MMU
操作页表(PCB
中)完成由逻辑地址向物理地址的转换(地址翻译)。
在用户进程和物理内存(磁盘存储器)之间引入虚拟内存主要有以下的优点:
库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:静态库
(.a、.lib)和动态库
(.so、.dll)。 windows上对应的是.lib .dll linux上对应的是.a .so。
静态库
在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。试想一下,静态库与汇编生成的目标文件一起链接为可执行文件,那么静态库必定跟.o文件格式相似。其实一个静态库可以简单看成是一组目标文件(.o/.obj文件)的集合,即很多目标文件经过压缩打包后形成的一个文件。静态库存在的问题如下:
空间浪费
。更新、部署和发布带来麻烦
。如果静态库liba.lib更新了,所以使用它的应用程序都需要重新编译、发布给用户(对于使用者来说,可能是一个很小的改动,却导致整个程序重新下载,全量更新
)。动态库在程序编译时并不会被连接到目标代码中,而是在程序运行时才被载入
。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费
问题。动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布也会带来麻烦的问题。用户只需要更新动态库即可,增量更新
。【拓展】
兄弟连视频笔记【链接】
----------> Enjoy it!