前文回顾
通过学习 Linux之【网络I/O】前世今生(一),我们知道了I/O 请求可以分为两个阶段,分别为 I/O 调用和 I/O 执行:
- I/O 调用
即用户进程向内核发起系统调用(通过 0x80 中断)。
- I/O 执行
内核等待 I/O 请求处理完成返回。该阶段分为两个过程:
- 等待数据就绪,并写入内核缓冲区;
- 将内核缓冲区数据拷贝至用户态缓冲区。
一、 网络 I/O 模型
在 Linux之【磁盘 IO】前世今生 一文中,我们提到了 I/O的三种模型:同步阻塞I/O (BIO)、同步非阻塞I/O (NIO)、异步I/O (AIO)。
对于网络I/O,我们是否可以采用上述I/O模型呢 ?
网络I/O 会涉及 Socket ,且同一时间可能有大量 Socket 连接和读写操作,需要考虑并发量和性能……
- 首先可以排除异步I/O,因为Linux 异步I/O最初是为数据库设计的;
- 同步阻塞I/O (BIO):考虑并发情况的话,只能使用多线程模型,一个请求对应一个线程。但是,线程的资源是有限且宝贵的,创建过多的线程会增加线程切换的开销。
- 请求数只有 100 个时,这种方式自然没问题,但增加到 10000 个请求时,10000 个进程或线程的调度、上下文切换乃至它们占用的内存,都会成为瓶颈。
- 同步非阻塞I/O (NIO):NIO 相比 BIO 虽然大幅提升了性能,但是轮询过程中大量的系统调用导致上下文切换开销很大。所以,单独使用 NIO 时效率并不高,而且随着并发量的提升,非阻塞 I/O 会存在严重的性能浪费。
1.1 I/O多路复用(I/O Multiplexing)
针对上文分析结果,同时结合网络I/O 的并发量考虑,我们改进下 NIO 模型:
- NIO 轮询产生的多次上下文切换,我们可以减少,正如我们我们之前用 sendfile 替代 [ read + write ] 一样,我们把轮询工作放在内核态进行;
- 所有 Socket 都注册到内核里,由内核统一负责监听所有Socket的I/O数据是否就绪;
- 线程资源有限且宝贵,我们通过减少线程数量来降低线程切换开销,一个用户线程负责处理多个Socket;
- 任一Socket 的 I/O 就绪,内核就通知应用程序;
我们用 多路复用器(Selector)来封装上述改进结果,即通过一次系统调用,检查多个Socket的状态,将这种改进后 I/O 模型记为I/O多路复用(I/O Multiplexing)。
- 多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。线程一次 select 调用可以获取内核态中多个数据通道的数据状态。多路复用解决了同步阻塞 I/O 和同步非阻塞 I/O 的问题,是一种非常高效的 I/O 模型。
- I/O 多路复用内部实现需要使用非阻塞 I/O
- 多路复用内部需要遍历 Socket 集合,如果使用阻塞I/O,当前遍历 Socket 数据未就绪会阻塞,那么后续 Socket 将没有机会遍历检查,直到当前阻塞解除;
- 注意:这里说的是 I/O 多路复用内部实现,而不是说,使用 I/O 多路复用就必须使用非阻塞 I/O。
Linux中 I/O 多路复用的具体实现有:select、poll、epoll ;kueue 是在UNIX上比较高效的IO复用技术。
1.1.1 select
select 允许应用程序监视一组 Socket 文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。调用 select 会一直阻塞直到有描述符的事件到达或者等待超时(timeout 参数精度为微秒)。
具体实现方式如下:
- 将已连接的 Socket 文件描述符放到一个
BitsMap
数组,然后调用select
函数将BitsMap
数组拷贝到内核里; - 内核通过遍历
BitsMap
数组,来检查是否有网络事件产生,当检查到有事件产生后,将此 Socket 标记为可读或可写; - 将
BitsMap
数组从内核拷贝到用户态; - 用户态遍历
BitsMap
找到可读写的Socket,然后再对其处理。
int select(int nfds, // 需要监听的 socket (也叫文件描述符(file descriptor))数量;
fd_set *restrict readfds, // 可读集合
fd_set *restrict writefds,// 可写集合
fd_set *restrict errorfds,// 异常集合
// 超时设置,NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。
struct timeval *restrict timeout);
- 数组遍历时间复杂度为O(n);
- 每次调用
BitsMap
需要拷贝2次,高并发场景下这样的拷贝消耗的资源是惊人的;- BitsMap 长度由参数
FD_SETSIZE
限制,默认最大值为1024,即只能监听0~1023的文件描述符;
应用场景
- select 的 timeout 参数精度为微秒,因此 select 更加适用于实时性要求比较高的场景;
- select 可移植性更好,几乎被所有主流平台所支持;
- 同时监控小于 1000 个描述符。
1.1.2 poll
为了摆脱select文件描述符最大个数的限制(当然还会受到系统文件描述符限制),poll 在内核态将文件描述符数组改为链表,链表节点内容基于pollfd
:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 要监听的事件 读事件、写事件、异常事件
short revents; // 文件描述符fd上实际发生的事件
};
- 链表遍历时间复杂度为O(n);
- poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高;
- select 会修改描述符,而 poll 不会。
应用场景
poll 的 timeout 参数精度为毫秒,且poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,可以使用 poll 而不是 select。
1.1.3 epoll
Linux 2.6 新增的 epoll(eventpoll,事件驱动的 poll) 是对 select 和 poll 的改进,解决了 “全量复制集合” 和 “文件描述符数量少” 这两个缺点,是性能最高的多路复用实现方式,能支持的并发量也是最大。
kueue 是 unix (FreeBSD,NetBSD, OpenBSD, DragonFly BSD, macOS)中用来替换select和poll的。
改进点如下:
-
内核使用红黑树存储所有需要监听的 Socket 连接,每个Socket 连接只需在添加(通过系统调用
epoll_ctl
)时传入一次,无需用户每次都重新传入; -
内核用链表存储就绪的 Socket 连接;这样用户态只需处理该链表即可,无需遍历整个Socket集合;
-
内核采用事件驱动机制来监听Socket是否就绪,避免轮询扫描全部 Socket 连接;
// 创建一个 epoll 实例,同时返回一个引用该实例的文件描述符。
int epoll_create(int size);
// 监听文件描述符 fd 上发生的 event 事件。op 表示要对 fd 执行的操作,添加,删除、更新
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 功能相当于 select/poll。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
struct eventpoll {
wait_queue_head_t wq; // 等待队列链表,存放阻塞的进程
struct list_head rdllist; // 存放数据就绪的 socktet
struct rb_root rbr; // redBlackTree 红黑树,管理用户进程下添加进来的所有 socket 连接
......
}
- 红黑树查找,时间复杂度为O(log n);
应用场景
- epoll 的 timeout 参数精度为毫秒,有大量的描述符需要同时轮询,并且这些连接是长连接、不活跃;
- C10K、C100K、C1000K:即单机同时处理 1 万(十万、百万)个请求(并发连接 1 万、10万、100万)的问题,首字母 C 是 Client 或者Concurrent的缩写。再往上走到 C10M问题 用 epoll 已经搞不定了?得用用户态网络协议栈,扯远了……
1.1.4 select vs poll vs epoll
项目 | select | poll | epoll |
---|---|---|---|
socket 数据就绪复制数量 | 全部复制(包含没有就绪的) | 全部复制(包含没有就绪的) | 仅复制数据就绪集合 |
socket集合复制次数 | 每次调用和返回均需复制 | 每次调用和返回均需复制 | 仅第一次调用复制,后续返回仅复制少量数据 |
集合大小 | 有限制 | 没有限制 | 没有限制 |
数据就绪机制 | 遍历 | 遍历 | 事件驱动 |
集合底层结构 | 数组 | 链表 | 红黑树 |
时间复杂度 | O (n) | O (n) | O (log n) |
阻塞超时精度 | 微秒 | 毫秒 | 毫秒 |
使用场景 | 实时性要求比较高,并发低于1000 | 实时性要求不高,并发稍微高于1000 | 海量并发,长连接、不活跃 |
1.1.5 I/O 事件通知的方式
-
水平触发(LT,Level Trigger):当数据就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。
-
边缘触发(ET,Edge Trigger):仅当数据从未就绪变为就绪时,通知一次,之后不会再通知。
边缘触发时,应用程序需要尽可能多地执行 I/O,直到无法继续读写,才可以停止。如果 I/O 没执行完,或者因为某种原因没来得及处理,那么这次通知也就丢失了。
- 水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。
- 区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。
- select 、poll 只支持水平触发;
- epoll 支持水平触发和边缘触发;
1.2 信号I/O
信号驱动 I/O 并不常用,它是一种半异步的 I/O 模型。在使用信号驱动 I/O 时,当数据准备就绪后,内核通过发送一个 SIGIO 信号通知应用进程,应用进程就可以开始读取数据了。
信号I/O和异步I/O区别:
- 信号I/O,内核只负责发信号通知用户态,有没有数据;即信号在数据就绪时发送;
- 异步I/O,内核会帮助用户态拷贝数据;即在数据拷贝完成时通知用户。
二、 I/O模型总结对比
在 Linux之【磁盘 IO】前世今生 我们介绍了阻塞I/O 、非阻塞I/O和异步I/O,本文我们梳理了I/O多路复用和信号I/O,至此,I/O 5 种模型已介绍完毕,对比如下:
附 Linux 前世今生系列文章:
-
Linux之【网络I/O】前世今生(二)
-
Linux之【网络I/O】前世今生(一)
-
Linux之【磁盘 IO】前世今生
-
Linux之【文件系统】前世今生(二)
-
Linux之【文件系统】前世今生(一)
-
Linux之【内存管理】前世今生(一)