详解磁盘IO、网络IO、零拷贝IO、BIO、NIO、AIO、IO多路复用(select、poll、epoll)

文章很长,但是很用心!

文章目录

1. 什么是I/O

在计算机操作系统中,所谓的I/O就是输入(Input)和输出(Output),也可以理解为读(Read)和写(Write),针对不同的对象,I/O模式可以划分为磁盘IO模型和网络IO模型。

IO操作会涉及到用户空间和内核空间的转换,先来理解以下规则:

  • 内存空间分为用户空间和内核空间,也称为用户缓冲区和用户缓冲区
  • 用户的应用程序不能直接操作内核空间,需要将数据从内核空间拷贝到用户空间才能使用
  • 无论是read操作,还是write操作,都只能在内核空间里执行
  • 磁盘IO和网络IO请求加载到内存的数据都是先放在内核空间的

再来看看所谓的读(Read)和写(Write)操作:

  • 读操作:操作系统检查内核缓冲区有没有需要的数据,如果内核缓冲区已经有需要的数据了,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用。如果内核缓冲区没有需要的数据,对于磁盘IO,直接从磁盘中读取到内核缓冲区(这个过程可以不需要cpu参与)。而对于网络IO,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从Socket协议找中读取客户端发送的数据到内核空间,然后把内核空间的数据copy到用户空间,供应用程序使用。
  • 写操作:用户的应用程序将数据从用户空间copy到内核空间的缓冲区中(如果用户空间没有相应的数据,则需要从磁盘—>内核缓冲区—>用户缓冲区依次读取),这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘或通过网络发送出去,由操作系统决定。除非应用程序显示地调用了sync命令,立即把数据写入磁盘,或执行flush()方法,通过网络把数据发送出去。
  • 绝大多数磁盘IO和网络IO的读写操作都是上述过程,除了后面要讲到的零拷贝IO。

2. 磁盘IO

磁盘IO的流程如下图所示:

(1)读操作

当应用程序调用read()方法时,操作系统检查内核缓冲区中是否存在需要的数据,如果存在,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用。如果内核缓冲区没有需要的数据,通过通过DMA方式(一种IO设备控制方式,下面会讲解)从磁盘中读取数据到内核缓冲区,然后由CPU控制,把内核空间的数据copy到用户空间。

这个过程会涉及到两次缓冲区copy,第一次是从磁盘的缓冲区到内核缓冲区,第二次是从内核缓冲区到用户缓冲区(或应用缓冲区),第一次是cpu的copy,第二次是DMA的copy。

(2)写操作

当应用程序调用write()方法时,应用程序将数据从用户空间copy到内核空间的缓冲区中(如果用户空间没有相应的数据,则需要从磁盘—>内核缓冲区—>用户缓冲区依次读取),这时对用户程序来说写操作就已经完成,至于什么时候把数据再写到磁盘(从内核缓冲区到磁盘的写操作也由DMA控制,不需要cpu参与),由操作系统决定。除非应用程序显示地调用了sync命令,立即把数据写入磁盘。

如果应用程序没准备好写的数据,则必须先从磁盘读取数据才能执行写操作,这时会涉及到四次缓冲区的copy,第一次是从磁盘的缓冲区到内核缓冲区,第二次是从内核缓冲区到用户缓冲区,第三次是从用户缓冲区到内核缓冲区,第四次是从内核缓冲区写回到磁盘。前两次是为了读,后两次是为了写。这其中有两次cpu拷贝,两次DMA copy。

(3)磁盘IO的延时

为了读或写,磁头必须能移动到所指定的磁道上,并等待所指定的扇区的开始位置旋转到磁头下,然后再开始读或写数据。磁盘IO的延时分成以下三部分:

  • 寻道时间:把磁头移动到指定磁道上所经历的时间
  • 旋转延迟时间 :指定扇区移动到磁头下面所经历的时间
  • 传输时间 :数据的传输时间(数据读出或写入的时间)

img

3. 网络IO

网络IO的流程如下:

(1)读操作

