Netty与高性能网络服务、Linux高并发网络编程实战、从epoll到Netty物联网接入层技术剖析、深入理解I/O多路复用、服务端网络编程进阶指南从select到epollI/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 标准定义几乎所有操作系统都支持。它的用法大概是这样的intselect(intnfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,structtimeval*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 pollselect的改良版但治标不治本poll 的出现主要是为了解决 select 的 fd 数量限制问题。它的接口长这样intpoll(structpollfd*fds,nfds_tnfds,inttimeout);poll 不再用位图而是用结构体数组来传递 fd 和事件理论上没有 1024 的硬性上限实际上受限于系统资源通常是 65535。而且 poll 不会修改传入的参数不需要每次重置。但 poll 的底层实现和 select 基本一样——还是轮询时间复杂度仍然是 O(n)。当并发连接数很大的时候性能问题依然突出。打个不太恰当的比方select 好比一个只能查 1024 人的花名册poll 把花名册容量扩大了但查人的方式还是挨个翻一遍。人少的时候无所谓人多的时候就太慢了。4 epoll为高并发而生Linux 2.5.44 开始引入 epoll2.6 内核正式可用。它的出现彻底改变了 Linux 下高并发网络编程的格局。epoll 的设计思路和 select/poll 有本质区别。select/poll 是你每次告诉我你要监控谁我帮你查一遍而 epoll 是你先告诉我你要监控谁我记下来有事了我主动通知你。这种从主动轮询到事件驱动的转变带来了质的飞跃。epoll 的 API 只有三组// 创建一个 epoll 实例intepoll_create(intsize);// 向 epoll 实例中添加/修改/删除监控的 fdintepoll_ctl(intepfd,intop,intfd,structepoll_event*event);// 等待事件就绪intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);注意这个设计上的巧妙之处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 TriggeredLT和边沿触发Edge TriggeredET。这是理解 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的对比说了这么多用一张表来总结一下三者的区别对比项selectpollepoll最大连接数1024默认65535无硬限制65535无硬限制就绪fd查找复杂度O(n)O(n)O(1)工作模式轮询轮询回调触发模式LTLTLT ET内核/用户数据传递每次拷贝全量fd每次拷贝全量fdmmap共享内存有一张 benchmark 图很能说明问题来自网上公开的测试数据当并发连接数在几百以内时三者的性能差距不大。但连接数超过 1000 以后select 的性能急剧下降poll 稍好一些但也不理想而 epoll 几乎是一条直线——连接数从一千涨到十万响应时间几乎不变。8 回到物联网平台最后说说这些机制在实际项目中的意义。一个典型的物联网平台需要同时处理成千上万台设备的 TCP 长连接或 MQTT 连接。设备的行为特点是连接数多但大部分时间处于空闲状态只在有数据上报或需要下发指令时才活跃。这正是 epoll 最擅长的场景——大量连接、少量活跃。如果用 select光是 1024 的连接数限制就不够看。即使用 poll 绕过了数量限制每次都要遍历全部连接的开销也是不可接受的。而 epoll 的事件驱动模型天然适合这种多连接、低活跃的场景。连接建立时注册到 epoll有数据来了内核主动通知没有数据就安静等着。不管是一千台设备还是十万台设备epoll_wait的开销几乎不变。这也是为什么 Netty我们在项目中使用的网络框架在 Linux 上默认会优先使用 epoll 作为底层实现。下一篇文章我会深入到 Linux 内核源码层面看看 epoll 具体是怎么实现这些机制的。9 参考资料Java NIO Selector - BaeldungLinux下的I/O复用与epoll详解epoll - 维基百科一文读懂Linux epoll实现原理
物联网接入层技术剖析(一):从select到epoll
发布时间:2026/5/22 1:14:27
Netty与高性能网络服务、Linux高并发网络编程实战、从epoll到Netty物联网接入层技术剖析、深入理解I/O多路复用、服务端网络编程进阶指南从select到epollI/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 标准定义几乎所有操作系统都支持。它的用法大概是这样的intselect(intnfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,structtimeval*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 pollselect的改良版但治标不治本poll 的出现主要是为了解决 select 的 fd 数量限制问题。它的接口长这样intpoll(structpollfd*fds,nfds_tnfds,inttimeout);poll 不再用位图而是用结构体数组来传递 fd 和事件理论上没有 1024 的硬性上限实际上受限于系统资源通常是 65535。而且 poll 不会修改传入的参数不需要每次重置。但 poll 的底层实现和 select 基本一样——还是轮询时间复杂度仍然是 O(n)。当并发连接数很大的时候性能问题依然突出。打个不太恰当的比方select 好比一个只能查 1024 人的花名册poll 把花名册容量扩大了但查人的方式还是挨个翻一遍。人少的时候无所谓人多的时候就太慢了。4 epoll为高并发而生Linux 2.5.44 开始引入 epoll2.6 内核正式可用。它的出现彻底改变了 Linux 下高并发网络编程的格局。epoll 的设计思路和 select/poll 有本质区别。select/poll 是你每次告诉我你要监控谁我帮你查一遍而 epoll 是你先告诉我你要监控谁我记下来有事了我主动通知你。这种从主动轮询到事件驱动的转变带来了质的飞跃。epoll 的 API 只有三组// 创建一个 epoll 实例intepoll_create(intsize);// 向 epoll 实例中添加/修改/删除监控的 fdintepoll_ctl(intepfd,intop,intfd,structepoll_event*event);// 等待事件就绪intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);注意这个设计上的巧妙之处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 TriggeredLT和边沿触发Edge TriggeredET。这是理解 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的对比说了这么多用一张表来总结一下三者的区别对比项selectpollepoll最大连接数1024默认65535无硬限制65535无硬限制就绪fd查找复杂度O(n)O(n)O(1)工作模式轮询轮询回调触发模式LTLTLT ET内核/用户数据传递每次拷贝全量fd每次拷贝全量fdmmap共享内存有一张 benchmark 图很能说明问题来自网上公开的测试数据当并发连接数在几百以内时三者的性能差距不大。但连接数超过 1000 以后select 的性能急剧下降poll 稍好一些但也不理想而 epoll 几乎是一条直线——连接数从一千涨到十万响应时间几乎不变。8 回到物联网平台最后说说这些机制在实际项目中的意义。一个典型的物联网平台需要同时处理成千上万台设备的 TCP 长连接或 MQTT 连接。设备的行为特点是连接数多但大部分时间处于空闲状态只在有数据上报或需要下发指令时才活跃。这正是 epoll 最擅长的场景——大量连接、少量活跃。如果用 select光是 1024 的连接数限制就不够看。即使用 poll 绕过了数量限制每次都要遍历全部连接的开销也是不可接受的。而 epoll 的事件驱动模型天然适合这种多连接、低活跃的场景。连接建立时注册到 epoll有数据来了内核主动通知没有数据就安静等着。不管是一千台设备还是十万台设备epoll_wait的开销几乎不变。这也是为什么 Netty我们在项目中使用的网络框架在 Linux 上默认会优先使用 epoll 作为底层实现。下一篇文章我会深入到 Linux 内核源码层面看看 epoll 具体是怎么实现这些机制的。9 参考资料Java NIO Selector - BaeldungLinux下的I/O复用与epoll详解epoll - 维基百科一文读懂Linux epoll实现原理