
开头我用一个真实场景引入手头接到一个“客户端连接数突然上不去”的问题或者读者刚学完socket编程觉得select够了。然后用从业者口吻引入poll。注意不需要主标题直接从##开始。开头200字以上。开始写全文注意字数控制。我会认真铺开每个小节的内容确保主体超过5000字。 先说个真实场景。早年我接手过一个嵌入式网关项目CPU 主频不高要同时盯着四十多路串口和十几路 TCP 连接当时代码里清一色的select一上线就跑得歪歪扭扭FD_SETSIZE默认 1024文件描述符一多就装不下每次循环还得把 fd_set 从用户态整个复制进内核select 返回后再逐位扫描找出就绪的 fd越到后面越觉得这是在拿蛮力对抗现实。换方案的时候我第一个想到的就是 poll。它的名字直白接口也直白看一遍 man page 就能写出第一版能跑的东西。但真要用好它必须得钻进 Linux 内核里看一遍它的实现路径——从poll()→vfs_poll→fd-f_op-poll再到驱动里的poll_wait和等待队列唤醒这一整条链路上任何一个环节松了你都会遇到poll 明明说没数据read 却直接读出来了这种玄学问题。这篇就把 poll 的背景、内核原理、优缺点一次讲透最后附带可落地的用户态调用和驱动实现细节。适合刚把 socket 编程摸熟、想进阶理解 IO 多路复用的人也适合做嵌入式驱动、想给字符设备加 poll 支持的内核开发者。1. 从阻塞 IO 到 IO 多路复用Poll 出场的历史背景1.1 为什么需要 IO 多路复用先回到最原始的模型。一个服务端程序要同时跟很多客户端通信最简单粗暴的做法是来一个连接 fork 一个进程或者起一个线程去读。但这套方案有个天然瓶颈每个连接都要消耗独立的进程/线程资源上下文切换的开销会随连接数线性增长几千个连接同时在线时调度器光顾着切换任务了根本没多少时间真正处理数据。更关键的是阻塞模型本身浪费严重。一个线程阻塞在read()上等数据这段期间它什么也干不了CPU 时间片白白消耗。非阻塞 IO 可以绕开这个问题但代价是你得自己反复调用read()去轮询——如果 fd 数量上千每次都要把每个 fd 试一遍这里面绝大部分调用都是无效的系统调用性能同样稀烂。IO 多路复用的核心价值就是把多个 fd 的就绪状态监听打包成一个系统调用让你一次传入一堆 fd内核统一帮你盯着哪个有动静再告诉你。select 是最早的尝试poll 是对它的第一次系统性修正epoll 则是在 poll 思路上继续深挖后的产物。1.2 select 的三个致命短板select 刚出来的时候是革命性的但工程实践很快暴露了三个硬伤。第一是 fd 数量上限。fd_set是一个位图默认大小是FD_SETSIZE绝大多数 Linux 系统里它就是 1024。就算你想办法改这个宏重新编译这个位图结构本身也不适合大规模文件描述符管理。第二是每次调用都要把整个 fd_set 从用户态拷到内核态select返回后再拷回来一大块内存来回搬fd 越多无谓开销越大。第三是返回之后内核不会告诉你到底哪个 fd 就绪了你得从头到尾遍历一遍 fd_set把所有位全部检查一遍才知道结果时间复杂度 O(n)。这三个问题里第一个和第三个对高并发场景是致命的第二个问题则是随着 fd 数量增长逐渐变得不可接受的。select 能撑住百级别的连接到千级别就开始力不从心。1.3 poll 的设计思路把 select 的病一次性治掉poll 的解决思路很直白放弃位图改用数组。每个监听的 fd 用一个pollfd结构体描述里面存着 fd、你关心的事件、内核返回的事件。数量不再有硬编码上限数组多大由你说了算。至于返回后不知道哪个 fd 就绪的问题poll 用revents字段解决了每个 pollfd 里都有一个独立的返回事件字段内核在返回时直接把结果写在对应位置你再遍历一遍数组逐个检查revents就行不需要跟位图里面某个 bit 到底对应哪个 fd这种映射关系纠缠。注意 poll 仍然是每次调用传全量数组这一点和 select 传全量 fd_set 是一样的也是它最终没有成为高性能服务器首选的根本原因。但在那个年代poll 是一次很漂亮的大修它把 select 里最让人难受的数量上限和事件表达方式两个问题做掉了代价小、兼容性好直到今天教材和面试题里依然绕不开它。2. Poll 的核心数据结构与内核路径拆解2.1 pollfd用户态的唯一操作入口用户态所有东西几乎都围绕一个结构体展开struct pollfd { int fd; // 要监听的文件描述符 short events; // 用户关心的事件掩码 short revents; // 内核返回的就绪事件掩码 };events是你告诉内核我想等什么revents是内核告诉你实际发生了什么。常用的掩码宏如下常量含义可用在 events可用在 reventsPOLLIN数据可读是是POLLPRI紧急数据可读是是POLLOUT可以写是是POLLERR出错否是POLLHUP挂断否是POLLNVALfd 没打开否是POLLRDHUP对端关闭连接是是注意一个细节POLLERR、POLLHUP、POLLNVAL这三个事件不需要你主动去 events 里申请内核只要检测到对应情况会自动把它们写进revents。如果你在用户态处理时只检查POLLIN而不检查POLLERR那么一个连接错误断开时poll 可能每次调用都立刻返回该 fd 的revents你就陷入了什么都没读到但 poll 总说有事发生的忙循环里。2.2 poll_table 与真正干活的内核函数用户态一个poll()调用在内核里的入口是do_sys_poll()。它主要做三件事把用户态的 pollfd 数组复制到内核空间、调用do_poll()去轮询、再把结果复制回用户态。do_poll()拿到的是经过poll_initwait()初始化好的一个poll_wqueues结构这里面托管着一个poll_table。poll_table这个概念极其重要它是连接 poll 机制和所有文件描述符的桥梁typedef struct poll_table_struct { __poll_t (*_qproc)(struct file *, wait_queue_head_t *, struct poll_table_struct *); unsigned long _key; } poll_table;_qproc默认指向__pollwait_key就是当前调用中你传入的events掩码它会被转成内核版本的事件标记。__pollwait的作用是把你当前的进程挂到目标 fd 的等待队列上去。可以这样理解poll 不只是问一遍你准备好了没有它还负责注册一个电话等你有信号了叫我。这个注册动作发生在每次 poll 调用时所以它是临时性的——这也是 poll 和 epoll 最本质的区别epoll 通过epoll_ctl把注册和等待彻底分开了。2.3 从 poll 到驱动 poll 回调的完整调用链内核从目录项拿到struct file之后会走这关键一步static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt) { if (file-f_op-poll) return file-f_op-poll(file, pt); return DEFAULT_POLLMASK; }也就是说每个文件系统、每种设备驱动都自己实现了一个.poll回调函数。socket 类型的文件对应的是sock_poll()字符设备对应的是驱动里 file_operations 上注册的 poll 成员。这个回调做的事情有两件第一调用poll_wait()把当前进程正式挂入设备自己的等待队列这样设备有数据了才能把你唤醒。第二查询设备当前的状态返回一个就绪事件的掩码比如 drv 发现自己缓冲区里有数据就返回POLLIN | POLLRDNORM。写驱动的时候这两步缺一不可。只调用poll_wait()不返回状态poll 会一直阻塞到超时只返回状态不调用poll_wait()poll 可能第一次调用就返回了一个伪就绪而且唤醒路径完全没接上。后面第 4 章我会给出一个完整的最小驱动实现。2.4 wait_queue 与唤醒链路的直白解读把等待队列理解成一个睡觉叫醒本就通透了。poll_wait()把当前进程登记进去记录为这个进程在等这个设备的数据。随后do_poll()进入一个for (;;)循环只要没有检测到就绪就去schedule_timeout()睡一会儿直到有人叫它。设备那边一旦有数据到达通常是在中断上下文或工作队列里调用wake_up_interruptible(dev-wq)。这个函数会遍历设备等待队列里的所有进程逐个找到那些设置了TASK_INTERRUPTIBLE状态的进程把它们唤醒。被唤醒的进程回到do_poll()的循环里重新把所有 fd 都扫描一遍看哪一个的revents确实是就绪了。这里有一个典型误区wake_up只是让 poll 重新去扫描并不是直接告诉你第几个 fd 就绪了。select/poll 的语义都是我可能有事你自己再看看只有 epoll 的epoll_wait才真的把就绪 fd 列表直接填充给你。理解了这一点就明白为什么 poll 在高并发场景下依然逃不开全量遍历的开销。3. Poll 的直白总结与各场景表现分析3.1 用一句话讲透 Pollpoll 的本质就是你告诉我你关心的 fd 列表和事件我给你一份同样结构的列表把每个 fd 的最新状态填回去如果没有一个就绪我就把自己的进程挂到所有 fd 的等待队列上睡一觉等任何一路信号来叫我再起来挨个查一遍。这句话里藏着三个特点要传全量列表、要线性扫描、调用时会注册等待。这三个特点决定了 poll 的最好和最差场景。3.2 Poll、Select、Epoll 优缺点对照用一个表来看尽这三兄弟的恩怨维度selectpollepollfd 上限受 FD_SETSIZE 限制受进程 fd 上限限制受进程 fd 上限限制每次调用拷贝拷贝 fd_set 位图拷贝 pollfd 数组仅首次传入事件注册机制每次调用都注册每次调用都注册epoll_ctl 独立注册内核检测方式线性扫描线性扫描回调方式就绪队列通知返回结果位图需自查每个 pollfd 的 revents直接返回就绪事件数组触发模式仅水平触发仅水平触发支持水平边缘连接数扩展性差一般极好再看 poll 自身的优缺点总结。先说优点。第一没有 fd 数量硬编码上限只要 fd 合法就可以往数组里放。第二接口比 select 清晰得多事件处理是每个 fd 独立的不像 select 还要维护读、写、异常三个位图。第三超时精度到毫秒select 用的是struct timeval微秒级但 poll 用int timeout负数表示永久阻塞0 表示立即返回正数表示等待毫秒数语义不容易踩坑。再说缺点。第一每次调用都是 O(n)内核要线性扫描所有 fd返回后你也要线性扫描所有 fd。第二每次都要重新注册等待队列无法复用上次注册的结果系统调用的成本随 fd 数增长几乎线性上升。第三只有水平触发如果你处理得快但没把数据完全读干净poll 会反复告诉你还可读不像 EPOLLET 能靠边沿触发把读压力一次性交给你。3.3 为什么 Poll 没有成为最后的赢家历史地看poll 是 select 的救火队员但它没有解决大规模 fd 场景下的线性开销这个核心矛盾。要知道它在设计上已经是全量数组线性扫描的极致形态了再想继续优化就必须推翻每次调用传全量 fd这个模型。epoll 就是在推倒这个模型的基础上做的。它把 fd 的管理从内核外部接进来用epoll_ctl把 fd 注册进内核的等待队列实际监听期间用户态只需要等就绪事件内核把就绪 fd 通过回调放进一个 ready listepoll_wait只需要把 ready list 里的内容拷贝出来。这样一来就绪检测从 O(n) 降到 O(k)k 是实际就绪的 fd 数基本与连接总量无关。那 poll 现在还有价值吗有而且不小。对于连接数稳定在几百到一两千以内的服务器、嵌入式环境、以及大多数驱动开发场景poll 是完全够用的。它没有 epoll 那些需要管理 eventpool、区分 ET/LT、小心翼翼处理 EPOLLHUP 的心智负担代码直白出问题也好排查。很多老牌嵌入式网络库一直在用 poll不是他们跟不上时代而是这个规模下 poll 的简洁本身就是一种优势。4. 实操环节用户态调用与内核驱动实现4.1 用户态 poll 调用示例与关键细节先给一个完整的、带错误处理的用户态 poll 示例读一个 TCP socket 上的数据带超时控制#include poll.h #include fcntl.h #include unistd.h #include errno.h #include stdio.h #include string.h #define TIMEOUT_MS 3000 int wait_readable(int fd) { struct pollfd pfd; int ret; pfd.fd fd; pfd.events POLLIN; pfd.revents 0; ret poll(pfd, 1, TIMEOUT_MS); if (ret 0) { // 被信号中断或者 errno 为 EINTR 时一般重试 return -errno; } if (ret 0) { return -ETIMEDOUT; } if (pfd.revents (POLLERR | POLLHUP | POLLNVAL)) { return -EIO; } if (pfd.revents POLLIN) { return 0; } return -EAGAIN; }几个实操细节值得单独说。poll()返回 0 只代表超时不代表 fd 有问题返回负数要看errno其中EINTR特别值得注意——如果你程序里用了信号比如定时器信号poll 被信号打断后不会自动恢复得自己循环重试。还有一个隐蔽问题pfd.fd一旦被别的线程关闭或者被复用也叫 fd 重用poll 可能在你不知情的情况下监听了一个完全无关的新连接。这不是 poll 的 bug是使用方的问题。多线程场景下操作 fd 前最好先确认你是否持有对应的引用必要的时候用poll(POLLNVAL)检测一下。4.2 内核驱动实现 poll 回调的最小可运行示例写字符设备驱动时如果希望用户态能用poll()监听你的设备file_operations 里必须这样实现#include linux/poll.h #include linux/wait.h static DECLARE_WAIT_QUEUE_HEAD(mydev_wq); static __poll_t mydev_poll(struct file *file, poll_table *wait) { __poll_t mask 0; poll_wait(file, mydev_wq, wait); // 判断设备是否有数据可读 if (mydev_data_available()) { mask | POLLIN | POLLRDNORM; } // 判断设备是否可写简单场景直接置位 if (mydev_room_for_write()) { mask | POLLOUT | POLLWRNORM; } return mask; } static int mydev_open(struct inode *inode, struct file *file) { // 初始化等操作 return 0; } static const struct file_operations mydev_fops { .owner THIS_MODULE, .open mydev_open, .poll mydev_poll, };有三条经验必须记下来。第一poll_wait()必须在返回 mask 之前调用顺序反了的话进程还没挂进等待队列就返回了可读标志用户态可能漏掉一次唤醒。第二poll_wait()本身不参与决定返回值它只负责把进程注册进等待队列返回值完全由你后面根据设备状态拼出来的 mask 决定。第三mask 里不要直接返回POLLIN就完事.poll回调可能被多个内部机制调用指定POLLRDNORM这类标准事件能避免某些协议栈应用出问题。数据到达时的唤醒代码大概是这样的void mydev_data_received(void) { // 把数据放入缓冲区... wake_up_interruptible(mydev_wq); }很多人写到这里就停了结果发现 poll 永远超时。原因通常只有一个唤醒用的等待队列头和poll_wait挂入的等待队列头不是同一个。你必须保证设备驱动里只维护一个wait_queue_head_t所有能产生数据的事件都调用同一个wake_up。4.3 超时参数与事件掩码的使用陷阱poll 的timeout参数语义很简单但实际工程里几个坑要注意。第一个坑是普通定时器和 poll 混用时的漂移。比如你打算实现一个每 500ms 做一次周期任务同时监听几个 fd的逻辑最容易写成poll(fds, nfds, 500)。问题在于如果某个 fd 连续有数据poll 每次都立刻返回你的周期任务会被无限推迟。正确做法是记录time_before每次算剩余时间int remaining 500; while (remaining 0) { struct timespec now; int t0_ms get_timestamp_ms(); ret poll(fds, nfds, remaining); if (ret 0) { handle_events(); } now get_timestamp(); remaining 500 - (now_ms - t0_ms); }第二个坑是对POLLHUP的处理。TCP 对端正常关闭时poll 返回的revents里是POLLHUP | POLLIN很多人只检查POLLIN就调read()最后返回 0知道是关闭了这没问题。但如果你只检查POLLIN不检查POLLHUP有些实现里会漏掉关闭事件导致连接一直在那边占着。建议每次处理时先看POLLHUP或POLLRDHUP再决定是否读数据。第三个坑是events 掩码里不要写入POLLERR/POLLHUP/POLLNVAL。这三个是只读的标志位由内核在 revents 里设置。如果写进 events行为虽然不会立刻报错但某些内核版本下会产生不可预期的唤醒行为。5. 高频问题与排查心得实录5.1 poll 明明超时返回 0但 fd 实际有数据怎么办这是 poll 用户最常见的灵异问题。我说一个真实案例一个同事负责的数据采集程序用 poll 等串口数据经常 2s 超时但用 cat 读串口立刻有数据。排查过程大概三步。先确认串口是不是被设置成了非阻塞并且接收缓冲区没被及时读走导致数据一直留在内核缓冲区里而 poll 的POLLIN本该触发却没触发——这种情况多半是驱动的问题设备驱动的 poll 回调没有正确返回POLLIN或者返回了但等待队列注册错了。再检查是不是多进程/多线程同时打开了同一串口另一个进程把数据抢先读走了poll 看到的自然还是空。最后再用strace确认 poll 调用前后的行为看返回值是在哪个环节丢的。对内核驱动排查的时候最省事的方法是在.poll回调里加临时printk打印调用时的设备状态和 mask 拼接过程同时确认poll_wait挂入的等待队列和中断下半部wake_up用的是同一个队列。我踩过最深的一个坑是DECLARE_WAIT_QUEUE_HEAD(mydev_wq)定义在模块里没问题但设备结构体是动态分配重新 malloc 的等待队列被 new 了一份中断里 wake 的却还是旧指针poll 当然永远睡不着、也永远醒不来。5.2 惊群问题多个线程 poll 同一个 fd惊群thundering herd不是 poll 独有但 poll 因为每次调用都全量注册等待队列表现格外明显。多线程同时poll()同一个监听 socket这个 socket 有新连接到达时内核会把所有挂在它等待队列上的 poll 进程全部唤醒大概率只有一个进程能成功 accept其他线程白跑一圈。epoll 能通过EPOLLEXCLUSIVE做互斥唤醒select/poll 没有对应的机制。所以在 poll 模型下做并发通常不要多个线程同时 poll 同一个 fd。更合理的分工是一个专用线程负责 poll 和 accept把新连接分发到多个工作线程里去读。这是老派 IO 多路复用服务器最经典的reactor 单线程 worker 多线程模型poll 时代一直被验证很稳定。5.3 连接数上去之后 poll 的性能崩溃点在哪给一个直观的体感数据在我的测试环境里poll 监听 100 个几乎全空闲的连接每次调用大概消耗十几微妙上升到 2000 个单次调用直接到百微秒级别如果这些连接还有一半是有数据频繁到达的每次返回后用户态的全量扫描又要再吃掉和内核差不多的成本。瓶颈就在两个地方。一是 syscall 本身的拷贝成本每次都要把 pollfd 数组从用户态复制到内核态2000 个 pollfd 就是 16KB 左右的拷贝二是线性扫描内核要挨个调用每个 fd 的.poll回调去拿状态。如果想在不大改架构的前提下延长 poll 的生命周期最有效的优化是把 fd 分组把活跃连接和非活跃连接分开高频 poll 只听活跃组非活跃组用很长的超时时间慢慢 poll。本质上就是用业务特征减少单次 poll 的 fd 数这也是没法用 epoll 时最实用的降级手段。5.4 水平触发读不完数据导致忙循环poll 是水平触发只要 fd 里还有可读数据poll 就会一直返回POLLIN。如果你的处理逻辑每次只读一部分然后立刻又进 poll 循环CPU 会直接打满表现为进程 user 时间疯涨。我见过一个串口协议解析模块就是每次只读一个字节来处理分包然后转手又去 poll。结果是每来一包 64 字节数据poll 被唤醒 64 次每次还伴随一次扫描。优化方式很简单POLLIN触发后内部把recv的缓冲区尽量读大或者先在内层循环读到EAGAIN再回到 poll。说白了水平触发模型下你要自己承担有多少读多少的职责。终章经验补遗最后再分享两个实际项目里沉淀下来的判断标准我觉得比背 API 有用。第一poll 和 epoll 的选型核心看的是活跃连接占比。如果连接数三千但每秒真正在收发数据的只有几十个poll 每次调用都在扫描三千个 fd其中绝大多数是白看这时候别犹豫换 epoll。反过来如果你的连接数长期稳定在几百以内或者追求代码可读性和可移植性poll 在各类 Unix 上表现一致而 epoll 是 Linux 专属poll 完全够用不必背负 epoll 的复杂度。第二如果你在做驱动开发poll 这门功夫必须扎实。因为 epoll 在内核里也是依赖同一个file_operations-poll回调链路的你在字符设备里把 poll 写好用户态用 select、poll、epoll 就都能监听这个设备。我后来写很多杂七杂八的设备驱动都是先把 poll 回调做对再往上堆别的功能这个底层功底的复用率极高。