网络IO的既可以从物理磁盘中读数据,也可以从socket中读数据(从网卡中获取)。当从物理磁盘中读数据的时候,其流程和磁盘IO的读操作一样。当从socket中读数据,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从Socket协议找中读取客户端发送的数据到内核空间(这个过程也由DMA控制),然后把内核空间的数据copy到用户空间,供应用程序使用。

(2)写操作

为了简化描述,我们假设网络IO的数据从磁盘中获取,读写操作的流程如下:

  • 当应用程序调用read()方法时,通过DMA方式将数据从磁盘拷贝到内核缓冲区
  • 由cpu控制,将内核缓冲区的数据拷贝到用户空间的缓冲区中,供应用程序使用
  • 当应用程序调用write()方法时,cpu会把用户缓冲区中的数据copy到内核缓冲区的Socket Buffer中
  • 最后通过DMA方式将内核空间中的Socket Buffer拷贝到Socket协议栈(即网卡设备)中传输。

网络IO的写操作也有四次缓冲区的copy,第一次是从磁盘缓冲区到内核缓冲区(由cpu控制),第二次是内核缓冲区到用户缓冲区(DMA控制),第三次是用户缓冲区到内核缓冲区的Socket Buffer(由cpu控制),第四次是从内核缓冲区的Socket Buffer到网卡设备(由DMA控制)。四次缓冲区的copy工作两次由cpu控制,两次由DMA控制。

(3)网络IO的延时

网络IO主要延时是由:服务器响应延时+带宽限制+网络延时+跳转路由延时+本地接收延时 决定。一般为几十到几千毫秒,受环境影响较大。所以,一般来说,网络IO延时要大于磁盘IO延时。

4. IO中断与DMA

以前传统的IO读写是通过中断由cpu控制的,为了减少CPU对I/O的干预,引入了直接存储器访问方式(DMA)方式。在DMA方式下,数据的传送是在DMA的控制下完成的,不需要cpu干预,所以CPU和I/O设备可以并行工作,提高了效率。现在来看看它们各自的原理:

(1)IO中断原理

  1. 用户进程通过read等系统调用接口向操作系统(即CPU)发出IO请求,请求读取数据到自己的用户内存缓冲区中,然后该进程进入阻塞状态
  2. 操作系统收到用户进程的请求后,进一步将IO请求发送给磁盘。
  3. 磁盘驱动器收到内核的IO请求后,把数据读取到自己的缓冲区中,此时不占用CPU。当磁盘的缓冲区被读满之后,向内核发起中断信号告知自己缓冲区已满。
  4. 内核收到磁盘发来的中断信号,使用CPU将磁盘缓冲区中的数据copy到内核缓冲区中
  5. 如果内核缓冲区的数据少于用户申请读的数据,则重复步骤2、3、4,直到内核缓冲区的数据符合用户的要求为止。
  6. 内核缓冲区的数据已经符合用户的要求,CPU停止向磁盘IO请求。
  7. CPU将数据从内核缓冲区拷贝到用户缓冲区,同时从系统调用中返回。
  8. 用户进程读取到数据后继续执行原来的任务。

中断IO缺点:每次IO请求都需要CPU多次参与。

(2)DMA原理

  1. 用户进程通过read等系统调用接口向操作系统(即CPU)发出IO请求,请求读取数据到自己的用户内存缓冲区中,然后该进程进入阻塞状态
  2. 操作系统收到用户进程的请求后,进一步将IO请求发送给DMA,然后CPU就可以去干别的事了
  3. DMA将IO请求转发给磁盘。
  4. 磁盘驱动器收到内核的IO请求后,把数据读取到自己的缓冲区中,当磁盘的缓冲区被读满后,向DMA发起中断信号告知自己缓冲区已满。
  5. DMA收到磁盘驱动器的信号,将磁盘缓存中的数据copy到内核缓冲区中,此时不占用CPU(IO中断这里是占用CPU的)
  6. 如果内核缓冲区的数据少于用户申请读的数据,则重复步骤3、4、5,直到内核缓冲区的数据符合用户的要求为止。
  7. 内核缓冲区的数据已经符合用户的要求,DMA停止向磁盘IO请求
  8. DMA发送中断信号给CPU。
  9. CPU收到DMA的信号,知道数据已经准备好,于是将数据从内核空间copy到用户空间,系统调用返回。
  10. 用户进程读取到数据后继续执行原来的任务。

