
开博客写技术内容这些年被问得最多的一个话题就是网络并发到底怎么扛从 BIO 到 NIO从 select 到 epoll很多新手卡在概念上绕不出来。而 Linux 内核里的 IO 多路复用正好是理解高并发网络服务的第一道分水岭。今天这篇专门把Poll这个词讲透——它既不是新东西也没被完全淘汰恰恰是理解整个 IO 模型演进的绝佳中间站。先给结论poll 和 select 一样都是 IO 多路复用的经典实现它解决了 select 文件描述符数量上限的问题但又不像 epoll 那样有事件回调机制。这篇博文会把 poll 的背景、内核原理、编程实操、坑点优缺点一次性讲明白适合正在学 Linux 网络编程的开发者、做嵌入式内核移植的朋友以及那些对 epoll 似懂非懂、想回头补基础的人。1. 背景篇为什么有了 select内核还要搞出 poll1.1 阻塞 IO 和它的“一人一线程”困境先回到最原始的网络服务模型。早期写服务器程序最朴素的做法就是一个连接来了创建一个线程或进程去处理它的读写。这个线程在等待网络数据时是阻塞的也就是卡在read()或write()系统调用上啥也不干光等着数据从网卡抵达内核缓冲区再拷贝到用户态。这种模型在连接少的时候完全没问题代码逻辑直来直去。可一旦并发上来比如同时挂了 5000 个客户端连接你就得创建 5000 个线程。线程多了之后问题就爆发了每个线程默认栈空间要 8MB虚拟内存光栈内存就吃紧线程切换要保存和恢复上下文CPU 时间大量耗费在调度上再加上锁竞争和频繁 sleep/wakeup整个服务的吞吐量甚至可能不如 100 个连接时的情况。于是大家就琢磨能不能让一个线程去盯着几千个连接谁有数据我再去处理谁这个思路就是 IO 多路复用。1.2 select 的贡献与天花板Linux 内核最早提供的多路复用方案是 select。它的思路很直接你告诉我你关心哪些文件描述符以下简称 fd把它们的集合传给我我去挨个检查哪些 fd 已经可读、可写或者出错了再告诉你结果。select 的模型处理少量连接很舒服但天生有两个硬伤。第一fd 数量有上限。FD_SETSIZE在 Linux 上默认是 1024用fd_set这个位图结构来表达集合位图大小写死。哪怕你把内核参数调了用户态这边的结构也装不下多于 1024 的 fd。这在早期桌面系统上够用但对于要支撑几万连接的服务器来说就是一个需要绕过去的坎。第二性能是线性退化。select 每次调用都要把整个 fd 集合从用户态拷贝到内核态内核遍历一遍再把可读可写结果拷回来。整个过程的复杂度是 O(n)。更难受的是select 返回后你根本不知道具体是哪些 fd 就绪了还得自己再遍历整个集合去检查FD_ISSET。连接数量越大白白浪费的次数越多。1.3 poll 的定位打破 1024 藩篱poll 的诞生很大程度上就是冲着 select 的第一个硬伤去的。它不再用定长的位图而是用数组来管理 fd。这个数组的长度只要内存够你可以往上传几千、几万个不再受FD_SETSIZE限制。这样一来高并发场景下能管理的连接数立刻不一样了。但从模型上讲poll 仍然是和 select 一样的“轮询水平触发”思路也就是每次调用都要全量扫描返回后再全量检查。它解决了量的限制没有解决质的冲突。在理解这一点之前我们先看看 poll 在内核态究竟怎么干活以及它在用户态的用法到底是怎样的。2. 原理篇poll 在内核里到底转了几圈2.1 用户态的编程视角一个数组玩转所有 fdpoll 的系统调用长这样#include poll.h int poll(struct pollfd *fds, nfds_t nfds, int timeout);重点是第一个参数struct pollfd数组。每个元素代表你关心的一个 fdstruct pollfd { int fd; // 文件描述符 short events; // 关心的事件掩码 short revents; // 内核返回的事件掩码 };events是你请求监控的事件常用的有这么几个POLLIN有数据可读包括对端关闭连接read 不会阻塞POLLOUT可以写数据发送缓冲区有空间POLLERR发生错误这个不需要你请求内核会在 revents 里主动返回POLLHUP对端挂断同样会被动返回POLLPRI有紧急数据比如带外数据调用 poll 时你把关心的 fd 填进数组events置上掩码revents清 0。内核返回后遍历数组查看revents就知道哪个 fd 就绪了。nfds是数组里元素个数timeout单位是毫秒不是秒。timeout 传 0 表示立即返回传 -1 表示无限期阻塞直到某个 fd 有事件。第一次用 poll 的人很容易有个错觉POLLIN就像“有人敲门”返回POLLIN就代表我 read() 一定能拿到数据。实际上并不完全是这样。POLLIN表示“read 调用不会阻塞”这和对端恰好发来了一整包完整数据是两码事。比如对端发了 2 个字节而你期望读到 100 字节read() 可能只返回 2 字节需要循环读取。还有对端优雅关闭连接时read() 返回 0此时 poll 也会把这个 fd 标记为可读你要用read() 0来判断对端关闭而不是等POLLHUP。2.2 内核执行流程do_sys_poll 的层层深入用户态调用poll()之后大概经历这样一个路径以 Linux 4.x/5.x 为例poll() → SYSCALL_DEFINE3(poll) → do_sys_poll() → do_poll() → do_pollfd() → fdget() → file-f_op-poll()do_sys_poll先处理一些边界条件比如 nfds 是否合法、timeout 是否越界。然后分配内存来存放拷贝进来的 pollfd 数组。这里有个很实用的细节如果数组不太大内核会直接放在栈上一个固定大小的poll_wqueues结构里超过阈值才走kmalloc堆分配。接着进入do_poll它的核心是一个大循环把所有 pollfd 数组拷贝到内核态的poll_list链表里遍历每个 pollfd调用do_pollfd最终落到具体文件系统实现的file-f_op-poll()回调do_pollfd会调用poll_wait()把当前进程挂到这个文件对应的等待队列上同时返回当前的掩码任何一次遍历后如果发现有人满足 POLLIN/POLLOUT就置上标志位count检查count 0如果是说明已经有 fd 就绪直接退出循环如果count 0且 time 没超时继续阻塞等待。这里的等待不是死等而是通过schedule_timeout让出 CPU直到等待队列唤醒或者超时时间到。这里最重要的一步就是poll_wait。每个驱动和文件系统都会维护自己的等待队列wait queue。比如 TCP socket它的 sock 结构里有一个sk_wq等待队列。poll_wait 做的事情就是让当前进程“登记”到这个等待队列里等网卡中断来了协议栈把数据收进接收缓冲区再调用wake_up_interruptible把队列里的进程唤醒。唤醒后进程从schedule_timeout返回再次执行一次完整的遍历检查。这就是 poll 的精髓没有数据的时候进程睡眠不占 CPU数据到了靠中断驱动唤醒再确认一次就绪状态。顺带说一句如果你直接去读内核源码会发现poll系统调用在不同架构上的实现略有差异。有些架构会把poll翻译成select兼容层有些则直接走独立的 sys_poll。不必在这上面纠结行为是一致的。2.3 和 select 的差异从位图到链表的进化搞清 poll 的原理后可以把它和 select 做个明确的对比。select 用fd_set位图表达 fd 集合有FD_SETSIZE限制poll 用pollfd数组表达理论上只受内存限制。select 每次要额外计算最大 fd 加一的数值传给内核poll 直接给 nfds 数组长度就行。还有一个容易被忽略的区别select 的 timeval 参数在 Linux 上会被内核修改剩下的时间会被写回所以如果要在循环里多次调用 select每次都得重新初始化 timeval。而 poll 的 timeout 是以毫秒为单位的整数内核不会修改你的变量循环里重复用更省心。有人管 poll 叫“第二代 IO 多路复用”其实不太准确。它更像是 select 的修正版模型不变手段升级。真正的质变要从 epoll 引入事件驱动和回调机制才发生这个放到第五节展开。3. 实操篇用 poll 写一个真实的多连接服务器3.1 从 echo 服务器看 poll 的基本用法光说不练没意思。我写一个最简单的 echo 服务器——客户端发送什么服务端原样回复什么但允许多个客户端同时连接。这是理解 poll 的最佳入门项目。#define _GNU_SOURCE #include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include poll.h #include fcntl.h #include netinet/in.h #include arpa/inet.h #include sys/socket.h #define MAX_CLIENTS 4096 #define BUF_SIZE 1024 int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(EXIT_FAILURE); } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; bzero(addr, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(8080); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(EXIT_FAILURE); } if (listen(listen_fd, 128) 0) { perror(listen); exit(EXIT_FAILURE); } struct pollfd fds[MAX_CLIENTS]; for (int i 0; i MAX_CLIENTS; i) { fds[i].fd -1; } fds[0].fd listen_fd; fds[0].events POLLIN; int nfds 1; printf(poll echo server listening on 8080...\n); while (1) { int ret poll(fds, nfds, -1); if (ret 0) { perror(poll); break; } if (fds[0].revents POLLIN) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, client_len); if (conn_fd 0) { perror(accept); continue; } // 找一个空闲槽位存放这个新连接 int slot -1; for (int i 1; i MAX_CLIENTS; i) { if (fds[i].fd -1) { slot i; break; } } if (slot -1) { printf(too many clients, reject %d\n, conn_fd); close(conn_fd); } else { fds[slot].fd conn_fd; fds[slot].events POLLIN; fds[slot].revents 0; if (slot 1 nfds) { nfds slot 1; } printf(new client, fd%d, slot%d\n, conn_fd, slot); } } // 遍历所有客户端 fd处理就绪事件 for (int i 1; i nfds; i) { if (fds[i].fd -1) { continue; } if (fds[i].revents (POLLIN | POLLHUP | POLLERR)) { char buf[BUF_SIZE]; ssize_t n read(fds[i].fd, buf, sizeof(buf)); if (n 0) { // 连接关闭或出错 close(fds[i].fd); fds[i].fd -1; printf(client fd%d closed\n, fds[i].fd); } else { // 原样写回 ssize_t written write(fds[i].fd, buf, n); if (written 0) { close(fds[i].fd); fds[i].fd -1; } } } } } close(listen_fd); return 0; }这段代码的逻辑很清楚一个固定大小的 pollfd 数组下标 0 放监听 socket每次 poll 返回后先处理新连接再处理所有现有连接的读写。代码有几处需要注意的地方。我在处理POLLHUP时直接并入POLLIN分支因为对端关闭时 poll 通常会在revents里置上POLLIN | POLLHUP这时候 read() 返回 0正好走n 0的逻辑完成清理。如果只监听POLLIN而不理POLLHUP关闭事件可能没法及时被处理。数组大小是固定的 4096这是 po 的实现上限不代表 poll 的 fd 上限只是这个值。真实项目中这个数组应该动态扩容。poll 本身支持很大的 nfds扩容的逻辑你自己控制就行比如当 nfds 逼近数组容量时 realloc 一个更大的数组。3.2 timeout 选择的艺术poll 的第三个参数是个大学问。开发调试阶段我建议 timeout 传 -1让 poll 一直等这样逻辑简单不会因为超时返回导致误判。但生产环境如果所有连接都空闲poll 会一直阻塞在那里想做一些定时检查比如心跳就无从下手。这时可以把 timeout 设为某个毫秒数比如 5000然后检查 poll 返回值是不是 0。返回 0 说明超时且没有任何 fd 就绪可以用来执行定时任务再继续下一次 poll。还有一种常见坑就是把 timeout 当成了“事件发生的时间限制”。实际上 poll 的超时是“最迟返回时间”如果期间有 fd 就绪它会立刻返回不会傻等到超时。所以循环处理轮次时不要以为时间到了才返回一次。考虑一个应用场景你给 poll 传了 1000 毫秒超时但第 100 毫秒就有数据了poll 立刻返回返回值大于 0。这完全正常。3.3 监听可写事件要注意的坑POLLOUT事件极其容易误用。很多新手在客户端连接建立后就把events同时置为POLLIN | POLLOUT然后发现 poll 几乎每次都返回POLLOUTCPU 瞬间跑满。这是因为 TCP 的发送缓冲区通常有空间比如默认 16KB~几百KB只要缓冲区没满POLLOUT就一直是就绪状态。正确的用法是只在你确认要发送数据且发送缓冲区可能已满时才去监听POLLOUT。比如你有一个很大的响应要写write 返回 EAGAIN 或只写了一半这时候才登记POLLOUT等缓冲区腾出空间再继续写出。写完立刻把events里的POLLOUT关掉回到只监听POLLIN的状态。这个坑在 select 时代也有换成 poll 之后因为 fd 上限提高更容易踩。其实本质是一个问题多路复用里可写事件是“零延迟就绪”的高频事件必须按需开启不能一直开着。4. 常见问题与排查技巧实录4.1 poll 返回后找不到就绪 fd有时候 poll 返回了大正数你满心欢喜去遍历结果一个 fd 都没处理到。先别怀疑内核绝大部分情况是你对nfds的管理出了问题。比如你从数组中间删除一个 fd槽位置 -1但没有收缩nfds或者新加 fd 时没有同步更新nfds。poll 内核只看你传的nfds范围内的事件如果有个 fd 的槽位已经被释放但 nfds 没变内核还是会检查它只是 fd 是 -1 的话就直接忽略。反过来如果你新加了 fd 却没增大 nfds新 fd 的 events 永远不会被监控数据到了也不会有 revents。我自己的经验是动态管理 pollfd 数组时不只维护一个 nfds还得维护一个“最大使用槽位索引”的变量。比如上面的代码里每次新增 fd 时nfds max(nfds, slot 1)删除 fd 后不收缩 nfds只把 fd 置为 -1。这样既避免每次删除后挪动数组带来的 O(n) 开销又保证 poll 检查范围足够大。等到空闲槽位多到一定程度再压缩数组这是典型的空间换时间。还有个隐蔽场景如果数组中某个 fd 被 close 了但 pollfd 里还留着它的旧值之后这个 fd 号又被其他文件比如磁盘文件复用你可能会在 poll 返回时发现一堆莫名其妙的就绪事件。所以 close 后必须立即把数组里的 fd 置为 -1否则就是“悬空 fd”后果非常难查。4.2 惊群问题poll 在 fork 多进程下的表现不少做高并发服务的人会考虑 fork 多个 worker 进程每个进程都调用 poll 监听同一个 listen_fd。这时如果没有特殊处理一个连接到来会唤醒所有 worker 进程但只有一个是真正 accept 成功的剩下的白白空跑一圈。这就是经典的“惊群”问题。在内核比较老的版本上poll 场景下的惊群是真实存在的解决手段一般是用互斥锁限制同时只有一个进程调用 poll或者让每个 worker 监听不同的端口更少见。如果用的是较新的内核linux 已经针对 accept 场景做了优化多个进程同时 accept 时由内核唤醒其中一个惊群问题减轻不少。但 poll 本身覆盖的场景更广包括普通可读事件这些不一定有内核级优化。真要到分布式多进程网络服务这一层epoll EPOLLEXCLUSIVE 是更合适的方案poll 的劣势在这里非常明显。4.3 不要把 poll 用成“计时器”有时你会看到有人用poll(NULL, 0, 500)来做毫秒级 sleep。这个用法是合法的本质上就是让内核无事可做地等 timeout 毫秒。它比 usleep/nanosleep 有一定优势比如不会被信号打断某些信号会中断 nanosleep而 poll 在超时时间内会尽量睡够。但它的睡眠精度一般依赖内核的时钟节拍HZ如果 HZ250实际误差可能到 4 毫秒级别。对时序要求高的场景还是要用高精度定时器比如 POSIX timerfd。这条技巧偶尔有用但别把它写进主业务逻辑里否则代码的可读性和可移植性都会打折。4.4 信号中断导致 poll 返回 EINTRpoll 是慢系统调用之一。进程收到信号并且信号处理函数没有设置 SA_RESTART 标志时poll 会返回 -1errno 等于 EINTR。新手第一次遇到会非常懵好好的连接就这么“断”了正确做法是判断 EINTR 后重试进入 polldo { ret poll(fds, nfds, timeout); } while (ret 0 errno EINTR);在使用了 SIGCHLD、SIGPIPE 这类信号的程序里这段代码几乎是必须的否则你的服务会莫名奇妙地退出。我当初调试一个多进程服务压测时进程时不时退出打日志才发现全是 poll 返回 EINTR 又没处理导致的。看清这一点之后凡是写网络循环我都习惯性地加这个重试逻辑不加反而不踏实。5. 优缺点剖析与选型建议同样多路复用为什么 poll 会被 epoll 替代5.1 poll 的优势清单poll 到现在没有被踢出内核是因为它有不可替代的实用价值。第一没有 1024 这个硬上限。poll 能管的 fd 数量只受系统内存和进程限制如ulimit -n约束。甚至你ulimit -n调大到 10 万poll 数组也能往上扩。这一点比 select 跨出了一大步。第二实现和调试简单。poll 是同步阻塞式的多路复用模型代码整体顺向不需要事件循环那种反向回调。对于连接数几千上万的嵌入式网关、小型服务器poll 完全够用写起来比 epoll 省很多心思。第三可移植性较好。poll 是 POSIX 标准定义的接口各种类 Unix 系统包括嵌入式 Linux、macOS、BSD、QNX基本都支持。epoll 是 Linux 专有的。如果你的代码将来要在多个平台编译poll 是比 epoll 省事得多的抽象层。第四对普通 fd 的支持更通用。epoll 主要擅长监控 socket 和某些设备 fd对普通文件比如磁盘上的日志文件的监控支持有限。poll 的 f_op-poll 接口几乎所有文件类型都实现了包括终端、管道、FIFO、特殊设备文件。我写一个监控目录变化的工具、支持管道输入的命令行程序时poll 比 epoll 顺手太多。5.2 poll 的劣势隐藏在“每次全量扫描”里的成本poll 最遭诟病的是性能模型。每次调用用户态要把整个 pollfd 数组拷贝到内核态内核遍历每一个 fd调用它们的 poll 回调返回时把 revents 拷回用户态用户态还要再遍历一遍数组才能知道谁就绪了。整个过程是 O(n)n 是所有被监控的 fd 数量无论其中有多少个实际上是有事件的。连接少还好说一旦监控 10 万个 fd哪怕其中只有 1 个真正有读事件你也要付出 10 万次遍历的代价。这种“主动轮询所有候选者”的模型决定了它无法支撑超大连接数的 C10K 甚至 C100K 场景。更细一层看poll 没有把“变更”和“等待”拆开。你每次都要重新告诉内核“我对哪些 fd 感兴趣”哪怕上次调用之后只有一个 fd 的 events 变了其他几万个都没变。内核不缓存这些状态不知道你这次和上次的区别。epoll 用epoll_ctl维护一棵红黑树epoll_wait只负责取就绪链表的头部复杂度直接降到 O(就绪数量)彻底绕开了全量扫描。另外poll 对事件是水平触发Level Triggered简称 LT的。意思是 fd 只要还有未读数据每次 poll 都会返回它可读。程序员如果忘记 read 干净缓冲区下次 poll 还会立刻返回看起来就像 fd 一直就绪导致忙循环。epoll 除了支持 LT还能选择边缘触发Edge TriggeredET只有状态从“无数据”变成“有数据”时才通知一次配合非阻塞 IO 能做更高吞吐的事件驱动模型。5.3 poll、select、epoll 横向对比表维度selectpollepollfd 数量上限FD_SETSIZE通常 1024无固定上限受内存约束无固定上限受内存约束数据结构fd_set 位图pollfd 数组内核红黑树 就绪链表算法复杂度O(n)O(n)注册 O(1)等待 O(就绪数)每次调用拷贝fd 集合全量拷贝pollfd 数组全量拷贝只用 epoll_ctl 注册/更新wait 不拷数组就绪 fd 定位遍历 fd_set遍历 pollfd 数组直接从就绪链表取有事件类型陪标触发模式LTLTLT ET可读事件定位效率低要检查全部低要检查全部高只取有事件的可移植性广泛POSIX广泛POSIXLinux Only修改监听集合代价每次重建 fd_set每次改 pollfd 数组再全量传只对变化的 fd 调 epoll_ctl这张表看起来很直观但我要多说一句表格里的复杂度是以连接数为变量的实际上还要考虑你的事件处理模式。如果你的服务只有几百个连接而且每个连接都有较大概率短时间内收发数据poll 的 O(n) 并不构成瓶颈。最大误区是“无脑上 epoll”结果把代码复杂度拉高好几倍收益却只有 2% 的性能提升完全没必要。5.4 什么时候我仍然建议用 poll说说我个人的项目经验。早期做一个嵌入式 Linux 网关CPU 是单核 800MHz内存 64MB。设备上同时连了 200 个左右传感器终端还要接一个云平台。用 epoll 当然能做但交叉编译工具链版本较老内核 2.6.35epoll 的某些接口行为在新版本内核上不一样调试成本高。而 poll 在哪个内核版本上行为都几乎一致代码移植过去直接编译即可。那个项目最终就是用 poll 非阻塞 IO 多线程每线程一个 poll 循环处理一组 fd把问题解决了。这种场景的典型特征连接数在几百到一万之间事件量不是特别密集对响应延迟没有极端的微秒级要求同时代码需要长期可维护可移植。在这种范围内poll 反而是投入产出比最高的方案。反过来如果你在做上百万长连接推送服务代理服务器、网关要转发海量请求需要 ET 模式做精细读取控制单个线程处理数万连接且大部分时间空闲那还是老实上 epoll 吧。poll 在这种场景里只会成为瓶颈。6. 扩展思考从 poll 到 io_uring一个模型的两种未来很多文章讲到 epoll 就收尾了可我总想提一嘴 io_uring因为理解了 poll 之后能更清楚地看到时代的转向。poll 模型是“进程主动去查”epoll 模型是“内核主动回调”它们本质上还是同步 IO 模型——用户线程发起等待内核做监听就绪后再由用户线程自己调用 read/write 去拷贝数据。而 io_uring 把“提交 IO 请求”和“收割 IO 结果”两个阶段都交给内核用 SQ提交队列和 CQ完成队列两个环形队列在用户态和内核态之间交换数据连 read/write 系统调用本身都可以省略。你回头再看 poll 的内核流程do_poll 里的循环遍历、poll_wait 挂等待队列、schedule_timeout 入睡、数据到达后被唤醒——这个循环很直观也很符合人类的直觉。但正是这种“每次全量检查”的习惯让它在不断增长的连接规模面前越来越吃力。io_uring 则代表另一种思路我不检查了你的数据到了直接丢到队列尾我用一个循环去消费完成事件。成本从 O(n) 直接摊到 O(事件数)。在不同的阈值下select、poll、epoll、io_uring 各有各的主场没有银弹。理解了 poll你就理解了为什么会有后面那些所谓更“高级”的东西。回到最初的话题如果你正在为手头的项目纠结到底用哪个多路复用模型建议不要只搜“poll 和 epoll 哪个好”而是先问自己三个问题我预计的最大连接数是多少我的事件是密集还是稀疏我的代码需要在哪些平台上跑答案出来了选型也就顺理成章了。poll 不会让你失望也不会给你意外的惊喜它就是一个稳定、简单、可靠的老朋友——只要你知道它的边界在哪里。