Netty与高性能网络服务、Linux高并发网络编程实战、从epoll到Netty:物联网接入层技术剖析、深入理解I/O多路复用、服务端网络编程进阶指南
从select到epoll:I/O多路复用的演进之路
0 写在前面
做物联网平台这些年,我接触过不少网络编程的场景。从最早用传统 BIO 一个连接一个线程地处理设备接入,到后来迁移到 Netty 的 NIO 模型,中间踩过不少坑。而这一切的背后,其实都绕不开一个核心话题------Linux 的 I/O 多路复用机制。
如果你做过网络服务端的开发,大概率听过 select、poll、epoll 这几个词。它们都是 I/O 多路复用的实现方式,但效率和适用场景差异很大。这篇文章我想结合自己在物联网平台中的实战经验,把这几个机制的来龙去脉讲清楚,不堆砌太多源码,重在理解"为什么"。
1 先搞清楚什么是I/O多路复用
在讲 select 和 epoll 之前,先得把 I/O 多路复用这个概念说透。
假设你开了一家餐厅,厨房只有一个厨师(一个线程),但外面同时来了 100 个客人(100 个网络连接)。每个客人都在等自己的菜,厨师不可能同时做 100 道菜。那怎么办?
最笨的办法是雇 100 个厨师,每人盯一个客人------这就是传统的 BIO 模型,一个连接一个线程。连接少的时候还行,一旦并发量上来,光是线程上下文切换的开销就能把 CPU 吃满,更别提每个线程还要占一块内存栈空间。
聪明一点的做法是:让厨师先巡视一圈,看看哪些客人的菜已经准备好了可以上菜,哪些还需要等。厨师只处理那些"准备好了"的,其余的先不管。等下一轮再巡视。这就是 I/O 多路复用的核心思想------一个线程,同时监控多个 I/O 事件,哪个准备好了就处理哪个。
select、poll、epoll 都是这种思想的实现,区别在于"巡视"的效率天差地别。
2 select:老兵的荣光与局限
select 是最早的 I/O 多路复用机制,POSIX 标准定义,几乎所有操作系统都支持。它的用法大概是这样的:
c
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
你把所有需要监控的文件描述符(file descriptor,简称 fd)塞进三个集合里------可读、可写、异常。调用 select 之后,内核会遍历这些 fd,检查哪些已经就绪。返回时,内核会修改这三个集合,只保留就绪的 fd。
听起来挺合理的,对吧?但问题出在细节上。
第一个问题是数量限制。 在 Linux 内核中,fd_set 的大小是由 __FD_SETSIZE 宏定义的,默认值是 1024。也就是说,select 最多只能同时监控 1024 个文件描述符。虽然在编译内核的时候可以改这个值,但改完得重新编译内核,这在生产环境基本不现实。
对于物联网平台来说,1024 个连接连一个小区的智能设备都接不完,更别说工业场景下动辄上万台设备同时在线了。
第二个问题是性能。 select 的底层实现是轮询------每次调用都要遍历全部 fd。假设你有 10000 个连接,但某一时刻只有 50 个活跃,select 依然要检查 10000 个 fd 才能找出这 50 个。而且这还是每次调用都要做的工作,调用频率越高,浪费越严重。
更要命的是,select 在用户态和内核态之间有数据拷贝的开销。每次调用,你都得把三个 fd_set 从用户空间拷贝到内核空间,返回时再拷贝回来。连接数一多,光是拷贝数据就能消耗不少 CPU 时间。
第三个问题是接口设计。 select 用位图来表示 fd 集合,用完之后 fd_set 会被内核修改,下次调用前必须重新设置。这个"每次都要重置"的特性写起代码来特别繁琐,也容易出错。
3 poll:select的改良版,但治标不治本
poll 的出现主要是为了解决 select 的 fd 数量限制问题。它的接口长这样:
c
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll 不再用位图,而是用结构体数组来传递 fd 和事件,理论上没有 1024 的硬性上限(实际上受限于系统资源,通常是 65535)。而且 poll 不会修改传入的参数,不需要每次重置。
但 poll 的底层实现和 select 基本一样------还是轮询,时间复杂度仍然是 O(n)。当并发连接数很大的时候,性能问题依然突出。
打个不太恰当的比方:select 好比一个只能查 1024 人的花名册,poll 把花名册容量扩大了,但查人的方式还是挨个翻一遍。人少的时候无所谓,人多的时候就太慢了。
4 epoll:为高并发而生
Linux 2.5.44 开始引入 epoll,2.6 内核正式可用。它的出现,彻底改变了 Linux 下高并发网络编程的格局。
epoll 的设计思路和 select/poll 有本质区别。select/poll 是"你每次告诉我你要监控谁,我帮你查一遍",而 epoll 是"你先告诉我你要监控谁,我记下来,有事了我主动通知你"。
这种从"主动轮询"到"事件驱动"的转变,带来了质的飞跃。
epoll 的 API 只有三组:
c
// 创建一个 epoll 实例
int epoll_create(int size);
// 向 epoll 实例中添加/修改/删除监控的 fd
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 只调用一次,epoll_ctl 在连接建立或断开时调用,而 epoll_wait 是高频调用的。高频调用的 epoll_wait 几乎没有入参(不需要每次都传入全部 fd 列表),这比 select 每次都要传入全部 fd 的效率高了一大截。
而且随着连接数增加,epoll_wait 的开销几乎不增长。因为 epoll 在内核中维护了所有被监控 fd 的数据结构,epoll_wait 只需要检查"就绪列表"里有没有东西就行了。
5 epoll高效的两个关键
epoll 之所以快,核心在于两个设计:红黑树 和 就绪链表。
5.1 红黑树管理所有fd
当你通过 epoll_ctl 往 epoll 实例中添加一个 fd 时,内核会创建一个 epitem 结构体来描述这个 fd,然后把它插入到一棵红黑树中。红黑树的键就是文件描述符。
红黑树的好处是查找、插入、删除的时间复杂度都是 O(logN)。当你需要修改或删除某个 fd 的监控时,内核可以很快地找到对应的节点。相比之下,select/poll 每次都是线性遍历,O(n) 的开销在连接数大的时候差距非常明显。
5.2 就绪链表 + 回调机制
这是 epoll 最精妙的地方。
当你把一个 socket 注册到 epoll 中时,epoll 会在这个 socket 的等待队列上注册一个回调函数 ep_poll_callback。当这个 socket 上有数据到达(网卡收到数据,触发中断,最终唤醒 socket 的等待队列)时,这个回调函数就会被调用。
回调函数做了什么呢?很简单------它把对应的 epitem 从红黑树上找到,然后挂到就绪链表 rdllist 的末尾。
所以当调用 epoll_wait 时,内核只需要检查就绪链表是不是空的。如果不空,就把链表中的事件复制到用户空间,返回就绪的数量。如果为空,就把当前进程挂起,等待被回调函数唤醒。
整个过程没有轮询,没有无意义的遍历。事件来了就通知,没来就等着。这就是事件驱动的威力。
另外还有一个容易被忽略的优化:epoll 通过 mmap 让内核空间和用户空间共享同一块物理内存。这样在 epoll_wait 返回就绪事件时,不需要从内核空间向用户空间拷贝数据,直接共享内存读取就行,又省了一笔开销。
6 LT和ET:两种触发模式
epoll 支持两种工作模式:水平触发(Level Triggered,LT)和边沿触发(Edge Triggered,ET)。这是理解 epoll 必须跨过的一道坎。
6.1 水平触发(LT)------默认模式
LT 模式下,只要缓冲区里还有数据没读完,epoll_wait 每次调用都会通知你。
举个例子,假设一个 socket 接收缓冲区里来了 100 字节数据。在 LT 模式下,如果你只读了 50 字节,下次调用 epoll_wait 时它还会告诉你这个 socket 可读,直到你把 100 字节全部读完。
这种模式的好处是编程简单,不容易漏掉数据。缺点是如果你故意不读完,内核会反复通知你,造成一定的浪费。
6.2 边沿触发(ET)------高性能模式
ET 模式下,epoll_wait 只在状态变化的那一次通知你。缓冲区从空变非空,通知一次。之后不管你读不读完,只要没有新数据到来,就不会再通知。
还是上面的例子:100 字节数据到达,epoll_wait 通知你一次。你读了 50 字节,剩下 50 字节没读。下次调用 epoll_wait,它不会通知你。只有当新的数据再次到达时,才会重新通知。
这意味着在 ET 模式下,你必须在一次通知中把数据读完,否则剩下的数据就"丢了"(不是真丢了,只是你不知道它还在那里)。要做到这一点,通常需要配合非阻塞 I/O,循环调用 read 直到返回 EAGAIN。
ET 模式的编程复杂度高,但效率也更高,因为减少了 epoll_wait 的触发次数。Nginx 用的就是 ET 模式。
6.3 在物联网场景中的选择
在物联网平台中,我的建议是:
- 如果设备上报数据的频率不高、数据量不大,LT 模式完全够用,开发效率更高
- 如果是网关类场景,设备数量多、数据流密集,可以考虑 ET 模式来压榨性能
- 不管用哪种模式,非阻塞 I/O 都是推荐的做法,可以避免单个慢连接拖垮整个系统
7 select、poll、epoll的对比
说了这么多,用一张表来总结一下三者的区别:
| 对比项 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024(默认) | 65535(无硬限制) | 65535(无硬限制) |
| 就绪fd查找复杂度 | O(n) | O(n) | O(1) |
| 工作模式 | 轮询 | 轮询 | 回调 |
| 触发模式 | LT | LT | LT + ET |
| 内核/用户数据传递 | 每次拷贝全量fd | 每次拷贝全量fd | mmap共享内存 |
有一张 benchmark 图很能说明问题(来自网上公开的测试数据):当并发连接数在几百以内时,三者的性能差距不大。但连接数超过 1000 以后,select 的性能急剧下降,poll 稍好一些但也不理想,而 epoll 几乎是一条直线------连接数从一千涨到十万,响应时间几乎不变。
8 回到物联网平台
最后说说这些机制在实际项目中的意义。
一个典型的物联网平台需要同时处理成千上万台设备的 TCP 长连接或 MQTT 连接。设备的行为特点是:连接数多,但大部分时间处于空闲状态(只在有数据上报或需要下发指令时才活跃)。这正是 epoll 最擅长的场景------大量连接、少量活跃。
如果用 select,光是 1024 的连接数限制就不够看。即使用 poll 绕过了数量限制,每次都要遍历全部连接的开销也是不可接受的。
而 epoll 的事件驱动模型,天然适合这种"多连接、低活跃"的场景。连接建立时注册到 epoll,有数据来了内核主动通知,没有数据就安静等着。不管是一千台设备还是十万台设备,epoll_wait 的开销几乎不变。
这也是为什么 Netty(我们在项目中使用的网络框架)在 Linux 上默认会优先使用 epoll 作为底层实现。下一篇文章,我会深入到 Linux 内核源码层面,看看 epoll 具体是怎么实现这些机制的。