跟IO中断模式相比,DMA模式下,DMA就是CPU的一个代理,它负责了一部分的拷贝工作,从而减轻了CPU的负担

需要注意的是,DMA承担的工作是从磁盘的缓冲区到内核缓冲区或网卡设备到内核的soket buffer的拷贝工作,以及内核缓冲区到磁盘缓冲区或内核的soket buffer到网卡设备的拷贝工作,而内核缓冲区到用户缓冲区之间的拷贝工作仍然由CPU负责。

5. 零拷贝IO

在上述IO中,读写操作要经过四次缓冲区的拷贝,并经历了四次内核态和用户态的切换。 零拷贝(zero copy)IO技术减少不必要的内核缓冲区跟用户缓冲区之间的拷贝,从而减少CPU的开销和状态切换带来的开销,达到性能的提升。

在zero copy下,如果从磁盘中读取文件然后通过网络发送出去,只需要拷贝三次,只发生两次内核态和用户态的切换

下图是不使用zero copy的网络IO传输过程:

零拷贝的传输过程:硬盘 >> kernel buffer (快速拷贝到kernel socket buffer) >>Socket协议栈(网卡设备中)

  • 当应用程序调用read()方法时,通过DMA方式将数据从磁盘拷贝到内核缓冲区
  • 由cpu控制,将内核缓冲区的数据直接拷贝到另外一个与 socket相关的内核缓冲区,即kernel socket buffer
  • 然后由DMA 把数据从kernel socket buffer直接拷贝给Socket协议栈(网卡设备中)。

这里,只经历了三次缓冲区的拷贝,第一次是从磁盘缓冲区到内核缓冲区,第二次是从内核缓冲区到kernel socket buffer,第三次是从kernel socket buffe到Socket协议栈(网卡设备中)。只发生两次内核态和用户态的切换,第一次是当应用程序调用read()方法时,用户态切换到内核到执行read系统调用,第二次是将数据从网络中发送出去后系统调用返回,从内核态切换到用户态。

零拷贝(zero copy)的应用:

  • Linux下提供了zero copy的接口:sendfile和splice,用户可通过这两个接口实现零拷贝传输
  • Nginx可以通过sendfile配置开启零拷贝
  • 在linux系统中,Java NIO中FileChannel.transferTo的实现依赖于 sendfile()调用。
  • Apache使用了sendfile64()来传送文件,sendfile64()是sendfile()的扩展实现
  • kafka也用到了零拷贝的功能,具体我没有深究

注意:零拷贝要求输入的fd必须是文件句柄,不能是socket,输出的fd必须是socket,也就是说,数据的来源必须是从本地的磁盘,而不能是从网络中,如果数据来源于socket,就不能使用零拷贝功能了。我们看一下sendfile接口就知道了:

1
2
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
  • out_fd:待写入文件描述符
  • in_fd: 待读出文件描述符
  • offset:从读入文件流的哪个位置开始读,如果为空,则默认从起始位置开始
  • count:指定在文件描述符in_fd 和out_fd之间传输的字节数
  • 返回值:成功时,返回出传输的字节数,失败返回-1

in_fd必须指向真实的文件,不能是socket和管道;而out_fd则必须是一个socket。由此可见,sendfile几乎是专门为在网络上传输文件而设计的。

在Linxu系统中,一切皆文件,因此socket也是一个文件,也有文件句柄(或文件描述符)。

6. BIO

现在,我们就来讲解BIO、NIO、IO多路复用、AIO,在这之前,我必须强调,这些IO大多用于网络IO,并且这里主要介绍用户程序从网络中获取数据那一部分。一方面是为了方便描述,另一方式,更能体现出这些IO的区别。

