在UNIX的世界中一切皆文件,文件本质上是一串二进制流。在数据交换过程中,需要对流进行数据的收发操作也就是I/O输入输出操作(Input/Output
)。
由于程序和运行时数据在内存中驻留,由CPU的计算核心来执行,涉及到数据交换在磁盘、网络时也需要IO。
文件描述符
对于不同的流如何才能辨别标识呢?做到这个的就是文件描述符fd
,文件描述符是一个整数,对这个整数的操作就是对流的操作。
文件描述符fd
(File Descriptor
)是一个用于表述指向文件的引用的抽象化概念,文件描述符在形式上是一个非负整数。实际上它是一个索引值,指向内核Kernel
为每个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或创建一个新文件时,内核会向进程返回一个文件描述符。
在程序设计中,涉及底层的程序编写往往会围绕着文件描述符展开,但是文件描述符的概念仅适用于Linux这样类UNIX操作系统中。
虚拟内存
现代操作系统均采用虚拟存储器,32位操作系统的寻址空间(虚拟存储空间)为4GB(2的32次方)。
操作系统的核心是内核kernel
,是独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核安全,操作系统将虚拟空间划分为两个部分:内核空间、用户空间。
在Linux操作系统中,会将最高的1G字节(从虚拟地址0xC0000000
到0xFFFFFFFF
)提供给内核kernel
使用,称之为内核空间。将较低的3G字节(从虚拟地址0x00000000
到0xBFFFFFFF
)提供给进程使用,称之为用户空间。
内核空间中存放的是内核代码和数据,进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。
Linux提供了两级保护机制:0级供内核使用、3级供用户程序使用
操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,两者不能简单地使用指针传递数据。因为Linux使用的虚拟内存机制,必须通过系统调用请求内核来协助完成IO动作。
内核会为每个IO设备维护一个缓冲区,用户空间的数据可能被换出,当内核空间使用用户空间指针时对应的数据可能不再内存中。
对于一个输入操作来说,进程IO系统调用后内核会先去看缓冲区中有没有相应的缓存数据,若有数据则会直接复制到进程空间中,若没有的话会到设备中读取,因为设备IO一般速度较慢需要等待。
Linux系统中的每次IO都需要经过两个阶段
- 内核准备数据
将数据从磁盘文件中加载到内核内存空间(内核缓冲区),等待数据准备完毕,耗时较长。 - 将数据从内核拷贝到用户空间
将数据从内核缓冲区复制到用户空间进程的内存中,耗时较短。
IO包括内存IO、网络IO、磁盘IO三种,常说的IO是指后两者。以文件IO为例,一个IO读过程是文件数据从“磁盘-内核缓存区-用户内存”的过程。
缓存IO
缓存IO又称为标准IO,大多数文件系统默认的IO操作都是缓存IO,在Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的页缓存page cache
。数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓存区拷贝到应用程序的地址空间中。这种做法的缺点是需要在应用程序地址空间和内核进行多次拷贝,这些拷贝动作所带来的CPU以及内存开销是非常大的。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为称为进程的切换。任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
进程切换的过程会经过以下变化
- 保存处理机上下文,包括程序计数器和其它寄存器。
- 更新PCB信息
- 将进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
- 选择另外一个进程执行并更新PCB
- 更新内存管理的数据结构
- 恢复处理机上下文
进程阻塞
正在执行的进程由于期待的某些事件未发生,比如请求系统资源失败、等待操作的完成、新数据尚未到达、无新工作做等时会由操作系统自动执行阻塞原语Block
,使自己由运行状态变为阻塞状态。由此可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU)才可以转变为阻塞状态。当进程进入阻塞状态后是不会占用CPU资源的。通俗来说,就是要等别人做完后你才能继续工作。
同步与异步
由于CPU和内存的速度远高于外设,所以IO编程中存在严重不匹配的问题。比如将100M数据写入磁盘,CPU输出100M数据只需0.01秒,磁盘接收却需要10秒,怎么办?有两种办法可以解决这个问题:
- 让CPU等着,也就是程序暂停执行后续代码,等写入完成后再接着后续执行,这种模式称为同步IO。
- CPU不等待只是告诉磁盘:“你慢慢写不着急,我先干别的事儿去了!”,于是后续代码立即执行,这种模式称为异步IO。
- 同步和异步关注的是消息通信机制
- 同步与异步描述的是用户线程与内核的交互方式
- 同步
synchronous
是指用户线程发起IO请求后需要等待或轮询内核IO操作完成后才能继续执行。 - 异步
asynchronous
是指用户线程发起IO请求后仍然继续执行,当内核IO操作完成后会通知用户线程或调用用户线程注册的回调函数。
同步与异步的区别
- 数据从“内核缓存区-用户内存”这个过程是否需要用户进程等待,实际IO读写是否阻塞请求进程。
- 是否等待IO执行的结果,使用异步IO来编写程序性能会远远高于同步IO,但异步IO的缺点是编程模型复杂。
阻塞与非阻塞
- 阻塞与非阻塞关注的是调用者在等待结果返回之前所处的状态
- 阻塞与非阻塞描述的是用户线程调用内核IO操作的方式
- 阻塞
blocking
是指IO操作需要彻底完成后才返回到用户空间,调用结果返回之前调用者被挂起。 - 非阻塞
noblocking
是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。 - 阻塞与非阻塞是函数或方法的实现方式,在数据就绪之前是立即返回还是等待,发起IO请求是否会被阻塞。
网络IO
网络应用需要处理的两大类问题是网络IO和数据计算。相对于数据计算,网络IO的延迟会给应用带来性能上的瓶颈大于后者。
网路IO的本质是socket
的读取操作,socket
在Linux操作系统中被抽象为流stream
。IO可以理解为对流的操作。IO编程中Stream
流是一个重要的概念,可以把流想象成水管中的水,只能单向流向。Input Stream
是数据从外部(如磁盘、网络等)流进内存,Output Steam
是数据从内存流到外设。
对于一次磁盘IO访问,比如以read
为例,数据会先被拷贝到操作系统内核的缓冲区,然后才会从系统内核缓冲区拷贝到应用程序的地址空间中。所以说,当一个read
读操作发生时,它会经历两个阶段:等待数据准备、将数据从内核拷贝到进程中
网络IO除了转入内核调用外,与传统的磁盘IO不同的是,网络IO的读写对于socket
流而言大致可分为两个阶段:
- 等待:等待网络上的数据分组到达,然后复制到内核的某个缓冲区。
- 复制:将数据从内核缓冲区复制到应用进程缓冲区
如果内核空间缓冲区中已经有数据了就可以省略掉第一步,为什么不能直接让磁盘控制器将数据送到应用程序的地址空间中呢?因为应用程序不能直接操作底层硬件。
相比于传统的网络IO,普通文件描述符的操作可分为两步,以read
读操作为例,利用read()
函数从socket
中同步阻塞的读取数据。
需要注意的是不要使用操作磁盘文件IO的经验去看待网络IO,为什么呢?
因为实际上在磁盘IO中等待阶段是不存在的,因为磁盘文件并不像网络IO那样,需要等待远程传输数据。所以,习惯操作磁盘IO的开发者开始无法理解同步阻塞IO的工作过程,也无法理解为什么read()
方法不会返回。
客户端发起一个HTTP请求,服务器处理响应HTTP请求,此过程再服务器以网络IO的角度看经历了哪些阶段?
服务器构建网络数据包触发IO的过程
- 用户空间进程通过
recvfrom
函数接收等待接收数据包,并将接收到的数据包在内核中通过四表五链检查网络状态,若通过网络检查则提交给用户空间的HTTP进程。 - HTTP进程解析请求并发起系统调用
read
函数,到达内核空间。 - 内核空间执行
read
函数读取磁盘内容并将此内容加载到内存 - 内核空间提交给用户空间HTTP进程并告知数据已经
read
完毕 - 用户空间HTTP进程根据请求报文进行构建响应报文
- 构建完HTTP响应报文后通知内核空间构建网络封装
- 内核空间再次通过四表五链网络状态,通过网卡发送构建号的HTTP响应报文。
单纯根据流程可得到以下信息
- 单进程接收响应数据报文调用
recvform
函数时,与此同时不执行其它函数调用,此时严重影响效率。 - 当内核空间执行
read
函数后提交给用户空间进行HTTP封装,最后调用内核空间进行网络封装构建并发送报文。
根据上述流程可发现一次网络IO在逻辑上实际上是处于两种状态的,在这两种状态上进行优化IO才可以优化整体的性能。这两种形态在Linux网络编程中定义如下:
- 等待数据准备
从逻辑上看是内核网络驱动等待接收网络数据包,表现在内核形态上,同时也是数据流可得信息的第一步。在用户空间封装完毕后如何通知内核再次进行网络报文的构建,在此状态下诞生了两种状态同步synchronous
和异步asynchronous
,同步是为进程自己主动等待函数执行成功并返回消息后才能继续执行其它函数,异步为函数执行完毕后主动通知进程执行其它流程,最后内核空间对网络IO进行解封装。
- 将数据从内核拷贝到进程中
逻辑意义上是内核空间调用并执行系统调用函数后,将执行的结果反馈给用户空间,让用户空间进行构建响应报文。用户空间的进程能够执行其它函数,从而提升整体性能。在此状态下诞生了两种状态阻塞blocking
和非阻塞nonblocking
,阻塞状态是指IO操作需要彻底完成后才能返回到用户空间,调用结果返回之前调用者被挂起,即进程调用函数后被挂起直到函数返回结果后才能执行其它操作,非阻塞状态是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成,最终的调用结果返回之前调用者不会被挂起,即进程在执行函数后无需等待执行结果仍可继续执行其它函数。
IO模型
为什么会出现IO模型呢?
IO操作根据设备类型一般分为内存IO、网络IO、磁盘IO,其中内存IO的速度是最快的,计算机的性能瓶颈一般不在内存IO上。尽管网络IO可通过购买独享带宽和高速网卡来提升速度,磁盘IO可使用RAID磁盘整列来提升磁盘IO的速度。但是由于IO操作都是由系统内核调用来完成的,系统调用是又通过CPU来调度的。由于CPU的速度远远快于IO操作,导致浪费CPU宝贵的时间来等待慢速的IO操作。为了让快速的CPU和慢速IO设备能更好的协调工作,减少CPU在IO调用的上的消耗,逐渐发展出各种IO模型。
阻塞IO模型BIO blocking I/O
同步阻塞IO模型是最常用也是最简单的IO模型,在Linux系统中默认情况下,所有的套接字socket
都是阻塞的。这里的阻塞是指当前发起IO操作的进程会被阻塞,同步阻塞IO是指当进程调用某些IO操作的系统调用或库函数时,比如accept()
、send()
、recv()
等时进程会暂停下来等待IO操作结束后再继续运行。
同步阻塞IO中进程的等待时间可能会包含两部分:一个是等待数据就绪,比如等待数据可以读和可以写。另一个是等待数据的复制,当数据准备就绪后对数据的读写操作会比较耗时。
传统的IO模型,在读写数据过程中会发生阻塞现象。当用户线程发出IO请求后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程会处于阻塞状态,用户线程交出CPU。当数据就绪后内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才结束阻塞状态。
在内核将数据准备好之前,系统调用会一直等待所有的socket
,默认是阻塞方式。
典型应用
Linux中如果不对文件描述符fd
做特殊设置,直接调用read
就是同步阻塞IO,同步阻塞IO的两个阶段都需要等待完成后,read
才会返回。
read(fd, buffer, count)
也就是说,如果远程一直没有发送消息,read
就永远不会有返回,整个线程就会阻塞在这里。
data = socket.read()// 如果数据没有就绪会一直阻塞在read方法
如果数据没有就绪会一直阻塞在read方法
fd = connect();// 文件描述符
write(fd);
read(fd);
close(fd);
程序的read
必须在write
之后执行,当write
阻塞住了,read
就不能执行下去,一直处于等待状态。
网络模型
阻塞IO模型中应用程序为了执行read
读操作,会调用相应的system call
系统调用,将系统控制权交给内核,然后就进入等待,等待的过程是被阻塞的,内核开始执行system call
系统调用,执行完毕后会向应用程序返回响应,应用程序得到响应后就不再阻塞并继续后续工作。
上图所示
进程调用一个recvfrom
请求,但不会立即收到回复,直到数据返回后才将数据从内核空间复制到程序空间。
当用户进程调用了recvfrom
系统调用,内核kernel
就开始了IO的第一个阶段“准备数据”。对于网络IO来说很多时候数据在一开始还没有到达。比如还没有收到一个完整的UDP包,此时内核kernel
要等待足够的数据到来。磁盘IO的情况就是等待磁盘数据从磁盘上读取到内核态内存中。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。
用户进程这边整个进程会被阻塞,当然是进程自己选择的阻塞。当内核一直等到数据准备就绪后,就会将数据从内核中拷贝到用户内存中,处于系统安全考虑,用户态的程序是没有权限直接读取内核态内存,因此内核负责将内核态内存中的数据拷贝一份到用户态内存中。然后内核返回结果,用户进程才会接触阻塞block
的状态,重新运行起来。所以,阻塞时IO的特点就是在IO执行的两个阶段都被阻塞了。
优缺点
- 优点:实时性高能够及时返回数据,响应及时无延迟。
- 缺点:需要阻塞等待且性能差,对用户来说等待就要付出性能代价。
适用场景
BIO方式适用于连接数量较少且固定的架构,这种方式对服务器资源要求比较高,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务端就需要启动一个线程进行处理。如果这个连接不做任何事情则会造成不必要的线程开销,当然可以通过线程池机制加以改善。
非阻塞IO模型NIO noblocking I/O
同步非阻塞IO对比同步阻塞IO而言,它不会去等待数据的就绪,如果数据不可读或不可写,相关的系统调用会立即高速进程并立即返回。这样做的好处是结合反复轮询来尝试数据是否就绪,那么在一个进程中就可以同时处理多个IO操作。
非阻塞IO一般只针对网络IO有效,当在socket
的选项中设置O_NONBLOCK
时,此时socket
的send()
或recv()
就会采用非阻塞方式。对于磁盘IO非阻塞IO并不会产生效果。为什么呢?
- 文件描述符
fd
在read
之前有可能会重新进入不可读的状态,要么被其他人都走了(惊群问题),还有可能被内核抛弃了。总的来说,fd
因为在read
之前数据被其它方式读走,fd
重新变为不可读。此时使用阻塞时IO的read
函数就会阻塞整个线程。 -
epoll
只是返回了可读事件并没有返回可以读多少数据量,因此非阻塞IO的做法是都多次直到不能读。而阻塞式IO却只能读一次,因为万一一次就读完了缓冲区的所有数据,第二次读的时候read
就会有阻塞了。对于epoll
的ET模式来说,缓冲区的数据只会在改变时通知一次,如果此次没有消费完,在下次数据到来之前,可读事件再就也不会通知。这对只能调用一次的read
的阻塞式IO来说,未读完的数据就有可能永远读不到了。
实现原理
当用户线程发起一个read
读操作后并不需要等待,而是马上就得到一个结果。如果结果是一个error
错误,就表示数据还没有准备好,于是可以再次发送read
读操作。一旦内存中的数据准备好了并且又再次收到用户线程的请求,那么会马上就将数据拷贝到用户线程然后返回。事实上,在非阻塞IO模型中,用户线程需要不断询问内核数据是否就绪,换句话说非阻塞IO不会交出CPU而会一直占用CPU。
与阻塞时I/O不同的是,非阻塞的recvfrom
系统调用执行后,进程并不会被阻塞,内核会立即返回给进程。如果数据还未就绪(准备好),此时会返回一个错误error
(EAGAIN
或EWOULDBLOCK
)。
进程在返回之后可以处理其它业务逻辑,过会儿再发起recvfrom
系统调用。采用这种轮询的方式不断检查内核数据,直到数据准备就绪,再拷贝数据到进程进行数据处理。
上图所示:前三次调用recvfrom
请求都没有数据返回,内核每次都返回一个错误errno
(EWOULDBLOCK
), 但并不会阻塞进程。当第四次调用recvfrom
时数据已经准备就绪,则将其从内核空间拷贝到程序空间进行处理数据。
值得注意的是,在非阻塞状态下,IO执行的等待阶段并不是完全阻塞的,但第二个阶段依然处于一个阻塞状态。
典型应用
while(true)
{
data = socket.read();
if(data != error)
{
//handle data
break;
}
}
对于非阻塞IO有一个非常严重的问题是在while
循环中需要不断地去询问内核数据是否就绪,这样会导致CPU占用率非常高,因此一般情况下很少使用while
循环这种方式来读取数据。
网络模型
当用户进程发出read
读操作时会调用相应的system call
,这个system call
会立即从内核中返回。但在返回的这个时间点中内核中的数据可能还没有准备好,也就是说内核只是很快就返回了system call
,只有这样才不会阻塞用户进程。对于应用程序,虽然这个IO操作很快就返回了,但并不知道这个IO操作是否真正成功了,为了知道IO操作是否成功,应用程序需要主动循环的去询问内核。
每次客户询问内核是否有数据准备好,即文件描述符缓冲区是否就绪。当有数据报准备好时,就进行拷贝数据报。当没有数据报准备好时也不会阻塞程序,内核直接返回未准备就绪的信号,等待用户程序的下一个轮询。但轮询对CPU来说是较大的浪费,一般只有在特定场景下才使用。
优缺点
- 优点:能够在等待的事件里去做其它的事情
- 缺点:任务完成得响应延迟增大了,因为每过一段时间去轮询一次
read
读操作 ,而任务可能在两次轮询之间的任意时间完成,这将导致整体数据吞吐量的降低。
同步非阻塞与同步阻塞之间有什么优缺点呢?
- 优点:同步非阻塞能够在等待任务完成得时间里做其它事情,包括提交其它任务,也就是说“后台”可以有多个任务在同时执行。
- 缺点:同步非阻塞任务完成得响应时间延迟增大了,因为每过一段时间需要去轮询一次
read
读操作,任务可能在两次轮询之间的任意时间中已经完成了。这将导致整体数据吞吐量的降低。
多路复用IO模型 I/O multiplexing
多路复用IO模型是目前使用较多的一种模型,Java NIO实际上就是多路复用IO。
IO复用也叫做多路IO就绪通知,是一种进程预先告知内核的能力,内核发现进程指定的一个或多个IO条件就绪了,就会去通知进程,使得一个进程能在一连串的事件上等待。
简单来说,就是指定一个线程,通过记录IO流的状态来同时管理多个IO,以提高服务器的吞吐能力。IO多路复用的好处在于单个进程就可以同时处理多个网络连接的IO。
IO多路复用的基本原理是不再由应用程序自己监视连接,取而代之由内核替应用程序监视文件描述符。
多路IO就绪通知模型允许进程通过一种方法同时监视所有的文件描述符,并能快速获得所有就绪的文件描述符,然后针对这些文件描述符进行数据访问。简单来说,就是提供了对大量文件描述符就绪检查的高性能方案。
需要注意的是,IO就绪模型只是解决了快速获取就绪的文件描述符的问题,在得知数据就绪后,就数据访问本身而言,还是需要选择阻塞或非阻塞的访问方式。
实现方式
由于平台和历史原因,多路IO就绪通知有多种不同的实现方式,性能上也存在一定的差异。IO 复用的实现方式主要包括select
、poll
、epoll
。
- select
select
最早出现于1934年BSD4.2中,通过一个select()
系统调用来监视包含多个文件描述符的数组,当select()
返回后这个数组中就绪的文件描述符会被内核修改标志位,使得进程可以获得这些文件描述符,从而进行后续的读写操作。
select
的缺点在于单进程能够监视的文件描述符的数量存在最大限制,在Linux上一般是1024,不过可以通过修改宏定义或重新编译内核的方式来提升限制。所以,如果使用select()
的服务器已经维持了1024个连接,后续的请求可能会被拒绝。
另外,select()
维护着存储大量文件描述符的数据结构,随着文件描述符数量的增加,复制开销也线性增长。
另一方面,由于网络延迟使大量TCP连接处于非活跃状态,调用select()
会对所有的socket
进行一次线性扫描,也会浪费一定的开销。
- poll
poll
诞生于1986年的System V Release3,显然UNIX不愿意直接沿用BSD的select
,而是重新实现了一遍。poll
和select
本质上没有太多区别,只是poll
没有最大文件描述符数量的限制。
select
和poll
的原理基本相同:
- 注册待监听的文件描述符
fd
,这里的fd
创建时最好是非阻塞的。 - 每次调用都去检查
fd
文件描述符的状态,当有一个或多个fd
就绪时返回。 - 返回结果中包含已就绪和未就绪的文件描述符
fd
相比select
,poll
解决了单进程能够打开文件描述符数量有限的问题,由于select
受限于FD_SIZE
的限制,若修改FD_SIZE
宏需重新编译内核。poll
通过一个pollfd
数组向内核传递需要关注的事件,避开了文件描述符的数量限制。
此外,select
和poll
共同具有一个很大的缺点是包含大量文件描述符fd
的数组会被整体复制到用户态和内核态地址空间之间,不论这些文件描述符fd
是否就绪,其开销会随着文件描述符fd
数量增多而线性增大。
另外,select()
和poll()
将就绪的文件描述符fd
告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()
或poll()
时会再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。
- epoll
而epoll
的出现解决select
和poll
的缺点:
-
epoll
基于事件驱动的方式避免了每次都要将所有fd
都扫描一遍 -
epoll_wait
只返回就绪的fd
-
epoll
使用nmap
内存映射技术避免了内存复制的开销 -
epoll
的fd
数量上限是操作系统的最大文件句柄数量,此数量和内存相关,通常大于1024。
目前epoll
是Linux2.6下最高效的IO复用方式,也是Nginx、Node的IO实现方式。
- 水平触发与边缘触发
此外,对于IO复用还有一个水平触发和边缘触发的概念:
- 水平触发:当就绪的
fd
未被用户进程处理后,下一次查询依旧会返回,这是select
和poll
的触发方式。 - 边缘触发:无论就绪的
fd
是否被处理,下一次不再返回。
理论上边缘触发的性能更高,但是实现相当复杂,任何以外的丢失事件都会造成请求处理错误。epoll
默认采用水平触发的方式,可通过配置选项可使用边缘触发。
实现原理
- 当进程调用
select
时会被阻塞 - 此时内核会监视所有
select
负责的socket
,当socket
的数据准备就绪后立即返回 - 进程再次调用
read
读操作,数据从内核中拷贝到进程。
在多路复用IO模型中会有一个线程不断去轮询多个socket
的状态,只有当socket
真正有读写事件时,才真正调用实际的IO读写操作。
在多路复用IO模型中,只需要使用一个线程就可以管理多个socket
,系统不需要建立新的线程或进程,也不必维护这些进程和线程,只有在真正有socket
读写事件进行时才会使用IO资源,所以它大大减少了资源占用。
上图所示:这里需要两个系统调用system call
分别是select
和recvfrom
,阻塞IO只调用了一个系统调用recvfrom
。如果处理的连接数不是很高的话,使用IO复用的服务器并不一定比使用“多线程+非阻塞IO”的性能更好,可能延迟还更大。
IO复用的优势并不是对于单个连接能处理的更快,而是单个进程就可以同时处理多个网络连接的IO。实际使用时,对于每个socket
都可以设置为非阻塞的。
上图所示,整个用户进程其实是一直被阻塞的。只不过进程是被select
函数阻塞,而不是被IO操作给阻塞。所以IO多路复用是阻塞在select
、epoll
这样的系统调用之上,而没有阻塞在整整的IO系统调用如recvfrom
上。
网路模型
当用户进程发出read
读操作时会调用相应的system call
之后,并不等待内核的返回结果而时立即返回。虽然返回结果的调用函数是一个异步的方式,但应用程序会被像select
、poll
、epoll
等具有多个文件描述符的函数阻塞住,一直等到system call
有结果返回后再通知应用程序。这种情况从IO操作的实际效果来看,异步阻塞IO和同步阻塞IO是一样的,应用程序都是一直等到IO操作成功后(数据已经被写入或读取),才开始进行后续工作。不同点在于异步阻塞IO用一个select
函数可以为多个文件描述符提供通知,提供了并发性。
例如:如果有1w个并发的read
读请求,但网络上仍然没有数据,此时这1w个read
会同时各自阻塞,现在用select
、poll
、epoll
这样的函数来专门负责阻塞同时监听这1w个请求的状态,一旦有数据到达就负责通知,这样就将1w个等待和阻塞转化为一个专门的函数来负责与管理。
IO多路复用多了一个select
函数,select
函数有一个参数是文件描述符集合,对这些文件描述符进行循环监听,当某个文件描述符就绪时就对这个文件描述符进行处理。其中select
只负责等,recvfrom
只负责拷贝。IO多路复用属于阻塞IO,但可以对多个文件描述符进行阻塞监听,所以效率比阻塞IO要高。
异步IO与同步IO的区别
同步IO是需要应用程序主动地循环去询问是否有数据,异步IO是通过像select
等IO多路复用函数来同时检测多个事件句柄来告知应用程序是否有数据。
高并发的程序一般使用“同步非阻塞”模式,而不是“多线程+同步阻塞”模式。要理解这一点需要先弄清楚并发和并行的区别。并发数是同时进行的任务数,并行数是可以同时工作的物理资源数量(如CPU核数)。
通过合理调度任务的不同阶段,并发数可以远远大于并行数。这就是区区几个CPU可以支持上万用户并发请求的原因。在这种高并发的情况下,为每个用户请求创建一个进程或线程的开销非常大,而同步非阻塞方式可以把多个IO请求丢到后台去,这样CPU就可以服务大量的并发IO请求了。
IO多路复用究竟是同步阻塞还是异步阻塞模型呢?
同步是需要主动等待消息通知,异步则是被动接受消息通知,通过回调、通知、状态等方式来被动获取消息。IO多路复用在阻塞到select
阶段时,用户进程是主动等待并调用select
函数来获取就绪状态消息,并且其进程状态为阻塞。所以IO多路复用是同步阻塞模式。
优势
与传统的多进程或多线程模型相比,IO多路复用的最大优势是系统开销小,系统无需创建新的进程或线程,也无需维护这些进程和线程的运行,因此降低了系统的维护工作量并节省了系统资源。
应用场景
- 服务器需要同时处理多个处于监听状态或多个连接状态的套接字
- 服务器需要同时处理多种网络协议的套接字
- 服务器需要监听多个端口或处理多种服务
- 服务器需要同时处理用户输入和网络连接
信号驱动IO模型singal blocking I/O
在信号驱动IO模型中,当用户线程发起一个IO请求操作, 会给对应的socket
注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。
业务流程
- 开启套接字信号驱动IO功能
- 系统调用
sigaction
执行信号处理函数,信号处理函数是非阻塞的会立即返回。 - 数据就绪并生成
sigio
信号,通过信号回调通知应用读取数据。
网络模型
应用程序提交read
读请求后调用system call
,然后内核开始处理相应的IO操作。同时应用程序并不等内核返回响应就会开始执行其它的处理操作(应用程序没有被IO阻塞)。
当内核执行完毕返回read
响应,会产生一个信号或执行一个基于回调函数来完成这次IO处理过程。在这里IO的读写操作是在IO事件发生之后由应用程序来完成的。异步IO读写操作总是立即返回,不论IO是否阻塞,因为真正的读写操作已经由内核掌管。
也就是说,同步IO模型要求用户自行执行IO操作(将数据从内核缓冲区移动到用户缓冲区,或相反),异步操作机制则由内核来执行IO操作。简单来说,同步IO向应用程序通知的是IO就绪事件,而异步IO向应用程序通知的是IO完成事件。
信号驱动IO模型中应用程序告诉内核,当数据包准备好的时候,给我发送一个信号,对SIGIO
信号进行捕捉,并且调用我的信号处理函数来获取数据报。
问题缺陷
信号驱动IO模式存在一个很大的问题是Linux中信号队列是有限的,如果超过限制则无法读取数据。
异步IO模型
异步IO又叫做事件驱动IO,异步IO操作是需要操作系统底层支持。
异步IO模型是最理想的IO模型,在异步IO模型中当用户线程发起read
读操作后立即就可以开始去做其它的事情。从内核角度看,当内核收到一个asynchronous read
之后会立即返回,说明read
请求已经成功发起了,因此不会对用户线程产生任何阻塞block
。
然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read
读操作完成了。也就是说用户线程完全不需要知道实际整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时,表示IO操作已经完成可以直接去使用数据了。
在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成的,然后发送一个信号告知用户线程操作已经完成。用户线程中不需要再次调用IO函数进行具体的读写。这点和信号驱动模型有所不同。在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作,在异步IO模型中,收到信号表示IO操作已经完成,不需要再在用户线程中调用IO函数进行实际读写操作。
异步IO和异步概念一样,当一个异步过程调用发出后,调用者不能立即得到结果,实际处理这个调用的函数在完成后,通过状态、通知、回调函数来通知调用者的IO操作。
异步IO的工作机制是告知内核启动某个操作,并让内核在整个操作完成后通知我们,这种模型与信号驱动的IO区域在于,信号驱动IO是由内核通知我们何时可以启动 一个IO操作,这个IO操作由用户自定义的信号函数来实现,而异步IO模型是由内核告知我们IO操作何时完成。
小结
前四种IO模型实际上都属于同步IO,只有最后一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第二阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程会让用户线程阻塞。
另外根据阻塞程度效率由低到高的顺序是:阻塞IO > 非阻塞IO > 多路复用IO > 信号驱动IO > 异步IO
IO设计模式
传统IO设计模式
在传统网路服务设计模式中,有两种经典的模式:多线程、线程池
多线程模式
多线程模式简单来说就是来了客户端服务器就会建立一个线程来处理该客户端的读写事件
多线程模型虽然处理简单但由于服务器为每个客户端的连接都建立一个线程去处理,资源占用非常大。因此当连接数达到上限时,后续的用户连接请求将会直接导致资源瓶颈,严重的可能会直接导致服务器崩溃。
线程池模式
为了解决这种“一个线程对应一个客户端”模式带来的弊端,提出了线程池的方式,也就是说创建一个固定大小的线程池,来一个客户端就从线程池中获取一个空闲的线程来处理,当客户端处理完读写操作之后就交出对线程的占用。这样就避免为每个客户端创建线程带来的资源浪费,使得线程可以复用。
线程池的弊端在于如果连接池中大多是长连接可能会导致一段时间内,线程池中的线程都被占用,再有用户请求连接时由于没有可用的空闲线程来处理,会导致客户端连接失败,从而影响用户体验,因此线程持比较适合大量的短连接的应用。
高性能IO设计模式
Reactor
在Reactor模式中会先对每个客户端注册感兴趣的事件,然后有一个线程专门去轮询每个客户端是否有事件发生,当有事件发生时便顺序处理每个事件,当所有事件都处理完毕后便再转去继续轮询。
IO模型中的多路复用IO模型采用的就时Reactor模式
Proactor
在Proactor模式中当检测到有事件发生时会新起一个异步操作,然后交由内核线程去处理,当内核线程完成IO操作之后,发送一个通知告知操作已经完成。IO模型中的异步IO模型采用的就时Proactor模式。
未完待续...