I/O 多路复用(I/O multiplexing)
本文对阻塞/非阻塞、同步/异步等概念进行简要区分,简单介绍了5种I/O模型,对Linux平台3种I/O复用的方式进行了比较,重点分析了 epoll 的实现。
I/O 模型
得益于Unix“一切皆文件”的设计哲学,I/O操作本身通过 read/write 即可完成。《UNIX网络编程》划分了5个I/O模型,其中前4中都是同步I/O,只有异步I/O是异步操作:
- 阻塞I/O(Blocking I/O):进程发起I/O系统调用后被阻塞,切换进入内核态,操作完成后返回进程。
- 非阻塞I/O(Non-Blocking I/O):进程发起I/O系统调用后立即返回,没完成时返回-1,通过轮询查询完成情况。
- I/O复用(I/O Multiplexing):多个I/O事件使用同一个进程监听,任何一个事件完成都会返回进程,多路复用可以是阻塞/非阻塞的。如 epoll、Java NIO、Netty。
- 信号驱动式I/O模型(Signal Driven I/O):采用 signal + 回调的方式,使用场景有限。
- 异步I/O模型(Asynchronous I/O):采用消息队列等方式,代码逻辑和同步I/O差别较大。如 Windows IOCP、Java AIO。Linux平台早期不原生提供AIO接口,一般是通过epoll模拟,目前已有的 kernel native aio 和 glibc aio 也都还没得到广泛应用。
阻塞和非阻塞的区别在于发出读写请求后是否需要等待数据准备好才能返回,同步跟异步的区别在于数据从内核空间拷贝到用户空间是否由用户线程完成。
对于同步和异步的两种高性能I/O,I/O多路复用又称 Reactor模型,异步I/O又称 Proactor模型。
I/O多路复用的本质是使用1个线程监控多个连接。
非阻塞操作遍历即可实现多路复用,在内核中实现的必要性在于:
I/O操作本身需要系统调用,遍历的时候每次检查一个文件描述符都是一次系统调用,需要切换一次内核态,开销较大。在内核中实现的话,一次遍历可以只切换一次内核态,砍掉了上下文切换的开销。
select/poll/epoll 比较
poll 基本与 select 相同,除了一个连接数限制上的改进。
select 和 poll 没有把活跃连接和非活跃连接区分开,epoll 把二者区分开了。
epoll 相比 select/poll 的优点:
-
在内核空间使用红黑树保存一份文件描述符集合,无需每次都从用户空间拷贝传入,而 select 和 poll 每次查询都需要把参数从用户态拷贝到内核态;
-
通过回调函数异步监听套接字,减少了遍历套接字监听的开销;
-
把不常使用的维护文件描述符的 add/del 和频繁调用的 wait 查询操作分开,提高了 wait 性能;
-
返回就绪描述符个数的同时,返回具体哪些描述符就绪,而 select/poll 仍旧需要用户遍历查找就绪连接;
-
epoll 取消了线程数上限的限制(select 的 fd_set 使用 bitmap 来标记进程号,poll 使用链表取消了数量限制);
-
select/poll 都只有一个 API,epoll 有三个 API。
epoll 使用的场景:总连接数较多,但活跃连接少。
epoll 详解
3 个 API
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);
epoll_create()
会创建一个特殊的文件描述符,也就是后面参数中会用到的 epfd,用于作为一个整体来管理。参数 size 的存在是历史原因:早期 epoll 底层数据结构使用的是哈希表而非红黑树,所以需要一个size参数。目前size参数已经失去意义,但是仍要注意不要传入0,否则会导致invalid argument错误。- epfd 也算文件描述符,所以使用结束后也需要 close 释放避免 fd 耗尽。
epoll_ctl()
将文件描述符 fd 上的I/O事件纳入 epfd 进行事件监听(可读、可写、发送错误…),op 表示的是对 fd 的操作类型(添加、删除、修改)。返回值 0/-1 表示操作成功/失败。epoll_wait()
用于查询监听结果,返回值表示就绪的文件描述符个数,具体描述符保存在 events 数组中,数组大小通过 maxevents 传递。wait 可以配置为阻塞/非阻塞。wait 本身是一个阻塞操作,当监听到有连接已就绪或者 timeout 超时才会返回,但是有两个特殊情况:timeout 设为 0 的时候立即返回,设为 -1 的时候一直阻塞到有连接就绪。wait 阻塞挂起进程的时候会讲本线程加入到等待队列中,当某个I/O事件发生时,会检查是否有线程在等待该事件,如果 有则会唤醒该线程。
值得注意的是,epoll_wait()
的 events 参数和 epoll_ctl()
的 event 参数不同,前者是用来获取返回值的,后者是用来传入配置信息的。
红黑树的选用是兼顾了插入、删除、检索三大需求,尤其是检索,因为用到了回调函数,所以判断是否是重复插入。这里选的其实是map数据结构,根据fd这个key来查找底层保存原始的结点,因为fd恰好是整数,所以除了红黑树、哈希表,其实还可以用数组,链表则不行因为查找操作是O(N)开销太大。最后考虑到伸缩性,红黑树脱颖而出。
3 个数据结构
红黑树 + 就绪队列 + 等待队列:
- epoll 通过在内核空间保存和维护一份文件描述集合,避免了每次查询在用户态和内核态之间的数据交换。
- epoll 通过红黑树来管理 fd,插入/删除/修改等操作都在红黑树上进行,时间复杂度O(logN),效率比 select 的 bitmap 和 poll 的链表的O(N)遍历操作都要高。因为添加都是在红黑树上完成的操作,所以重复添加是没有效果的,第一次添加就会把I/O事件与设备之间建立回调关系。
- epoll 通过双向链表来管理就绪事件。注册的事件发生时,回调函数 ep_poll_callback 会把对应 fd 加入双向链表 rdllist 中,调用
epoll_wait()
时只需要检测 rdllist 中是否有注册事件存在即可,效率O(1)远高于遍历。事件存在,则会将事件拷贝到用户内存中去(参数列表中的 events)。 - epoll 通过双向链表来管理阻塞线程。当线程等待的事件没有发生的时候,会将线程等待事件加入等待队列,等待有数据到来时,主动检查等待队列中是否有对应等待,如果有则唤醒线程。
红黑树相比链表、数组、哈希表的优点在于:插入、删除、检索的效率都不差,而且I/O完成时可以直接删除结点转存入就绪双链表。
epoll 的多路复用通过 eventpoll 结构体来管理,由 epoll_create()
创建并由 epfd
标识,epoll_ctl()
和 epoll_ctl()
都在此结构体上进行操作。参考 Linux 4.3源码:
struct eventpoll {
wait_queue_head_t wq; // sys_epoll_wait() 使用的等待队列
struct list_head rdllist; // 双链表,用于保存已就绪的fd
struct rb_root rbr; // 红黑树,用于保存fd以及注册的事件
struct epitem *ovflist; // 单链表,将就绪fd发送至用户空间
// ...
}
每一个注册了事件的文件描述符,使用一个 epitem 来管理:
struct epitem {
struct rb_node rbn; // 所属红黑树
struct list_head rdllink; // 就绪队列
struct eventpoll *ep; // 所属epoll结构体
struct epoll_event event; // 注册事件
// ...
}
触发方式
select 和 poll 的触发方式是水平触发 LT(Level Triggered),epoll 的默认触发方式也算 LT,但可以选用边缘触发 ET(Edge Triggered)。
边缘触发和水平触发,都是电子学里术语,表示是只有在上升沿/下降沿才触发事件,还是在整个高电平阶段都触发事件。
对应到 epoll 中,就是说 epoll_wait()
取出事件后,如果有事件未被处理完毕(如没有读写完毕),水平触发情况下会被重新放回就绪队列,只要上面还有未处理事件,每次都会返回此连接;而边缘触发只会在就绪时通知一次,之后不再通知。
边缘触发的效率相对更高一些,因为减少了事件被重复触发的次数。
关键总结
epoll 提升性能的关键在于,使用回调函数将就绪连接单独分离出来,减少了 wait 操作时间开销。 这是O(N) 到 O(1) 的提升。
epoll 在内核空间维护了一份文件描述符集合,避免每次查询传入,减少了 wait 操作空间开销。底层数据结构使用一个特殊文件描述符epfd标识,使用完需要关闭以释放资源。
但是性能提升是有场景限制的,适用于总连接数较多但活跃连接少的情况;如果所有连接都很活跃,实际上是没有性能是没有提高的。
当连接数很少并且都很活跃的时候,epoll 反而性能不如 select/poll,因为 epoll 的通知机制使用了大量回调函数。
I/O多路复用是目前高性能同步I/O的经典实现,但是仍旧存在着从内核态到用户态的数据拷贝过程。真正的异步I/O是数据从内核空间拷贝到用户空间的过程也算由系统线程完成的,目前应用并不广泛。对于更高性能的网络I/O,目前的主流方案是 Intel DPDK,旁路内核,一切工作直接在用户空间完成。
典型应用
Redis 6.0 之前一直都是使用单线程来做网络管理和读写操作的,6.0 之后开始将多线程引入网络管理。原因在于:连接数较少的时候,单线程+I/O多路复用就够用了,当连接数足够高的时候并且CPU是多核处理器的时候,多线程能够更好利用CPU资源提升连接数。