网络IO从Socket获取数据的步骤:

  1. 用户进程执行系统调用转入内核态
  2. 操作系统等待远处客户端发送数据(前提是客户端和服务器通过TCP三次握手成功),客户端发送数据后,操作系统通过从网卡设备获取数据,并把数据从Socket协议栈拷贝到内核缓冲区
  3. 把内核缓冲区的数据拷贝到用户缓冲区
  4. 用户进程获取到数据,继续执行

BIO、NIO、AIO的主要区别在于:

  • 步骤1里用户进程执行系统调用后的状态如何,是阻塞(或挂起),还是非阻塞。
  • 步骤3里把内核缓冲区的数据拷贝到用户缓冲区,在拷贝过程中,用户进程的状态又如何,是阻塞,还是非阻塞。

如果用户进程在步骤1执行后的状态是阻塞的,且步骤3过程中,进程也是阻塞的,那么是BIO(同步阻塞IO)。

如果用户进程在步骤1执行后的状态是非阻塞的,且步骤3过程中,进程是阻塞的,那么是NIO(同步非阻塞IO)。

如果用户进程在步骤1执行后的状态是非阻塞的,且步骤3过程中,进程也是非阻塞的,也就是说真正读(或写)时,进程的状态是非阻塞的,那么是AIO(异步IO)。

至于多路复用IO和BIO、NIO、AIO的区别,后面会细细讲解。

那么,我们就开始吧!

BIO (Blocking I/O),称之为同步阻塞I/O,其IO模型传输如下图所示:

上图红色表示进程处理阻塞状态,绿色表示进程处于非阻塞状态

我相信BIO模型的传输过程上图已经描述很清楚了,可以看到,BIO模型的用户进程在执行系统调用后,一直处于阻塞状态,等待内核数据到位后,进程继续阻塞,直到内核数据拷贝到用户空间。

该模式下,一个线程只能处理一个Socket IO连接,高并发时,服务端会启用大量的线程来处理多个Socket,由于是阻塞IO,会导致大量线程处于阻塞状态,导致cpu资源浪费,且大量线程会导致大量的上下文切换,造成过多的开销。

当前绝大操作系统都支持多线程,当操作系统引入多线程之后,进程的执行实际就是进程中的多个线程在执行,同一时刻,cpu只能执行一个线程,多个线程通过轮询的方式交替执行。

这时你可能会有疑问,用户进程都被阻塞(或挂起)了,在内核态还怎么操作呢?事实上,read和write都是内核级的操作,只要用户进程调用相应的系统调用接口后,内核进程(或线程)在真正执行读和写操作硬件时,与用户进程就没什么关系了。

7. NIO

NIO (Non-blocking IO),称之为非阻塞IO,其传输过程如下:

在NIO模式下,当用户进程执行系统调用后,如果当前数据还没有准备好,则会立即返回(NIO的非阻塞就提现在这里),然后再次进行系统调用,不断测试数据是否准备好。如果数据准备好了,当前进程会进入阻塞转态,直到数据从内核空间拷贝到用户空间,进程才会被唤醒,就可以处理数据了。

NIO模式下,一个线程就可以处理多个Socket连接,没必要开启多线程进行处理(如果多个NIO,会有多个线程一起执行多次系统调用,结果会很可怕)。但是,当有1000个Socket连接时,用户进程会以轮询的方式执行1000次系统调用判断数据有没有准备好,即会发生1000次用户态到内核态的切换,成本几何上升。即使当前只有一个Socket连接,也会重复进行系统调用,因为此时的用户进程不仅要接收新的Socket连接并把它拷贝到内核,还要判断已有的Socket连接是否准备好数据,这都会有系统调用,极大的浪费cpu资源。

8. IO多路复用

IO多路复用的传输过程如下:

由于NIO会多次执行系统调用进行测试,大大浪费系统的资源,而多路复用IO把轮询多个Socket文件句柄的事情放在内核空间里执行,即让内核负责轮询所有socket(这样就不会有用户态和系统态的切换),当某个或几个socket有数据到达了,返回所有就绪的Socket文件句柄给用户进程,然后用户进程执行read系统调用接口,并进入阻塞状态。内核进程(或线程)把数据从内核空间拷贝到用户空间,用户进程读取到数据就可以进行处理了。

