1、线程,进程和协程的区别☆
线程是指进程内的一个执行单元,也是进程内可调度的实体。
1、线程和进程的区别
线程 | 进程 | |
---|---|---|
拥有资源 | 不拥有资源,但是可以访问隶属进程的资源 | 资源分配的基本单位 |
调度 | 独立调度的基本单位,比进程更小的独立运行基本单位 | cpu调度和分派的基本单位 |
系统开销 | 开销小,线程切换只需保存和设置少量寄存器内容。 | 开销大,创建和撤销时,都需要为之分配资源或回收资源如内存空间,I/O资源等,进行进程切换时,涉及当前执行进程CPU环境的保存及新调度进程CPU环境的设置 |
通信方面 | 可以直接读写同一进程中的数据进行通信 | 需要借助IPC(Inter-Process Communication)进程间通信 |
2、协程和线程进行比较
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文切换非常的快。
1)一个线程可以有多个协程,一个进程也可以单独拥有多个协程。
2)线程进程都是同步机制,而协程是异步机制。
3)协程能够保留上一次调用的状态,每次过程重入时,就相当于进入上一次调用的状态实例:项目使用到了多个进程的和每个进程下有多个线程,线程之间通信方便,而进程之间需要共享内存、消息队列等方式。
2、进程间的通信方式有哪些?☆
消息队列,共享内存,无名管道,命名管道,信号量,socket
1.父子进程关系通道pipe:
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程中使用。进程的亲缘关系通常是指父子进程的关系。
2.命名管道FIFO:
有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
3.消息队列:
消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
4.共享内存:
共享内存就是映射一段能被其它进程所访问的内存,这段共享内存由一个进程创建,但是多个进程都可以访问。共享内存是最快的IPC方式,它是针对其它进程间通信方式效率低而专门设计的,它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
5.信号量semaphore:
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问资源的时候,其他进程也访问该资源。因此主要作为进程间以及同一进程内不同线程之间的同步机制。
6.套接字 Socket
与其他通信机制不同的是,它可用于不同机器间的进程通信。
7.信号(signal):
信号是一种比较复杂的通信方式,用于通知接收进程某个事件已发生。
3.进程调度算法
进程调度任务过程:
(1)首先保存当前进程的处理机的现场信息。
(2)按照算法选取进程。
(3)把处理器分配给进程。(将选中的进程的进程控制块内有关处理机现场的信息装入处理机相应寄存器中,进程控制处理机,使之从上次的断点处恢复运行)
1、先来先服务和短作业(进程)优先调度算法。
批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。
1、FCFS(first-come first-servered):
优点:易于实现,且相对公平。
缺点:比较有利于长进程,而不利于短进程。
2、SJF(shortest job first):
优点:平均周转时间最短,进程等待时间缩短,可以增大系统吞吐量。
缺点:难以准确预估进程执行时间,开销较大,不利于长进程,有可能出现“饥饿”现象。
3、最短剩余时间优先SRTN(shortest remaining time next):
按估计剩余时间最短的顺序进行调度。
2、高优先权优先调度算法
为了照顾紧迫型作业,使之在进入系统后便获得优先处理,引入了最高优先权优先(FPF)调度算法。当把该算法用于作业调度时,系统将从后备队列中选择若干个优先权最高的作业装入内存。当用于进程调度时,该算法是把处理机分配给就绪队列中优先权最高的进程,这时又可以进一步把算法分为以下两种:
1、非抢占式优先权算法。
主要用于批处理系统, 也可用于一些某些对实时性要求不高的实时系统。
2、抢占式优先权调度算法。
可以更好的满足紧迫作业的要求,故而常用于要求比较严格实时系统中,以及对性能要求比较高的批处理和分时系统。
3、高响应比优先调度算法:
//例题
计算在单CPU环境下,采用高响应比优先调度算法时的平均周转时间和平均带权周转时间,并指出它们的调度顺序。
批处理系统:
等待时间=上一个的完成时间-该作业到达的时刻
响应比=(等待时间+服务时间)/服务时间=等待时间/服务时间+1
周转时间 =(作业完成的时间 - 作业提交时间);
带权周转时间 = 作业周转时间 / 作业运行时间;
平均周转时间 = (周转时间1+周转时间2+…+周转时间n)/ n;
平均带权周转时间 = (带权周转时间1+带权周转时间2+…+带权周转时间n)/ n;
(1)如果作业等待时间相同,则服务时间越短,其优先权越高,因而该算法有利于短作业。
(2)当要求服务的时间相同时,作业的优先权决定于其等待时间,等待时间越长,其优先权越高,因而它实现的是先来先服务。
(3)对于长作业,作业的优先级可以随等待时间的增加而提高,当其等待时间足够长时,其优先级便可提高,从而也可获得处理机。简言之,就是该算法既照顾了短作业,又考虑了作业到达的先后顺序,也不会使长作业长期得不到服务。因此,该算法实现了一种比较好的折衷。当然,再利用该算法时,没要进行调度之前,都须先做响应比的计算,这会增加系统开销。
4、基于时间片的轮转调度算法:
交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速的进行响应。
(1)时间片轮转(RR)
一.轮转法的基本原理:
根据先来先服务的原则,将需要执行的所有进程按照到达时间的大小排成一个升序的序列,每次都给一个进程同样大小的时间片,在这个时间片内如果进程执行结束了,那么把进程从进程队列中删去,如果进程 没有结束,那么把该进程停止然后改为等待状态,放到进程队列的尾部,直到所有的进程都已执行完毕
二.进程的切换
时间片够用:意思就是在该时间片内,进程可以运行至结束,进程运行结束之后,将进程从进程队列中删除,然后启动新的时间片
时间片不够用:意思是在该时间片内,进程只能完成它的一部分任务,在时间片用完之后,将进程的状态改为等待状态,将进程放到进程队列的尾部,等待cpu的调用
三.关于时间片大小的选择
时间片过小,则进程频繁切换,会造成cpu资源的浪费
时间片过大,则轮转调度算法就退化成了先来先服务算法
(2)多级反馈队列(比较先进调度算法)
基本概念
多级反馈队列调度算法是一种根据先来先服务原则给就绪队列排序,为就绪队列赋予不同的优先级数,不同的时间片,按照优先级抢占CPU的调度算法。算法的实施过程如下:
- 按照先来先服务原则排序,设置N个就绪队列为Q1,Q2…QN,每个队列中都可以放很多作业;
- 为这N个就绪队列赋予不同的优先级,第一个队列的优先级最高,第二个队列次之,其余各队列的优先权逐个降低;
- 设置每个就绪队列的时间片,优先权越高,算法赋予队列的时间片越小。时间片大小的设定按照实际作业(进程)的需要调整;
- 进程在进入待调度的队列等待时,首先进入优先级最高的Q1等待。
- 首先调度优先级高的队列中的进程。若高优先级中队列中已没有调度的进程,则调度次优先级队列中的进程。例如:Q1,Q2,Q3三个队列,只有在Q1中没有进程等待时才去调度Q2,同理,只有Q1,Q2都为空时才会去调度Q3。
- 对于同一个队列中的各个进程,按照时间片轮转法调度。比如Q1队列的时间片为N,那么Q1中的作业在经历了时间片为N的时间后,若还没有完成,则进入Q2队列等待,若Q2的时间片用完后作业还不能完成,一直进入下一级队列,直至完成。
- 在低优先级的队列中的进程在运行时,又有新到达的作业,那么在运行完这个时间片后,CPU马上分配给新到达的作业即抢占式调度CPU。
应用范围
此算法应用于同一个资源的多个使用者可分优先级使用资源的情况。
使用方法及步骤
假设系统中有3个就绪队列Q1,Q2,Q3,时间片分别为2,4,8。
现在有3个作业J1,J2,J3分别在时间 0 ,1,3时刻到达。而它们所需要的CPU时间分别是3,2,1个时间片。
1、时刻0: J1到达。于是进入到队列1 , 运行1个时间片 , 时间片还未到,此时J2到达。
2、时刻1: J2到达。 由于时间片仍然由J1掌控,于是等待。 J1在运行了1个时间片后,已经完成了在Q1中的2个时间片的限制,于是J1置于Q2等待被调度。现在处理机分配给J2。
3、时刻2: J1进入Q2等待调度,J2获得CPU开始运行。
4、时刻3:J3到达,由于J2的时间片未到,故J3在Q1等待调度,J1也在Q2等待调度。
5、时刻4:J2处理完成,由于J3,J1都在等待调度,但是J3所在的队列比J1所在的队列的优先级要高,于是J3被调度,J1继续在Q2等待。
6、时刻5:J3经过1个时间片,完成。
7、时刻6:由于Q1已经空闲,于是开始调度Q2中的作业,则J1得到处理器开始运行。
8、时刻7:J1再经过一个时间片,完成了任务。于是整个调度过程结束。
4、死锁和处理方法
死锁必要条件:(可以引用Java并发)
互斥:每个资源要么已经分配给了一个进程,要么就是可用的,
占有和等待:已经得到了某个资源的进程可以再请求新的资源。
不可抢占:已经分配给一个进程的资源不能强制性的被抢占,它只能被占有它的进程显示地释放。
环路等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源。
处理方法:
鸵鸟策略:处理死锁问题的方法仅仅是忽略它。
死锁检测与死锁恢复:不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复,利用抢占恢复,回滚恢复,通过杀死进程恢复。
死锁预防:破坏死锁必要条件。
死锁避免:程序运行期间避免死锁。
3、什么是活锁?
活锁恰恰与死锁相反,死锁是大家都拿不到资源都占用着对方的资源,而活锁是拿到资源却又相互释放不执行,当多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又得不到执行,这就是活锁。
5、内存管理☆
虚拟内存
1.Linux虚拟内存技术:
(1)虚拟内存,物理内存,页表,内存管理单元。
操作系统将内存抽象成虚拟的地址空间,每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每个块称为一页,这些页被映射到物理内存中(页框),同时,就必须把页码和存放该页映像的页框填入一个叫做页表的表项中,其中,页表的表项中设置一些访问控制字段,用于指明对应页面中的内容允许何种操作,从而禁止非法访问。内存管理单元(MMU)管理着虚拟内存的地址和物理内存的转换,其中页表(page table)存储着页(程序地址空间)和页框(物理内存空间)的映射表。
功能:
进程内存管理
它有助于进程进行内存管理,主要体现在:
- **内存完整性:**由于虚拟内存对进程的”欺骗”,每个进程都认为自己获取的内存是一块连续的地址。我们在编写应用程序时,就不用考虑大块地址的分配,总是认为系统有足够的大块内存即可。
- **安全:**由于进程访问内存时,都要通过页表来寻址,操作系统在页表的各个项目上添加各种访问权限标识位,就可以实现内存的权限控制。
数据共享
通过虚拟内存更容易实现内存和数据的共享。
在进程加载系统库时,总是先分配一块内存,将磁盘中的库文件加载到这块内存中,在直接使用物理内存时,由于物理内存地址唯一,即使系统发现同一个库在系统内加载了两次,但每个进程指定的加载内存不一样,系统也无能为力。而在使用虚拟内存时,系统只需要将进程的虚拟内存地址指向库文件所在的物理内存地址即可。如上文图中所示,进程 P1 和 P2 的 B 地址都指向了物理地址 C。
而通过使用虚拟内存使用共享内存也很简单,系统只需要将各个进程的虚拟内存地址指向系统分配的共享内存地址即可。
SWAP
虚拟内存可以让帮进程”扩充”内存。
我们前文提到了虚拟内存通过缺页中断为进程分配物理内存,内存总是有限的,如果所有的物理内存都被占用了怎么办呢?Linux 提出 SWAP 的概念,Linux 中可以使用 SWAP 分区,在分配物理内存,但可用内存不足时,将暂时不用的内存数据先放到磁盘上,让有需要的进程先使用,等进程再需要使用这些数据时,再将这些数据加载到内存中,通过这种”交换”技术,Linux 可以让进程使用更多的内存。
附加:
虚拟内存
毋庸置疑,虚拟内存绝对是操作系统中最重要的概念之一。我想主要是由于内存的重要”战略地位”。CPU太快,但容量小且功能单一,其他 I/O 硬件支持各种花式功能,可是相对于 CPU,它们又太慢。于是它们之间就需要一种润滑剂来作为缓冲,这就是内存大显身手的地方。
而在现代操作系统中,多任务已是标配。多任务并行,大大提升了 CPU 利用率,但却引出了多个进程对内存操作的冲突问题,虚拟内存概念的提出就是为了解决这个问题。
上图是虚拟内存最简单也是最直观的解释。
操作系统有一块物理内存(中间的部分),有两个进程(实际会更多)P1 和 P2,操作系统偷偷地分别告诉 P1 和 P2,我的整个内存都是你的,随便用,管够。可事实上呢,操作系统只是给它们画了个大饼,这些内存说是都给了 P1 和 P2,实际上只给了它们一个序号而已。只有当 P1 和 P2 真正开始使用这些内存时,系统才开始使用辗转挪移,拼凑出各个块给进程用,P2 以为自己在用 A 内存,实际上已经被系统悄悄重定向到真正的 B 去了,甚至,当 P1 和 P2 共用了 C 内存,他们也不知道。
操作系统的这种欺骗进程的手段,就是虚拟内存。对 P1 和 P2 等进程来说,它们都以为自己占用了整个内存,而自己使用的物理内存的哪段地址,它们并不知道也无需关心。
分页和页表
虚拟内存是操作系统里的概念,对操作系统来说,虚拟内存就是一张张的对照表,P1 获取 A 内存里的数据时应该去物理内存的 A 地址找,而找 B 内存里的数据应该去物理内存的 C 地址。
我们知道系统里的基本单位都是 Byte 字节,如果将每一个虚拟内存的 Byte 都对应到物理内存的地址,每个条目最少需要 8字节(32位虚拟地址->32位物理地址),在 4G 内存的情况下,就需要 32GB 的空间来存放对照表,那么这张表就大得真正的物理地址也放不下了,于是操作系统引入了 页(Page)
的概念。
在系统启动时,操作系统将整个物理内存以 4K 为单位,划分为各个页。之后进行内存分配时,都以页为单位,那么虚拟内存页对应物理内存页的映射表就大大减小了,4G 内存,只需要 8M 的映射表即可,一些进程没有使用到的虚拟内存,也并不需要保存映射关系,而且Linux 还为大内存设计了多级页表,可以进一页减少了内存消耗。操作系统虚拟内存到物理内存的映射表,就被称为页表
。
内存寻址和分配
我们知道通过虚拟内存机制,每个进程都以为自己占用了全部内存,进程访问内存时,操作系统都会把进程提供的虚拟内存地址转换为物理地址,再去对应的物理地址上获取数据。CPU 中有一种硬件,内存管理单元 MMU(Memory Management Unit)
专门用来将翻译虚拟内存地址。CPU 还为页表寻址设置了缓存策略,由于程序的局部性,其缓存命中率能达到 98%。
以上情况是页表内存在虚拟地址到物理地址的映射,而如果进程访问的物理地址还没有被分配,系统则会产生一个缺页中断
,在中断处理时,系统切到内核态为进程虚拟地址分配物理地址。
(2)请页和交换
请页:
当处理器试图访问一个虚存页面时,首先到页表中去查询该页是否已映射到物理页框中,并记录在页表中。如果在,则MMU会把页码转换成页框码,如果不在,MMU就会通知操作系统:发生了一个页面访问错误,接下来系统会启动所谓的请表机制,即调用相应的系统操作函数,判断该虚拟地址是否为有效地址。
如果是有效地址,就从虚拟内存中将该地址指向的页面读入到内存中的一个空闲页框中,并在页表中添加上相对应的表项,最后处理器将从发生页面错误的地方重新开始运行。如果是无效的地址,则表明进程在试图访问一个不存在的虚拟地址,此时操作系统将终止此次访问。
交换(SWAP):
请页成功后,存在已没有空闲物理页框的情况,启动所谓地“交换”机制,即调用相应的内核操作函数,在物理页框中寻找一个当前不在使用或者近期可能不会用到的页面所占据的页框。找到后,就把其中的页移出,以装载新的页面。如果该页未被修改过,则删除它;如果该页曾经被修改过(脏页),则系统必须将该页写回磁盘。
(3)页面置换算法
页面置换算法,就是要选出最合适的一个物理页框,将其淘汰或者存储到磁盘中,使得置换的效率最高。
置换算法有:最佳置换算法OPT、先进先出算法FIFO、近期最久未用过算法LRU、 CLOCK置换算法NRU,页面缓冲算法PBA、近期少使用算法LFU。
和缓存淘汰策略类似,可以将内存看成磁盘的缓存。在缓存系统中,缓存的大小有限,当有新的缓存到达时,需要淘汰一部分已经存在的缓存,这样才有空间新的缓存数据。(LRU缓存淘汰算法)为了实现LRU,需要在内存中维护一个所有页面的链表(leetcode中有个题,实现LRU,利用HashMap+双向链表,map的key值表示着链表中的每个节点的位置)。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面是最近最久未访问的。
(4)加快分页过程
如何提高虚拟内存和物理地址的映射速度?(快表)
系统一旦访问了某个页面,那么系统就会在一段时间内稳定地工作在这个页上。所以,为了提高访问页表的速度,系统还配备了一组正好能容纳一个页表的硬件寄存器,这样当系统再访问虚存时,就首先到这组硬件寄存器中去访问,系统速度就快多了。这组存放在当前页表的寄存器叫做快表。
如果页表很大怎么解决?
采用多级页表,对页表也进行分页存储,在程序运行时只把需要的页复制到内存,而暂时不需要的页就让它留在辅存中。
2、分页与分段的比较
分页 | 分段 | |
---|---|---|
对程序员是否透明 | 透明 | 需要程序员显示划分每个段 |
地址空间维度 | 一维地址空间 | 二维,程序员在标识一个地址的时候,段名和段内地址都需要给出。 |
大小是否可改变 | 不可变 | 可动态改变 |
出现原因 | 实现虚拟内存,从而获得更大地址空间 | 使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。 |
6、IO管理☆
一文读懂高性能网络编程中的I/O模型
1、网络请求处理过程
第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
2、IO模型(与java的IO、NIO、AIO一起学习)
介绍操作系统的 I/O 模型之前,先了解一下几个概念:
阻塞、非阻塞
-
1)阻塞调用与非阻塞调用;
-
2)阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回;
-
3)非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
两者的最大区别在于被调用方在收到请求到返回结果之前的这段时间内,调用方是否一直在等待。
阻塞是指调用方一直在等待而且别的事情什么都不做;非阻塞是指调用方先去忙别的事情。
同步处理与异步处理:
同步处理是指被调用方得到最终结果之后才返回给调用方;异步处理是指被调用方先返回应答,然后再计算调用结果,计算完最终结果后再通知并返回给调用方。
阻塞、非阻塞和同步、异步的区别(阻塞、非阻塞和同步、异步其实针对的对象是不一样的):
- 1)阻塞、非阻塞的讨论对象是调用者;
- 2)同步、异步的讨论对象是被调用者。
recvfrom 函数:
recvfrom 函数(经 Socket 接收数据),这里把它视为系统调用。
一个输入操作通常包括两个不同的阶段:
- 1)等待数据准备好;
- 2)从内核向进程复制数据。
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
实际应用程序在系统调用完成上面的 2 步操作时,调用方式的阻塞、非阻塞,操作系统在处理应用程序请求时,处理方式的同步、异步处理的不同,可以分为 5 种 I/O 模型(下面的章节将逐个展开介绍)。(参考《UNIX网络编程卷1》)
3、分类:
1、I/O模型1:阻塞式 I/O 模型(blocking I/O)
在阻塞式 I/O 模型中,应用程序在从调用 recvfrom 开始到它返回有数据报准备好这段时间是阻塞的,recvfrom 返回成功后,应用进程开始处理数据报。
**比喻:**一个人在钓鱼,当没鱼上钩时,就坐在岸边一直等。
**优点:**程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用 CPU 资源。
**缺点:**每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。
2、I/O模型2:非阻塞式 I/O 模型(non-blocking I/O)
在非阻塞式 I/O 模型中,应用程序把一个套接口设置为非阻塞,就是告诉内核,当所请求的 I/O 操作无法完成时,不要将进程睡眠。
而是返回一个错误,应用程序基于 I/O 操作函数将不断的轮询数据是否已经准备好,如果没有准备好,继续轮询,直到数据准备好为止。
**比喻:**边钓鱼边玩手机,隔会再看看有没有鱼上钩,有的话就迅速拉杆。
**优点:**不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。
**缺点:**轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。
3、I/O模型3:I/O 复用模型(I/O multiplexing)
在 I/O 复用模型中,会用到 Select 或 Poll 函数或 Epoll 函数(Linux 2.6 以后的内核开始支持),这两个函数也会使进程阻塞,但是和阻塞 I/O 有所不同。
这两个函数可以同时阻塞多个 I/O 操作,而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。
**比喻:**放了一堆鱼竿,在岸边一直守着这堆鱼竿,没鱼上钩就玩手机。
**优点:**可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样可以大大节省系统资源。
**缺点:**当连接数较少时效率相比多线程+阻塞 I/O 模型效率较低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会有增加。
众所周之,Nginx这样的高性能互联网反向代理服务器大获成功的关键就是得益于Epoll。
4、I/O模型4:信号驱动式 I/O 模型(signal-driven I/O)
在信号驱动式 I/O 模型中,应用程序使用套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。
当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。
**比喻:**鱼竿上系了个铃铛,当铃铛响,就知道鱼上钩,然后可以专心玩手机。
**优点:**线程并没有在等待数据时被阻塞,可以提高资源的利用率。
**缺点:**信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。
信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失。
5、I/O模型5:异步 I/O 模型(即AIO,全称asynchronous I/O)
由 POSIX 规范定义,应用程序告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到应用程序的缓冲区)完成后通知应用程序。
这种模型与信号驱动模型的主要区别在于:信号驱动 I/O 是由内核通知应用程序何时启动一个 I/O 操作,而异步 I/O 模型是由内核通知应用程序 I/O 操作何时完成。
**优点:**异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠。
**缺点:**要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。
而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 IO 复用模型模式为主。
关于AOI的介绍,请见:《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》。
总结:
(1)阻塞式I/O:应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。
(2)非阻塞是I/O:应用进程可以继续执行,但是需要不断的执行系统调用来获知I/O是否完成,这种方式称为轮询(polling)。
(3)I/O 复用(select,poll,epoll):单个进程具有处理多个I/O事件的能力(Java中NIO的能力)。
(4)信号驱动式I/O(SIGIO):内核在数据到达时向应用进程发送SIGIO信号。
(5)异步I/O(AIO):内核完成所有操作后向应用进程发送信号。(Linux2.6才引入,目前AIO并不完善,因此在Linux下实现高并发网络编程时都是以I/O复用模型模式为主。)
从上图中我们可以看出,越往后,阻塞越少,理论上效率也是最优。这五种 I/O 模型中,前四种属于同步 I/O,因为其中真正的 I/O 操作(recvfrom)将阻塞进程/线程,只有异步 I/O 模型才与 POSIX 定义的异步 I/O 相匹配。(POSIX定义:应用程序告知内核启动某个操作,并让内核在整个操作,包括将数据从内核拷贝到应用程序的缓冲区,完成后通知应用程序。)
4、I/O复用技术详解
多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫I/O多路复用,这里的复用是指复用同一个进程/线程。
IO多路复用的三种机制Select,Poll,Epoll
(1)Select:
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
参数:
int maxfdp1 指定待测试的文件描述字个数,它的值是待测试的最大描述字加1。
fd_set *readset , fd_set *writeset , fd_set *exceptset
fd_set
可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄。中间的三个参数指定我们要让内核测试读、写和异常条件的文件描述符集合。如果对某一个的条件不感兴趣,就可以把它设为空指针。
const struct timeval *timeout timeout
告知内核等待所指定文件描述符集合中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。
【返回值】
int 若有就绪描述符返回其数目,若超时则为0,若出错则为-1
select运行机制
select()的机制中提供一种fd_set
的数据结构,实际上是一个long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读。
从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
select机制的问题
- 每次调用select,都需要把
fd_set
集合从用户态拷贝到内核态,如果fd_set
集合很大时,那这个开销也很大 - 同时每次调用select都需要在内核遍历传递进来的所有
fd_set
,如果fd_set
集合很大时,那这个开销也很大 - 为了减少数据拷贝带来的性能损坏,内核对被监控的
fd_set
集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)
(2)Poll
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。也就是说,poll只解决了上面的问题3,并没有解决问题1,2的性能开销问题。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
typedef struct pollfd {
int fd; // 需要被检测或选择的文件描述符
short events; // 对文件描述符fd上感兴趣的事件
short revents; // 文件描述符fd上当前实际发生的事件
} pollfd_t;
poll改变了文件描述符集合的描述方式,使用了pollfd
结构而不是select的fd_set
结构,使得poll支持的文件描述符集合限制远大于select的1024
【参数说明】
struct pollfd *fds fds
是一个struct pollfd
类型的数组,用于存放需要检测其状态的socket描述符,并且调用poll函数之后fds
数组不会被清空;一个pollfd
结构体表示一个被监视的文件描述符,通过传递fds
指示 poll() 监视多个文件描述符。其中,结构体的events
域是监视该文件描述符的事件掩码,由用户来设置这个域,结构体的revents
域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域
nfds_t nfds 记录数组fds
中描述符的总数量
【返回值】
int 函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;
(3)Epoll
epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式,相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
Linux中提供的epoll相关函数如下:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
1. epoll_create函数创建一个epoll句柄,参数size
表明内核要监听的描述符数量。调用成功时返回一个epoll句柄描述符,失败时返回-1。
2. epoll_ctl 函数注册要监听的事件类型。四个参数解释如下:
epfd
表示epoll句柄- op表示fd操作类型,有如下3种
- EPOLL_CTL_ADD 注册新的fd到epfd中
- EPOLL_CTL_MOD 修改已注册的fd的监听事件
- EPOLL_CTL_DEL 从epfd中删除一个fd
fd
是要监听的描述符event
表示要监听的事件
epoll_event 结构体定义如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
3. epoll_wait 函数等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。
epfd
是epoll句柄events
表示从内核得到的就绪事件集合maxevents
告诉内核events的大小timeout
表示等待的超时事件
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
- **水平触发(LT):**默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件
- 边缘触发(ET): 当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。
LT和ET原本应该是用于脉冲信号的,可能用它来解释更加形象。Level和Edge指的就是触发点,Level为只要处于水平,那么就一直触发,而Edge则为上升沿和下降沿的时候触发。比如:0->1 就是Edge,1->1 就是Level。
ET模式很大程度上减少了epoll事件的触发次数,因此效率比LT模式下高。
总结:
一张图总结一下select,poll,epoll的区别:
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
底层实现 | 数组 | 链表 | 红黑树 |
IO效率 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1) |
最大连接数 | 1024(x86)或2048(x64) | 无上限 | 无上限 |
fd拷贝 | 每次调用select,都需要把fd集合从用户态拷贝到内核态 | 每次调用poll,都需要把fd集合从用户态拷贝到内核态 | 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝 |
epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。
无聊趣闻:
既然select,poll,epoll都是I/O多路复用的具体的实现,之所以现在同时存在,其实他们也是不同历史时期的产物
- select出现是1984年在BSD里面实现的
- 14年之后也就是1997年才实现了poll,其实拖那么久也不是效率问题, 而是那个时代的硬件实在太弱,一台服务器处理1千多个链接简直就是神一样的存在了,select很长段时间已经满足需求
- 2002, 大神 Davide Libenzi 实现了epoll