I/O模型
在Unix
下可用的5种I/O模型
- 阻塞式I/O
- 非阻塞式I/O
- I/O复用(select和poll)
- 信号驱动式I/O(SIGIO)
- 异步I/O (POSIX的aio_系列函数)
五种I/O模型的比较
一个输入操作通常包含两个不同的阶段:
- 等待数据准备好
- 从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络到达。当所等待分组到达时,它被复制到内核的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
阻塞、非阻塞、同步、异步
典型的一次IO的两种状态是什么? 数据准备和数据读写
数据准备:根据系统IO的就绪状态
- 阻塞(调用IO方法的线程进入阻塞状态)
- 非阻塞 (不会改变线程的状态,通过返回值判断 )
数据读写: 根据应用程序和内核的交互方式
- 同步
- 异步
阻塞时I/O模型、非阻塞式I/O模型、I/O复用模型和信号驱动式I/O模型都是同步I/O模型,因为其中的真正的I/O操作(recvfrom)将阻塞进程。只有异步I/O模型与POSIX定义的异步I/O相匹配。
异步 :
告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何使可用启动一个I/O操作,而异步I/O模型是内核通知我们I/O操作何时完成。
我们调用aio_read
函数(POSIX异步I/O函数以aio_
或lio_
开头),给内核传递描述符、缓存区指针、缓冲区大小(与read相同的三个参数)和文件偏移(与lseek
类似),并告诉内核整个操作完成时如何通知我们。该系统调用立即返回,而且在等待I/O完成期间,我们的进程不会被阻塞。本例子中我们假设要求内核在操作完成时产生某个信号。该信号直到数据已复制到应用进程缓冲区才产生,这一点不同于信号驱动式I/O模型。
业务层面的一个逻辑处理是同步还是异步?
- 同步:A操作等待B操作做完事情,得到返回值,继续处理
- 异步:A操作告诉B操作它感兴趣的事件以及通知方式,A操作继续执行自己的业务逻辑了;等B监听到相应的事件发生后,B会通知A,A开始相应的数据逻辑处理。
一个典型的网络IO接口调用,分为两个阶段,分别是数据就绪和数据读写,数据就绪阶段分为阻塞和非阻塞,表现的结果就是,阻塞当前进程或是直接返回。
同步表示A向B请求调用一个网络IO接口时(或是调用某个业务逻辑API接口时),数据的读写都是由请求方A自己来完成的(不管是阻塞还是非阻塞);异步表示A向B请求调用一个网络IO接口时(或是调用某个业务逻辑API接口时),向B传入请求的事件以及事件发生时通知方式,A就可以处理其它业务逻辑了,当B监听到事件处理完成后,会用事先约定好的通知方式,通知A处理结果。
- 同步阻塞
- 同步非阻塞
- 异步阻塞
- 异步非阻塞
Unix/Linux的五种IO模型
阻塞式I/O模型 (blocking)
非阻塞I/O模型(non-blocking)
IO复用模型(IO muliplexing)
信号驱动式I/O模型(signal-driven I/O)
这种模式的优势在于等待数据包到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据准备好被读取。
与非阻塞IO相的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统API调用次数,提高了效率。
异步I/O模型(asynchronous)
Reactor模型
重要组件:Event事件、Reactor反应堆、Demultiplex事件分发器、Evanthandler事件处理器
muduo库的Multiple Reactors模型如下:
I/O复用
select 系统调用
在一段时间内,监听用户感兴趣的文件描述符的可读、可写和异常等事件。
1 |
|
- nfds 参数指定被监听的文件描述符的总数
- readfds、writefds和 exceptfds 参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用
select
函数时,通过这3个参数传入自己感兴趣的文件描述符。select
函数调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。这三个参数是fd_set
结构指针类型。fd_set
结构体仅包含一个整型数组,该数组的每一个元素的每一位(bit)标记一个文件描述符。fd_set
能容纳的文件描述符的数量由FD_SETSIZE
指定,这就限制了select
能同时处理的文件描述符的数量
timeout
参数用来设置select
函数的超时时间。它是一个timeval
结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select
等待了多久,不过我们不能完全信任 select 调用返回时的值,比如调用失败时 timeout 的值是不确定的。- 由以上定义可见,select 给我们提供了一个微秒级的定时方式。如果给 timeout 变量的
tv_sec
成员和tv_usec
成员都传递0,则select将立即返回。如果给timeout
传递NULL,则select将一直阻塞,直到某个文件描述符就绪。
select 成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select将返回 0。select失败时返回-1并设置 errno。 如果在select等待期间,程序收到信号,则select立即返回-1,并设置errno为EINTR。
poll系统调用
poll
系统调用和select
类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。poll 的原型如下;
1 |
|
fds 参数是一个 pollfd 结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写和异常等事件。 pollfd结构体的定义如下:
1
2
3
4
5struct pollfd {
int fd; // 文件描述符
int events; // 注册的事件
short revents; // 实际发生的事件,由内核填充
};其中, fd成员指定文件描述符,events成员告诉poll监听fd上的哪些事件,它是一系列事件的按位或;revents 成员则由内核修改,以通知应用程序fd上实际发生了哪些事件。
nfds 参数指定了被监听事件集合fds的大小。其类型 nfds_t 的定义如下:
1
typedef unsigned long int nfds_t;
timeout 参数指定poll的超时值,单位是毫秒。当 timeout 为 -1 时,poll 调用将永远阻塞,直到某个事件发生;当 timeout 为 0 时,poll 调用将立即返回
poll 系统调用的返回值的含义与 select 相同
epoll 系统调用
内核事件表
epoll
是 Linux 特有的 I/O 复用函数。它在实现和使用上与 select 、poll 由很大的差异。首先,epoll 使用一组函数来完成任务,而不是单个函数。其次,epoll 把用户关心的文件描述符上的事件放入内核的一个事件表中,从而无需像 select 和 poll 那样每次调用都要重复传入文件描述符或事件集。但 epoll 需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用如下的 epoll_create
函数来创建
1 |
|
size 参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将作用其他所有epoll系统调用的第一个参数,已指定要访问的内核事件表。
下面的函数用来操作内核事件表:
1 |
|
fd参数是要操作的文件描述符,op 参数则是指定的操作类型。操作类型有以下三种:
- EPOLL_CTL_ADD: 往事件表中注册 fd 上的事件
- EPOLL_CTL_MOD: 修改 fd 上的注册事件
- EPOLL_CTL_DEL: 删除 fd 上的注册事件
event 参数指定事件,它是 epoll_event 结构指针类型。epoll_event 的定义如下:
1 | struct epoll_event { |
其中 events 成员描述事件类型。epoll 支持的事件类型与 poll 基本相同。表示 epoll 事件类型的宏是在 poll 对应的宏前加 “E” ,比如 epoll 的可读事件是 EPOLLIN。但 epoll 有两个额外的事件类型– EPOLLET和 EPOLLONESHOT。它们对于 epoll 的高效运作非常关键,后面会讨论他们。data 成员用于存储用户数据。
epoll_ctl 成功时返回 0, 失败时返回 -1,并设置 errno。
epoll_wait 函数
epoll 系列系统调用的主要接口是 epoll_wait 函数。它在一段超时时间内等待一组文件描述符上的事件,其原型如下:
1 |
|
该函数成功时返回就绪的文件描述符的个数,失败时返回 -1并设置 errno。
关于该函数的参数,timeout参数的含义与poll接口的timeout 参数相同。maxevents参数指定最多监听多少个事件,它必须大于 0。
epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表(由 epfd 参数指定)中复制到它的第二个参数 events 指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 select 和 poll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。以下代码体现了这个差别
1 | int ret = poll(fds, MAX_EVENT_NUMBER, -1); |
LT和ET模式
epoll 对文件描述符的操作有两种模式: LT(Level Trigger,电平触发)模式和 ET(Edge Trigger,边沿触发)模式。
LT模式是默认的工作模式,这种模式下 epoll 相当于一个效率较高的 poll 。当往 epoll 内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以 ET模式来操作文件描述符。ET模式是 epoll 高效工作模式。
对于采用 LT 工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用 epoll_wait 时,epoll_wait 还会再次向应用程序通告此事件,直到该事件被处理。而对于采用 ET 工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的 epoll_wait 调用将不再向应用程序通知这一事件。可见,ET模式在很大程度上降低了同一个 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。
muduo 采用的是 LT 工作模式,原因如下:
- 不会丢失数据或消息
- 应用没有读完数据,内核是会不断上报
- 低延迟处理
- 每次读数据只需要一次系统调用;照顾了多个连接的公平性,不会因为某个连接上的数据量过大而影响其它连接处理消息
- 跨平台处理
- 像 select 一样可以跨平台使用
三种I/O复用函数的比较
这三组系统调用都能同时监听多个文件描述符。它们将等待 timeout 参数指定的超时时间,直到一个或者多个文件描述符上有事件发生时返回,返回值是就绪的文件描述符的数量。返回 0 表示没有事件发生。从事件集、最大支持文件描述符、工作模式和具体实现等四个方面进一步比较它们的异同,以明确在实际应用中应该选择使用哪个。
事件集 compare
这三组函数都是通过结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。select 的参数类型 fd_set 没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此 select 需要提供 3 个这种类型的参数来分别传入和输出可读、可写及异常等事件,这一方面使得 select 不能处理更多类型的事件。另一方面由于内核对 fd_set 集合的在线修改,应用程序下次调用 select 前不得重置这三个 fd_set 集合。poll 的参数类型 pollfd 则聪明一些,它把文件描述符和事件都定义其中,任何事件都被统一处理,从而使得编程接口简洁很多,并且内核每次修改的是 pollfd 结构体的 revents 成员,而 events 成员保持不变,因此下次调用 poll 时应用程序无需重置 pollfd 类型的事件集参数。由于每次 select 和 poll 调用都返回整个用户注册的事件集合(其中包含就绪的和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度是 O(n)。
epoll 则采用与 select 和 poll 完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了独立的系统调用 epoll_ctl 来控制往其中添加、删除、修改事件。这样,每次 epoll_wait 系统调用就能从该内核事件表中取得用户注册的事件,而无需反复从用户空间读入这些事件。epoll_wait 系统调用的 events 参数仅用来从返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度达到 O(1)。
最大支持文件描述符 compare
poll 和 epoll_wait 分别用 nfds 和 maxevents 参数指定最多监听多少个文件描述符和事件。这两个数值都能达到系统允许打开的最大文件描述符的数量,即65535。而 select 允许监听的最大文件描述符通常有限制。虽然用户可以修改这个限制,但可能导致不可预期的后果。
工作模式 compare
select 和 poll 只能工作在相对低效的LT模式,而 epoll 可以工作在 ET 高效模式。并且 epoll 还支持 EPOLLONESHOT 事件。该事件可以进一步减少可读、可写和异常等事件被触发的次数。
实现原理 compare
select 和 poll 采用的是轮询的方式,每次调用都要扫描整个注册文件描述符集合,将其中就绪的文件描述符返回给用户程序,因此它们检测就绪时间的时间复杂度是 O(n) 。epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符对应的事件插入到内核就绪事件队列。内核最后在适当的时候就将该就绪队列中的内容拷贝到用户空间。因此 epoll_wait 无需轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度为O(1)。但是,当活动连接比较多时,epoll_wait的效率未必比 select 和 poll 高,因为此时回调函数被触发得过于频繁。所以 epoll_wait 适用于连接多,但活动连接较少的情况。
设想一下如下场景:有100万个客户端同时与一个服务器保持TCP连接,而每一时刻,通常有成百上千个TCP连接是活跃的(事实上大部分情况都是这种情况)。如何实现这样的高并发?
在 select/poll 时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄到内核态),让操作系统内核去查询这些套接字是否有事件发生,轮询完成后,再将句柄数据复制到内核态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗很大,因此,select/poll 一般只能处理几千的并发连接。
epoll 的设计和实现与select/polll 完全不同。epoll 通过在Linux内核去申请一个简易的文件系统,把原先的select/poll 调用分成以下三个部分:
- 调用 epoll_create() 建立一个 epoll 对象(在 epoll 文件系统为这个句柄对象分配资源)
- 调用 epoll_ctl() 向 epoll 对象中添加这100万个连接的套接字
- 调用 epoll_wait() 收集发生事件的 fd 资源
如此一来,要实现上面说的场景,只需要进程启动的时候建立一个 epoll 对象,然后在需要的时候向这个连接中添加或删除事件。同时,epoll_wait 的效率也非常高,因为调用 epoll_wait 时,并没有向操作系统复制这100万个连接的句柄数据,内核也不需要遍历全部的连接。
epoll_create 在内核上创建的 eventpoll 结构如下:
1 | struct eventpoll { |
系统调用 | select | poll | epoll |
---|---|---|---|
事件集合 | 用户通过3个参数分别传入感兴趣的可读、可写及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数 | 统一处理所有事件类型,因此只需要一个事件集参数。用户通过 pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中就绪的事件 | 内核通过一个事件表直接接管用户感兴趣的所有事件。因此每次调用epoll_wait时,无需反复传入用户感兴趣的事件。 |
应用程序索引就绪文件描述符的时间复杂度 | O(n) | O(n) | O(1) |
最大支持文件描述符数 | 一般有最大值限制 | 65535 | 65535 |
工作模式 | LT | LT | 支持ET高效模式 |
内核实现和工作效率 | 采用轮询方式检测就绪事件,算法时间复杂度O(n) | 采用轮询方式来检测就绪事件,算法时间复杂度O(n) | 采用回调方式来检测就绪事件,算法时间复杂度O(1) |