多路复用IO在执行系统调用后,进程就处于阻塞状态,所以多路复用IO本质上也是同步阻塞IO,只不过它是在内核态轮询所有socket,大大提高了IO的处理速度,也减少了系统状态切换的开销。此外,它与同步阻塞的BIO不同,多路复用IO可以使用一个线程同时处理多个Socket的IO请求,这是BIO做不到的。而在BIO中,必须通过多线程的方式才能达到这个目的。

另外,大家可以思考一下,为什么用户进程从网络中获取数据的第一步就要执行系统调用,我举一个例子来说明。

假如一个服务端上的用户进程要读取客户端发来的数据,此时用户进程在用户态,当进程执行了accept()方法获取客户端的链接,此时就得到了客户端Socket的文件句柄(或文件描述符),但是该用户进程并不知道该Socket的文件句柄是否就绪(即是否可读),这就要执行系统调用进入内核态,并把当前网络连接的Socket文件句柄(或文件描述符)复制到内核态。为什么要进入内核态呢?因为数据是从Socket协议栈(或网卡设备)发过来的,要操作硬件设备才能读取数据,所以必须在内核态下判断客户端的Socket是否发来消息。进入内核态以后,内核进程会判断该Socket是否可读(即是否准备好数据),如果准备好了数据,就把数据从Socket协议栈(或网卡设备)拷贝到内核缓冲区,再把内核缓冲区的数据拷贝到用户缓冲区。所以只要有一个客户端的Socket连接到来,就会进入一次系统调用判断Socket的文件句柄是否就绪。这里可能不好理解,但对下面多路复用模式的理解很有用处。

多路复用模式包含三种,即select、poll和epoll,这几种模式主要区别在于获取可读Socket文件句柄的方式

8.1 select

为了更清楚的进行说明,这里给出selet函数的定义:

1
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。**调用后select函数后用户进程会阻塞,直到有文件描述符就绪(有数据可读、可写、或者有exception)**或者超时(timeout指定等待时间),函数返回。当select函数返回后,可以通过遍历fd_set(文件描述符的集合),来找到就绪的描述符。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024(可以修改)。

select机制的详细过程如下:

  1. select创建3个文件描述符的集合(即writefds、readfds、和exceptfds),当有Socket连接时,将该Sokcet的文件句柄放入这3个文件描述符的集合中一个或多个,并将这些文件描述符集全部拷贝到内核中,这里限制了文件句柄的最大的数量为1024,此时用户进程会进入阻塞状态。
  2. 内核分别遍历(或轮询)这3个文件描述符集,判断是否有Socket连接可读、可写、或者有exception,这个动作和select无关。
  3. 内核检测文件描述符集中有某个或某几个Socket文件句柄就绪(有数据可读、可写、或者有exception),修改相应Socket文件句柄的状态位,表示该Socket已经就绪,以区别其它没有就绪的文件句柄。然后,就产生中断通知用户进程,并返回就绪的文件句柄的总数,只需遍历一遍文件描述符集合,就可以得到就绪的 Socket(可使用FD_ISSET宏函数来检测哪些文件句柄就绪)。
  4. 用户进程调用系统调用接口(read或write),转入内核态执行read或write操作,此时进程又进入阻塞状态,在内核态执行完系统调用以后,再次通知用户进程,用户进程获取到数据就可以进行处理了。
  5. 内核对就绪文件句柄的监控是建立在Socket状态位之上的,也就是说经过一次监控之后,相应Socket的状态位会被修改,因此再次监控时需要再次从用户态向内核态进行拷贝(即复位内核里面所有的fd)。
  6. 如果有新的Socket连接进来,用户进程(处于非阻塞时)将新Socket的文件句柄放入3个文件描述符集后,再次将这些文件描述符集全部拷贝到内核中(文件句柄的最大的数量为1024)。

使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。当有多个Socket连接时,把它们放入select创建的3个文件描述符集中的一个即可,然后由内核分别轮询这3个文件描述符集,即可达到在同一个线程内同时处理多个IO请求的目的。

