9、poll I/O复用:使用poll()替代select(),突破文件描述符上限。
好,咱们继续往下聊。上一章我们把 select() 翻了个底朝天,它的优点和缺点,我相信你已经心里有数了。说白了,select() 最大的痛点是啥?就是那个 FD_SETSIZE 的限制,默认 1024。你想想看,现在一个高并发服务器,动辄几千上万个连接,1024 这个数,根本不够用。
那怎么办?换!用 poll()。我个人习惯是,只要项目里文件描述符可能超过 1024,我就直接上 poll(),省得后面还要重构。今天我们就来彻底搞懂 poll(),看看它是怎么突破这个上限的,以及它和 select() 到底差在哪。
9.1 poll() 的核心数据结构
poll() 和 select() 最大的不同,在于它不再用位图(bitmap)来管理文件描述符了。它用的是一个结构体数组。这个结构体叫 struct pollfd,定义在 <poll.h> 里。
struct pollfd {
int fd; /* 要监视的文件描述符 */
short events; /* 感兴趣的事件(输入) */
short revents; /* 实际发生的事件(输出) */
};
你看,每个 pollfd 结构体就管一个文件描述符。你关心多少个 fd,就准备多少个这样的结构体。没有上限!当然,理论上受内存限制,但实际项目中,几万个 fd 是轻轻松松的。
events 是你告诉内核:“我想看这个 fd 上发生什么事”。revents 是内核告诉你:“刚才这个 fd 上发生了这些事”。常用的宏有:
POLLIN:有数据可读(类似select()的读集)POLLOUT:可以写数据(类似select()的写集)POLLERR:发生错误POLLHUP:对方关闭连接
嗯,这里要注意:POLLIN 和 POLLOUT 是最常用的,POLLERR 和 POLLHUP 一般不需要你设置,内核会自动在 revents 里返回。
9.2 poll() 的函数原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
三个参数,很简单:
fds:指向pollfd数组的指针nfds:数组里有多少个元素(也就是你要监视多少个 fd)timeout:超时时间,单位是毫秒。传 -1 表示永远等,传 0 表示立即返回(轮询模式)
返回值:成功时返回有事件发生的 fd 数量;超时返回 0;出错返回 -1。
你看,这个接口比 select() 清爽多了。没有那四个让人头疼的 fd_set 参数,也没有那个 maxfd 参数。你只需要准备好数组,告诉内核数组有多大,然后等着就行。
9.3 实战:用 poll() 实现一个简单的回显服务器
光说不练假把式。我们来写一个用 poll() 管理的 TCP 回显服务器。这个例子我当年在做一个嵌入式网关项目时用过类似的模式,稳定运行了好几个月没出过问题。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <poll.h>
#include <errno.h>
#define PORT 8888
#define MAX_CLIENTS 2000
#define BUFFER_SIZE 1024
int main() {
int listen_fd, conn_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
struct pollfd fds[MAX_CLIENTS + 1]; // +1 给监听 socket
int nfds = 1; // 当前有效的 fd 数量
int i, ret;
char buffer[BUFFER_SIZE];
// 1. 创建监听 socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 设置端口重用,避免 "Address already in use"
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listen_fd);
exit(1);
}
// 3. 开始监听
if (listen(listen_fd, 10) < 0) {
perror("listen");
close(listen_fd);
exit(1);
}
printf("Echo server listening on port %d\n", PORT);
// 4. 初始化 pollfd 数组
memset(fds, -1, sizeof(fds)); // 把所有 fd 设为 -1
fds[0].fd = listen_fd;
fds[0].events = POLLIN; // 只关心监听 socket 的可读事件(有新连接)
nfds = 1;
// 5. 主循环
while (1) {
ret = poll(fds, nfds, -1); // 永远等待
if (ret < 0) {
perror("poll");
break;
}
// 检查监听 socket 是否有新连接
if (fds[0].revents & POLLIN) {
conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (conn_fd < 0) {
perror("accept");
continue;
}
printf("New connection from %s:%d, fd=%d\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), conn_fd);
// 把新连接加入 pollfd 数组
for (i = 1; i <= MAX_CLIENTS; i++) {
if (fds[i].fd == -1) {
fds[i].fd = conn_fd;
fds[i].events = POLLIN;
if (i >= nfds) nfds = i + 1;
break;
}
}
if (i > MAX_CLIENTS) {
printf("Too many clients, closing new connection\n");
close(conn_fd);
}
ret--; // 消耗掉一个事件
}
// 检查所有客户端 socket
for (i = 1; i < nfds && ret > 0; i++) {
if (fds[i].fd == -1) continue;
if (fds[i].revents & POLLIN) {
// 有数据可读
memset(buffer, 0, BUFFER_SIZE);
int n = read(fds[i].fd, buffer, BUFFER_SIZE - 1);
if (n <= 0) {
// 连接关闭或出错
if (n == 0) {
printf("Client fd=%d closed connection\n", fds[i].fd);
} else {
perror("read");
}
close(fds[i].fd);
fds[i].fd = -1;
} else {
// 回显数据
printf("Received from fd=%d: %s", fds[i].fd, buffer);
write(fds[i].fd, buffer, n);
}
ret--;
}
// 检查是否有错误或挂起
if (fds[i].revents & (POLLERR | POLLHUP)) {
printf("Error or hangup on fd=%d\n", fds[i].fd);
close(fds[i].fd);
fds[i].fd = -1;
ret--;
}
}
}
// 清理
for (i = 0; i < nfds; i++) {
if (fds[i].fd != -1) {
close(fds[i].fd);
}
}
return 0;
}
这段代码的核心逻辑,我画了个图帮你理解:
9.4 poll() 和 select() 的对比
我把它们俩的核心差异整理成了一张表,方便你对比:
| 对比项 | select() | poll() |
|---|---|---|
| 数据结构 | 位图(fd_set),大小固定 | 结构体数组(pollfd),大小动态 |
| 文件描述符上限 | FD_SETSIZE(通常 1024) | 无硬性上限(受内存限制) |
| 事件注册方式 | 每次调用前都要重新设置 fd_set | 只需设置 events 字段,内核不修改它 |
| 事件返回方式 | 修改 fd_set,需要 FD_ISSET 检查 | 填充 revents 字段,直接判断即可 |
| 跨平台性 | 几乎所有平台都支持 | POSIX 标准,Linux/Unix 支持良好 |
| 性能(大量 fd) | O(n) 遍历,但位图操作有开销 | O(n) 遍历,但结构体数组更高效 |
核心结论:poll() 是 select() 的升级版,解决了文件描述符上限问题,接口也更简洁。但两者都是「水平触发」(Level-Triggered),且都需要 O(n) 遍历所有 fd 来找出有事件的那个。如果你要处理上万连接,后面我们讲的 epoll 才是终极方案。
9.5 避坑指南:我踩过的 poll() 的坑
我曾经在一个物联网项目中,用 poll() 管理几百个设备连接。一开始跑得好好的,但运行几天后,发现新设备连不上来了。排查了半天,发现问题出在 nfds 的维护上。
坑1:nfds 不是数组大小,而是最大索引+1
poll() 的第二个参数 nfds 表示数组中有多少个有效的元素。如果你数组大小是 2000,但只用了 100 个,nfds 应该传 100,而不是 2000。传大了,内核会遍历很多无效的 fd == -1 的条目,浪费性能。传小了,后面的 fd 就监视不到了。
坑2:关闭 fd 后,一定要把对应的 pollfd.fd 设为 -1
如果不这样做,下次 poll() 返回时,你可能会拿到一个已经关闭的 fd 的事件。更糟糕的是,如果这个 fd 号被新连接复用了,就会产生混乱。我那次排查了半天,最后发现就是忘了把关闭的 fd 置为 -1。
小技巧:我习惯在初始化数组时,把所有 pollfd.fd 设为 -1。然后在添加新 fd 时,找到第一个 fd == -1 的位置插入。这样管理起来非常清晰,也不容易出错。
9.6 poll() 的 timeout 参数详解
timeout 参数有三种典型用法:
- timeout = -1:永远等待,直到有事件发生才返回。适合服务器主循环。
- timeout = 0:立即返回,不等待。适合轮询模式,比如你想检查一下有没有数据,没有就继续干别的事。
- timeout > 0:等待指定的毫秒数。适合需要定期做其他任务的场景,比如每 100ms 检查一次,同时处理心跳包。
嗯,这里有个细节:timeout 的单位是毫秒,但实际精度取决于操作系统。Linux 上一般能精确到毫秒级,但如果你需要微秒级的精度,那得用 ppoll()(poll 的增强版,支持纳秒精度)。不过大多数场景下,毫秒级已经够用了。
9.7 什么时候该用 poll()?
我个人觉得,poll() 最适合的场景是:
- 文件描述符数量在几百到几千之间
- 对性能要求不是极端苛刻(比如不是百万并发)
- 代码需要较好的可移植性(poll 是 POSIX 标准)
- 你不想引入 epoll 的复杂性(epoll 虽然性能好,但 API 更复杂)
说白了,poll() 是 select() 和 epoll() 之间的一个很好的平衡点。它比 select() 灵活,比 epoll() 简单。很多中小型项目,用 poll() 就足够了。
好了,关于 poll() 的内容就讲到这里。你把它和上一章的 select() 对比着看,会发现网络编程的 I/O 复用模型其实是一脉相承的——从 select 到 poll,再到后面的 epoll,每一步都是在解决前一步的痛点。理解了这些演进逻辑,你写起代码来就会更有底气。