前言
网络编程中的 I/O 操作,本质上涉及到用户空间(用户态程序所在空间)和内核空间(内核态程序所在空间)之间的数据交互。当应用程序发起一个 I/O 请求时,往往需要从用户态切换到内核态,由内核来完成实际的 I/O 操作,例如从网络设备读取数据或者向网络设备发送数据。操作完成后,再从内核态切换回用户态,将结果返回给应用程序。不同的 I/O 模型在处理这个过程时,采用了不同的策略,从而导致了用户态和内核态切换的频率、时机和方式存在差异。在网络编程中,有五种常见的 I/O 模型:
- 阻塞IO(Blocking IO)
- 非阻塞IO(Nonblocking IO)
- IO多路复用(IO Multiplexing)
- 信号驱动IO(Signal Driven IO)
- 异步IO(Asynchronous IO)
以用户从IO读取数据为例,整个流程分为两阶段:等待数据、将数据从内核拷贝到用户空间。五种IO模型的对比可以参考下图:

阻塞IO
在阻塞 I/O 模型中,当应用程序调用一个 I/O 操作(如 recv
接收数据)时,程序会被阻塞,直到数据准备好并且被复制到应用程序的缓冲区中,或者发生错误才会返回。在阻塞期间,应用程序无法执行其他任务。

该方案效率低下,因为在 I/O 操作期间,应用程序会被阻塞,无法处理其他任务,导致 CPU 资源浪费。
非阻塞IO
当调用 I/O 操作时,如果数据没有准备好,系统会立即返回一个错误(如 EWOULDBLOCK
或 EAGAIN
),而不会阻塞应用程序。应用程序可以继续执行其他任务,并不断轮询文件描述符的状态,直到数据准备好。

非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
IO多路复用
IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。select
、poll
和 epoll
是三种在 Unix/Linux 系统中用于实现 I/O 多路复用的机制,它们可以让一个进程同时监视多个文件描述符(如套接字)的状态变化,从而实现高效的并发处理。

其中select和pool相当于是当被监听的数据准备好之后,会把监听的FD整个数据都发给你,然后需要到整个FD中通过遍历的方式查找数据准备完成的FD,因此性能一般。
而epoll在内核准备好了之后,他会把准备好的数据,直接发给你,省去了遍历的动作。
Select
关键代码如下:
1 | // 定义类型别名 __fd_mask,本质是 long int |
工作原理
- 应用程序创建一个描述符集合
fd_set
并将需要监视的文件描述符添加到该集合中 ,在上面的代码中fd_set
相当于一个1024位的位图,每位表示该FD是否需要监听。 - 调用
select
系统调用,将描述符集合从用户空间传递给内核空间,同时指定一个超时时间。 - 内核会检查这些文件描述符的状态,当有文件描述符就绪(可读、可写或异常)时,
select
会修改描述符集合,只保留就绪的文件描述符,并返回就绪文件描述符的数量。 - 应用程序检查修改后的描述符集合,确定哪些FD就绪并在
fd_set
上标出已就绪的FD并拷贝回用户缓冲区,并进行相应的 I/O 操作。