在select机制中,为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set集合大小做了限制(默认最大为1024),且想要做到多次监控或者有未就绪的文件句柄,select机制会不断地将文件描述符集从用户态向内核态进行拷贝,会大大浪费系统的资源。此外,select机制只能知道有socket就绪(有数据可读或可写、或者有exception),无法知道具体是哪一个socket接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个socket接收到了数据。

8.2 poll

不同于select使用3个文件描述符集合,poll使用一个pollfd结构体的指针实现。

1
2
3
4
5
6
7
8
# poll函数定义
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
# pollfd指针的结构体
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};

poll函数参数说明:

  • struct pollfd *fdsfds是一个struct pollfd类型的数组,用于存放需要检测其状态的socket文件描述符,并且调用poll函数之后fds数组不会被清空,这一点与select()函数不同,调用select()函数之后,select() 函数会清空它所检测的socket描述符集合,导致每次调用select()之前都必须把socket描述符重新加入到待检测的集合中。一个pollfd结构体表示一个被监视的Socket文件描述符,通过给poll函数传递pollfd数组来监视多个socket文件描述符。
  • nfds:是监控的socket文件句柄数量。
  • timeout是等待的毫秒数,这段时间内无论IO是否准备好,poll都会返回。timeout为负数表示无线等待,timeout为0表示调用后立即返回。
  • poll函数执行结果:为0表示超时前没有任何事件发生,-1表示失败,成功则返回结构体pollfdrevents不为0的文件描述符个数。

接下来,我们就介绍一下,结构体pollfd的events域和revents域。events域是监视该文件描述符的事件,由用户来设置这个域,revents域是文件描述符的操作结果事件,由内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回,events域合法的事件如下:

  • POLLIN:有数据可读。
  • POLLRDNORM:有普通数据可读。
  • POLLRDBAND:有优先数据可读。
  • POLLPRI:有紧迫数据可读。
  • POLLOUT:写数据不会导致阻塞。
  • POLLWRNORM:写普通数据不会导致阻塞。
  • POLLWRBAND:写优先数据不会导致阻塞。
  • POLLMSG、SIGPOLL:消息可用。

此外,revents域中还可能返回下列事件,这些事件在events域中无意义,因为它们在合适的时候总是会从revents中返回。

  • POLLER:指定的文件描述符发生错误。
  • POLLHUP:指定的文件描述符挂起事件。
  • POLLNVAL:指定的文件描述符非法。

举一个例子,events=POLLIN | POLLPRI等价于select()的读事件,events=POLLOUT | POLLWRBAND等价于select()的写事件。此外,POLLIN等价于POLLRDNORM |POLLRDBAND,而POLLOUT则等价于POLLWRNORM

当我们要同时监视一个文件描述符是否可读和可写,可以设置 events=POLLIN |POLLOUT(由用户设置)。在poll返回时,可以检查revents中的标志(由内核在调用返回时设置)。如果revents=POLLIN,表示Socket文件描述符可以被读取而不阻塞。如果revents=POLLOUT,表示Socket文件描述符可以写入而不导致阻塞。注意,POLLINPOLLOUT并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。

现在,我们来总结一下poll机制的过程:

  1. 调用poll函数,进入系统调用,将pollfd数组拷贝至内核,此时用户进程会进入阻塞状态。
  2. 此时已经从用户空间复制了pollfd数组来存放所有的Socket文件描述符,此时把数组pollfd组织成poll_list链表。
  3. 对poll_list链表进行遍历,判断每个Socket文件描述符的状态,如果某个Socket就绪,就在Socket的就绪队列上加入一项,然后继续遍历。若遍历完所有的文件描述符后,都没发现任何Socket就绪,则继续阻塞当前进程,直到有Socket就绪或者等待超时,就唤醒用户进程,返回就绪队列。
  4. 如果用户进程被唤醒后,Socket就绪队列有就绪的Socket,用户进程就获取所有就绪的Socket,调用系统调用接口(read或write),转入内核态执行read或write操作,此时进程又进入阻塞状态,在内核态执行完系统调用以后,再次通知用户进程,用户进程获取到数据就可以进行处理了。
  5. 如果用户进程因等待超时被唤醒,Socket就绪队列为空,进程会再次执行系统调用,重复步骤1、2、3。

