Linux 中 poll 的用法与底层实现

在 Linux 的 I/O 多路复用机制中,poll 可以看作是 select 的改进版本。它保留了“一个线程同时等待多个文件描述符事件”的基本思想,同时解决了 select 在 fd 表示方式上的一些局限。


一、什么是 poll

poll 是 Linux/Unix 系统中常见的 I/O 多路复用接口,用于同时监视多个文件描述符的状态变化。

它的系统调用原型如下:

1
2
3
#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

其中:

  • fds:一个 pollfd 数组,每个元素描述一个待监视 fd
  • nfds:数组元素个数
  • timeout:超时时间,单位毫秒

select 相比,poll 最直观的区别在于:

它不再使用位图,而是使用结构体数组来描述关注的 fd 和事件。


二、poll 解决了 select 的哪些问题

理解 poll,最好先从 select 的问题出发。

select 的几个典型缺点是:

  • fd_set 通常受 FD_SETSIZE 限制
  • 需要通过“最大 fd + 1”来决定扫描范围
  • 使用位图表达集合,不够直观

poll 对此做了两点重要改进:

  1. 不再使用固定大小的位图,而是使用数组
  2. 不再依赖最大 fd + 1,而是直接依赖数组长度

也就是说,poll 更像是:

直接把“要关注谁、关注什么事件”整理成一张列表交给内核。

不过它并没有彻底解决“每次都提交全集”和“线性扫描”的问题,所以它只是改进,不是质变。


三、pollfd 结构体与事件含义

poll 的核心数据结构是:

1
2
3
4
5
struct pollfd {
int fd;
short events;
short revents;
};

字段含义如下:

  • fd:要监听的文件描述符
  • events:希望监听的事件
  • revents:内核返回的实际发生事件

常见事件包括:

  • POLLIN:可读
  • POLLOUT:可写
  • POLLERR:出错
  • POLLHUP:挂断
  • POLLNVAL:fd 非法
  • POLLRDHUP:对端关闭写端(Linux 常见扩展)

例如:

1
2
3
4
5
6
struct pollfd fds[2];
fds[0].fd = listenfd;
fds[0].events = POLLIN;

fds[1].fd = connfd;
fds[1].events = POLLIN;

四、poll 的基本用法

1. 调用方式

一个最简单的调用例子:

1
int ret = poll(fds, nfds, 5000);

表示:

  • 监视 fds 数组中的所有 fd
  • 最多等待 5000 毫秒
  • 返回后在 revents 中查看哪些 fd 就绪

2. timeout 语义

  • timeout < 0:一直阻塞
  • timeout == 0:立即返回
  • timeout > 0:等待指定毫秒数

3. 返回值

  • > 0:就绪 fd 的数量
  • 0:超时
  • < 0:出错

五、poll 的典型使用流程

一个基于 poll 的 TCP 服务端,大体流程如下:

  1. 创建监听 socket
  2. bind + listen
  3. 准备 pollfd 数组
  4. 把监听 fd 放入数组并关注 POLLIN
  5. 循环调用 poll
  6. 遍历数组,查看哪些元素的 revents 非空
  7. 处理对应 fd 的读写或关闭逻辑

下面是一个简化示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <poll.h>

#define PORT 8080
#define MAXFDS 1024

int main() {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);

bind(listenfd, (struct sockaddr *)&addr, sizeof(addr));
listen(listenfd, 128);

struct pollfd fds[MAXFDS];
for (int i = 0; i < MAXFDS; i++) {
fds[i].fd = -1;
fds[i].events = 0;
fds[i].revents = 0;
}

fds[0].fd = listenfd;
fds[0].events = POLLIN;
int nfds = 1;

while (1) {
int nready = poll(fds, nfds, -1);
if (nready < 0) {
continue;
}

if (fds[0].revents & POLLIN) {
int connfd = accept(listenfd, NULL, NULL);
if (connfd >= 0) {
for (int i = 1; i < MAXFDS; i++) {
if (fds[i].fd == -1) {
fds[i].fd = connfd;
fds[i].events = POLLIN;
if (i >= nfds) nfds = i + 1;
break;
}
}
}
if (--nready <= 0) continue;
}

for (int i = 1; i < nfds; i++) {
if (fds[i].fd == -1) continue;

if (fds[i].revents & (POLLIN | POLLERR | POLLHUP)) {
char buf[4096];
ssize_t n = read(fds[i].fd, buf, sizeof(buf));
if (n > 0) {
write(fds[i].fd, buf, n);
} else {
close(fds[i].fd);
fds[i].fd = -1;
fds[i].events = 0;
}
}
}
}

close(listenfd);
return 0;
}

这个示例体现了 poll 的典型写法:

  • 用数组保存所有关注对象
  • 返回后遍历数组查看 revents

六、poll 相比 select 的改进点

1. 不再受固定大小位图的直接限制

poll 不使用 fd_set,而是使用数组,因此在接口层面上不再像 select 那样天然绑定到 FD_SETSIZE

当然,这并不意味着它可以无限扩展,实际仍受:

  • 进程可打开文件数限制
  • 内存开销
  • 线性扫描成本

2. 不依赖最大 fd + 1

poll 直接传数组长度 nfds,不需要再维护 maxfd + 1 这种额外信息。

3. 表达更直接

每个 pollfd 元素都显式写出:

  • 监听哪个 fd
  • 关心什么事件
  • 实际发生了什么事件

可读性通常比 select 更好。


七、poll 仍然存在的核心问题

虽然 pollselect 更灵活,但它并没有改变整体工作模式。

1. 每次调用仍要把全集交给内核

poll 每次调用时,应用依旧要把整个 pollfd 数组传给内核。

也就是说,关注集合并没有像 epoll 那样长期保存在内核里。