存在问题
- 描述符集合大小有限制,通常为 1024。
- 每次调用
select
都需要将描述符集合从用户空间复制到内核空间,开销较大。 - 应用程序需要遍历整个描述符集合来确定哪些文件描述符就绪。
Poll
关键代码如下:
1 | // pollfd 中的事件类型 |
工作原理
与Select方式相比,其实只是将返回的位图改成了数组,进而克服了Select描述符集合大小限制的问题,但剩下的两个问题依旧没有得到改善,并且由于每次依然需要遍历查询数组,描述符集合依然不能太大。尽管突破了硬上限,但依然存在软上限。
Epoll
epoll模式是对select和poll的改进,采用了事件驱动的方式,每个epoll实例由一个红黑树加一个链表构成,红黑树用于记录需要监听的FD;链表用于记录已就绪的FD。关键代码如下:
1 | struct eventpoll { |
工作原理
- 创建一个
epoll
实例,通过epoll_create
系统调用返回一个文件描述符。 - 使用
epoll_ctl
系统调用向epoll
实例的红黑树中添加、修改或删除需要监视的文件描述符及其事件类型。 - 当红黑树中的FD监听到事件发生时,会将其存入链表并唤醒所有等待在
epoll_wait
上的应用程序。 - 调用
epoll_wait
,如果有就绪的FD,返回就绪的文件描述符数量,并将就绪的文件描述符信息存储在一个events数组中;如果没有就绪的FD,当前进程休眠等待。 - 应用程序遍历该数组,处理就绪的文件描述符。

优势
- 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高;
- 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间;
- 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降。
事件通知机制
当FD有数据可读时,调用epoll_wait
(或者select、poll)可以得到通知。但是事件通知的模式有两种:
- LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。会有重复通知问题以及惊群现象的产生。
- EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。需要应用程序一次将所有数据处理完,否则当前FD的数据后续就无法访问了。
如果是LT模式,epoll链表节点只有当对应的FD无数据可读后才会移除;而如果是ET模式,链表节点在通知过后就会直接置空。
select和poll仅支持LT模式,epoll可以自由选择LT和ET两种模式。
惊群现象
当多个进程或线程共享同一个 epoll
实例并使用 LT
模式时,一旦有事件发生,所有等待在该 epoll
实例上的进程或线程都会被唤醒。因为在 LT
模式下,只要事件处于就绪状态,epoll
就会持续通知,所以多个进程或线程可能会重复收到同一个事件的通知。例如,当有新的客户端连接到来时,所有等待在 epoll
实例上的进程或线程都会被唤醒,它们都会尝试去处理这个新连接,最终只有一个进程或线程能够成功处理,其余的进程或线程会发现无事可做,从而造成资源的浪费。

使用ET模式可以有效避免惊群现象,原因有二:
-
减少重复通知:在
ET
模式下,事件状态的变化只会触发一次通知。当有事件发生时,只有一个进程或线程能够首先响应并处理该事件。一旦该进程或线程处理了这个事件,即使其他进程或线程也在等待这个epoll
实例,由于事件状态已经被处理过,不会再次触发通知,所以其他进程或线程不会被不必要地唤醒。例如,当有新的客户端连接到来时,只有一个进程或线程会被唤醒并处理这个连接,其他进程或线程不会收到重复的通知,从而避免了惊群现象。 -
促使应用程序一次性处理完事件:由于
ET
模式只通知一次,应用程序必须在收到通知后一次性处理完所有的事件。这使得应用程序在设计上更加高效,减少了多次处理同一事件的可能性,进一步降低了惊群现象发生的概率。例如,在处理可读事件时,应用程序需要一次性将输入缓冲区中的数据全部读取完,避免了因为数据未处理完而导致的重复通知和惊群问题。

基于epoll的web服务流程
基于epoll模式的web服务的基本流程如图:
信号驱动IO
在信号驱动IO中,应用程序首先通过系统调用(如 sigaction
)注册一个信号处理函数,并将文件描述符设置为信号驱动 I/O 模式。当数据准备好时,系统会向应用程序发送一个信号(如 SIGIO
),应用程序在信号处理函数中进行 I/O 操作。

在数据准备期间,应用程序可以继续执行其他任务,不会被阻塞。当数据准备好时,通过信号通知应用程序,提高了系统的响应速度。但是当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。而且,信号驱动 I/O 模型在某些操作系统上的支持不够完善。
异步IO
应用程序调用一个异步 I/O 操作(如 aio_read
或 aio_write
),并指定一个回调函数。系统在后台进行 I/O 操作,当操作完成后,会调用应用程序指定的回调函数通知应用程序。在整个 I/O 操作过程中,应用程序可以继续执行其他任务,不会被阻塞。

真正实现了 I/O 操作的异步化(另外四种IO操作实际上都是同步的),应用程序在 I/O 操作期间可以继续执行其他任务,不会被阻塞,提高了系统的并发处理能力和性能。
__END__