可以看到,poll的实现和select非常相似,只是文件描述符集合不同,poll使用pollfd结构体,select使用的是fd_set结构,其他的都差不多,管理多个文件描述符也都是采用轮询的方式,然后根据文件描述符的状态进行处理。由于poll使用的链表,故没有最大文件描述符数量的限制,而select监控文件描述符的最大默认数量为1024。

poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的内存空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的文件描述符数量的增长,其效率也会线性下降。

poll还有一个特点是“水平触发”,如果报告了某个就绪的Socket文件描述符后,没有被处理,那么下次poll时会再次报告该Socket fd。

8.3 epoll

epoll是在Linux2.6内核中提出的,是之前的select和poll的增强版本,先来看下epoll系统调用的三个函数

1
2
3
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. int epoll_create(int size);
    创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,这里的size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议,也就是说,size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。当创建好epoll句柄后,它就会占用一个fd值,所以在使用完epoll后,必须调用close()关闭,否则可能导致该进程的fd被耗尽。

  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    该函数是对上面建立的epoll文件句柄执行op操作。例如,将刚建立的socket加入到epoll中让其监控,或者把 epoll正在监控的某个socket句柄移出epoll不再监控它等等。

    • epfd:是epoll的文件句柄。

    • op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD,分别表示添加、删除和修改对fd的监听事件。

    • fd:是需要监听的fd(文件描述符)

    • epoll_event:告诉内核需要监听什么事件,struct epoll_event结构如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      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队列里
  3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    epoll_wait在调用时,在给定的timeout时间内,当在监控的所有句柄中有事件发生时(即有Socket就绪时),就返回用户态的进程。

    • epfd:是epoll的文件句柄。
    • events:从内核得到事件的集合
    • maxevents:events的大小
    • timeout:超时时间,timeout为负数表示无线等待,timeout为0表示调用后立即返回。

select机制的详细过程如下:

  1. 执行epoll_create在内核建立专属于epoll的高速cache区,并在该缓冲区建立红黑树和就绪链表,用户态传入的文件句柄将被放到红黑树中(第一次拷贝)。
  2. epoll_ctl执行add动作时除了将Socket文件句柄放到红黑树上之外,还向内核注册了该文件句柄的回调函数,内核在检测到某Socket文件句柄就绪,则调用该回调函数,回调函数将文件句柄放到就绪链表。
  3. epoll_wait只监控就绪链表就可以,如果就绪链表有文件句柄,则表示该文件句柄可读(或可写),并返回到用户态(少量的拷贝);
  4. 由于内核不修改文件句柄的状态位,因此只需要在第一次传入就可以重复监控,直到使用epoll_ctl删除,否则不需要重新传入,因此无多次拷贝。

从上面的调用方式就可以看到epoll比select/poll的优越之处:

  • epoll同poll一样,也没有文件句柄的数量限制。
  • select/poll每次系统调用时,都要传递所有监控的socket给内核缓冲区,如果有数以万计的Socket文件句柄,意味着每次都要copy几十几百KB的内存到内核态,非常低效。epoll不需要每次都将Socket文件句柄从用户态拷贝到内核态,在执行epoll_create时已经在内核建立了epoll句柄,每次调用epoll_ctl只是在往内核的数据结构里加入新的socket句柄,所以不需要每次都重新复制一次。
  • select 和 poll 都是主动轮询,select在内核态轮询所有的fd_set来判断有没有就绪的文件句柄,poll 轮询链表判断有没有就绪的文件句柄,而epoll是被动触发方式。epoll_ctl执行add动作时除了将Socket文件句柄放到红黑树上之外,还向内核注册了该文件句柄的回调函数,当Socket就绪时,则调用该回调函数将文件句柄放到就绪链表,epoll_wait只监控就绪链表就可以判断有没有事件发生了。

epoll的工作模式

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

  • 水平触发(LT模式):当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
  • 边缘触发(ET模式):当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
  • LT模式:LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket,在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不做任何操作,内核还是会继续通知你的。
  • ET模式:ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作(错误的操作)导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个fd做IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用no-block socket,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