2. 内核仍需要遍历整个数组

内核需要检查数组中的每个元素,看对应 fd 是否就绪。

3. 用户态返回后仍要遍历整个数组

即使本次只有少数几个 fd 真正发生事件,应用仍然需要扫描数组,判断谁的 revents 非零。

所以从复杂度特征上看,poll 仍然是接近 O(n) 的。


八、poll 的底层实现思路

poll 的实现思路可以概括为:

每次调用把“关注列表”作为数组交给内核,内核逐项检查,并在等待期间挂入相应等待队列,事件到来后再整理结果返回。

1. 数组是核心输入结构

select 的位图不同,pollpollfd 数组作为输入。内核拿到数组后,会逐项查看:

  • 这个元素对应哪个 fd
  • 想监听什么事件

2. 对每个 fd 调用对应的 poll 回调

在 Linux 内核里,不同类型的文件对象通常会提供各自的 poll 回调逻辑。

例如 socket、pipe、字符设备,它们判断“当前是否可读/可写”的方式并不完全相同。

因此内核在处理 poll 时,会对每个 fd 对应的文件对象调用相应的 poll 方法,以判断当前状态。

3. 如果暂时未就绪,则把当前线程挂到等待队列

如果当前没有感兴趣的事件发生,而调用者允许阻塞,那么内核会把当前线程与相关等待队列关联起来。

后续一旦某个 fd 状态变化,对应等待队列就能唤醒该线程。

4. 唤醒后重新扫描并填写 revents

线程被唤醒后,内核会再次检查数组中的 fd,把就绪结果写入各元素的 revents 字段,然后返回给用户态。

这里要注意:

poll 虽然也利用等待队列和唤醒机制,但它并没有像 epoll 那样专门维护一个长期存在的“就绪事件队列”。

所以它依然需要围绕整个数组做遍历。


九、从内核视角看一次 poll 等待过程

可以粗略理解为:

  1. 用户态准备 pollfd 数组
  2. 调用 poll
  3. 内核复制数组到内核态
  4. 逐项检查每个 fd 当前是否满足 events
  5. 若没有事件且允许阻塞,则线程进入等待
  6. 某个 socket 收到数据或状态变化
  7. 等待线程被唤醒
  8. 内核再次扫描数组
  9. 把实际发生事件填写到 revents
  10. 将结果返回用户态

这说明 poll 相比 select 的改进主要在“接口和数据表达”,不是在“整体处理模型”上发生根本变化。


十、poll 为什么仍不适合超大规模高并发

假设服务端维护 5 万个连接,而某一时刻只有 200 个连接活跃。

poll 通常仍需要:

  • 把 5 万个 pollfd 元素传给内核
  • 内核遍历这 5 万项
  • 返回后应用再遍历这 5 万项

因此它更像是:

不管今天真正活跃的是谁,都先把整个通讯录翻一遍。

这就是为什么在超大规模并发连接场景里,poll 往往会被 epoll 替代。


十一、使用 poll 时的常见坑

1. 忘记检查 revents 而只看 events

events 是“你想监听什么”,revents 才是“实际发生了什么”。

返回后要重点看 revents

2. 没有处理错误和挂断事件

除了 POLLIN,通常还应注意:

  • POLLERR
  • POLLHUP
  • POLLNVAL

否则容易漏掉异常关闭连接。

3. 数组中删除元素策略混乱

有些程序在关闭 fd 后会:

  • 直接把该项 fd = -1
  • 或者把最后一个元素挪过来覆盖

两种都可以,但要保持整体策略一致,否则很容易出 bug。

4. 误以为 poll 返回值就是数组下标

返回值表示“就绪 fd 的数量”,不是哪个元素就绪,因此仍要自己遍历数组判断。

5. 长期监听 POLLOUT

epoll 类似,socket 在很多时候都可写。如果一直监听 POLLOUT,可能造成大量无意义唤醒。


十二、poll 与 select、epoll 的对比

对比项 select poll epoll
fd 表示方式 位图 pollfd 数组 内核对象 + 事件注册
fd 数量限制 常受 FD_SETSIZE 影响 无明显固定上限 无明显固定上限
每次调用重复传全集
用户态是否线性扫描 通常只处理返回的就绪项
复杂度特征 O(n) O(n) 更接近按活跃事件处理
适用场景 小规模/通用 中等规模 大规模高并发

可以把三者理解为递进关系:

  • select:最早的基础版本
  • poll:接口更合理的改进版
  • epoll:Linux 下更适合高并发的版本

十三、poll 在工程中的适用场景

poll 一般适合以下情况:

1. 连接数中等规模

当连接数还没有大到必须用 epoll,但又不想受 select 位图限制时,poll 是一个自然过渡选择。

2. 想保留较通用的 Unix 接口风格

相比 epollpoll 不是 Linux 专有接口,在很多 Unix-like 系统中也存在类似支持。

3. 教学与对比分析

如果要理解为什么 epoll 比前两者更强,那么 poll 是一个很好的中间层:

  • 它修正了 select 的一部分接口问题
  • 但保留了线性扫描这一核心瓶颈

十四、总结

poll 的核心特点可以概括为:

  1. 接口上比 select 更自然:使用 pollfd 数组而不是位图
  2. 能力上比 select 更灵活:不依赖 maxfd + 1,fd 管理更直观
  3. 性能模型上仍接近线性扫描:每次调用仍要提交全集、遍历全集

因此,poll 可以看作是 selectepoll 之间的一个过渡形态:

  • 它改善了接口设计
  • 但没有从根本上解决大规模 fd 下的扫描成本问题

也正因如此,理解 poll 之后,再去看 epoll 的“注册集合 + 就绪队列”设计,就会更容易明白后者为什么更适合高并发服务器。