另外,还需要补充一点,epoll机制下,用户进程在执行epoll_create进入系统调用之后,并没有进入阻塞状态,因为它还要执行后面的epoll_ctl和epoll_wait方法来监控多个Socket连接,这点与select和poll不同,用户进程在第一次执行系统调用后就进入阻塞状态,等待就绪的Socket来唤醒它。但是,epoll机制下,用户进程在真正执行read或write系统调用接口时,还是会进入阻塞状态。所以这方面来讲,epoll是同步非阻塞IO,select和poll是同步阻塞IO(个人见解)。

9. AIO

AIO ( Asynchronous I/O):异步非阻塞I/O模型。传输过程如下:

可以看到,异步非阻塞I/O在判断数据有没有准备好(即Socket是否就绪)和真正读数据两个阶段都是非阻塞的。AIO在第一次执行系统调用后,会注册一个回调函数,内核在检测到某Socket文件句柄就绪,调用该回调函数执行真正的读操作,将数据从内核空间拷贝到用户空间,然后返回给用户使用。在整个过程,用户进程都是非阻塞状态,可以做其它的事情。

没有Linux系统采用AIO模型,只有windows的IOCP是此模型。

10. 总结

IO可以分为两个阶段,第一阶段,判断有没有事件发生(或判断数据有没有准备好,或判断Socket是否就绪),第二阶段,在数据准备好以后,执行真正的读(或写)操作,将数据从内核空间拷贝到用户空间。

这几个阶段:

  • 同步阻塞IO(BIO):两个阶段的用户进程都阻塞。
  • 同步非阻塞IO(NIO):第一阶段没有阻塞,但是用户进程(或线程)必须不断的轮询,判断有没有Socket就绪,这时cpu疯狂被占用。第二阶段,数据拷贝的过程是阻塞的。所以,所有的同步过程,在第二阶段都是阻塞的,尽管这是非阻塞的调用。
  • 多路复用:NIO的第一阶段没有阻塞,但是由用户线程不断轮询多个Socket有没有就绪。而多路复用把这件事情交给一个内核线程去处理,速度非常快。select和poll机制下,第一阶段是也是阻塞的,而epoll机制,用户线程除了要执行epoll_create,还要执行epoll_ctl和epoll_wait,所以是非阻塞的。在第二阶段,所有的多路复用IO都是阻塞的。所以,多路复用IO也是同步IO。
  • 异步IO(AIO):两个阶段都是非阻塞的。

另外,不得不提的是,上述的阻塞和非阻塞指的是IO模型,用户进程获取数据后执行业务逻辑的时候,也分异步和同步。比如,进程执行一段很复杂的业务逻辑,需要很长的时间才能返回,也可以注册一个回调函数,等待此段代码执行完毕后,就通知用户进程。例如,nginx在Linux2.6以后的内核中用的IO模型是epoll,即同步IO,而Nginx的worker进程的处理请求的时候是异步的。

业务逻辑的同步和异步概念如下:

  • 同步:所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。
  • 异步:异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。
  • 阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起,函数只有在得到结果之后才会返回。有人也许会把阻塞调用和同步调用等同起来,实际上它们是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是当前函数没有返回而已。
  • 非阻塞:非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

【参考文档】

  1. https://www.cnblogs.com/Mr-shen/p/12832501.html
  2. https://www.zhihu.com/question/20122137
  3. https://www.cnblogs.com/pluto-yang/p/12546942.html
  4. https://www.cnblogs.com/sunsky303/p/8962628.html
  5. https://blog.csdn.net/qq_22121229/article/details/103101191
  6. https://www.cnblogs.com/pugang/p/12823108.html
  7. https://blog.csdn.net/qq_33330687/article/details/81558198
  8. https://www.cnblogs.com/ljbkyBlog/p/10190576.html
  9. https://zhuanlan.zhihu.com/p/121651179
  10. https://zhuanlan.zhihu.com/p/260450151
  11. https://zhuanlan.zhihu.com/p/272891398
  12. https://zhuanlan.zhihu.com/p/159357381