4. IO多路复用(中):poll系统调用、poll与select对比、poll实战案例
上一章我们把 select 翻了个底朝天。说实话,select 的问题我太熟悉了——每次上线前都要检查 FD_SETSIZE 够不够,1024 这个数字就像个紧箍咒。今天聊的 poll,就是来解决这些痛点的。
poll 的出现比 select 晚,但设计上明显更成熟。它没有最大文件描述符限制,接口也更清晰。我当年第一次用 poll 替换 select 时,感觉就像从手动挡换成了自动挡——虽然核心逻辑没变,但开起来舒服多了。
4.1 poll 系统调用详解
先看 poll 的函数原型:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监控的事件 */
short revents; /* 返回的事件 */
};
参数不多,但每个都很关键:
- fds:pollfd 结构体数组,你要监控的所有 fd 都放这里
- nfds:数组的大小,注意不是最大 fd+1
- timeout:超时时间,单位毫秒。 -1 表示阻塞等待,0 表示立即返回
events 和 revents 是位掩码,常用的事件宏有:
| 事件宏 | 含义 | 可用于 events | 可用于 revents |
|---|---|---|---|
| POLLIN | 有数据可读 | ✓ | ✓ |
| POLLOUT | 可写数据 | ✓ | ✓ |
| POLLERR | 发生错误 | ✗ | ✓ |
| POLLHUP | 连接挂断 | ✗ | ✓ |
| POLLNVAL | fd 未打开 | ✗ | ✓ |
4.2 poll 的工作流程
poll 的核心流程其实很简单,我画个图你就明白了:
你看,流程和 select 很像,但数据结构完全不同。poll 用结构体数组代替了 select 的位图,这就带来了几个关键优势。
4.3 poll 与 select 的对比
我整理了一张对比表,把核心差异列出来:
| 对比维度 | select | poll |
|---|---|---|
| 数据结构 | fd_set 位图 | pollfd 结构体数组 |
| 最大 fd 数 | FD_SETSIZE(通常 1024) | 无硬限制(受内存限制) |
| 事件分离 | 读/写/异常三个集合 | events 和 revents 分离 |
| fd 重用 | 每次调用需重新设置 | events 不变可重复使用 |
| 超时精度 | 微秒(struct timeval) | 毫秒(int) |
| 可移植性 | 几乎所有系统 | POSIX 标准,Linux/Unix |
poll 最大的改进:events 和 revents 分离。这意味着你不用像 select 那样每次调用前都重新构造 fd_set。我维护过一个长连接服务,连接数稳定在 2000 左右,用 poll 后每次循环少了一次 O(n) 的集合拷贝,性能提升很明显。
但 poll 也不是完美的。它和 select 一样,存在 「线性扫描」 的问题——每次调用都要遍历所有 fd。连接数少的时候无所谓,一旦上万,这个开销就上来了。这也是后来 epoll 出现的原因。
4.4 poll 实战:简易 Web 服务器
光说不练假把式。我们来写一个基于 poll 的简易 Web 服务器。它能处理多个客户端连接,返回一个简单的 HTTP 响应。
#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 MAX_CLIENTS 1024
#define BUFFER_SIZE 4096
int main() {
int listen_fd, conn_fd;
struct sockaddr_in server_addr;
struct pollfd fds[MAX_CLIENTS + 1];
int nfds = 1; // 当前管理的 fd 数量
int i, ret;
// 1. 创建监听 socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 设置 SO_REUSEADDR,避免 TIME_WAIT 问题
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(8080);
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("Web server running on port 8080...\n");
// 4. 初始化 pollfd 数组
memset(fds, 0, sizeof(fds));
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
// 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, NULL, NULL);
if (conn_fd < 0) {
perror("accept");
continue;
}
printf("New connection: fd=%d\n", conn_fd);
// 将新连接加入 pollfd 数组
if (nfds < MAX_CLIENTS + 1) {
fds[nfds].fd = conn_fd;
fds[nfds].events = POLLIN;
nfds++;
} else {
printf("Too many clients, closing new connection\n");
close(conn_fd);
}
}
// 检查所有客户端 fd
for (i = 1; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
char buffer[BUFFER_SIZE];
int n = read(fds[i].fd, buffer, sizeof(buffer) - 1);
if (n <= 0) {
// 连接关闭或出错
if (n == 0) {
printf("Client fd=%d closed\n", fds[i].fd);
} else {
perror("read");
}
close(fds[i].fd);
fds[i].fd = -1; // 标记为无效
continue;
}
buffer[n] = '\0';
printf("Received from fd=%d: %s", fds[i].fd, buffer);
// 发送 HTTP 响应
const char *response =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html\r\n"
"Content-Length: 48\r\n"
"\r\n"
"<html><body><h1>Hello from poll!</h1></body></html>";
write(fds[i].fd, response, strlen(response));
// 短连接,发送完就关闭
close(fds[i].fd);
fds[i].fd = -1;
}
}
// 清理无效的 fd,压缩数组
int j = 1;
for (i = 1; i < nfds; i++) {
if (fds[i].fd != -1) {
if (j != i) {
fds[j] = fds[i];
}
j++;
}
}
nfds = j;
}
close(listen_fd);
return 0;
}
代码要点:
- 我把监听 socket 放在 fds[0],客户端连接从 fds[1] 开始。这样逻辑清晰,遍历时也方便跳过监听 fd。
- 每次 poll 返回后,先处理新连接,再处理客户端数据。这个顺序很重要——如果先处理客户端,新连接可能会被延迟接受。
- 我用了「标记-清理」策略:关闭连接后把 fd 设为 -1,最后统一压缩数组。这样避免了在遍历过程中删除元素导致的索引错乱。
4.5 poll 的局限性
虽然 poll 比 select 好用,但它也有自己的问题。我总结了几点:
- 线性扫描开销:每次 poll 调用,内核都要遍历整个数组。连接数越多,开销越大。
- 用户态/内核态数据拷贝:整个 pollfd 数组需要在用户态和内核态之间拷贝。连接数上万时,这个拷贝开销很可观。
- 不支持边缘触发:poll 只支持水平触发模式。这意味着如果事件没处理完,下次 poll 还会通知你。在某些场景下,这会导致重复通知。
嗯,这些局限性也正是 epoll 要解决的问题。不过那是下一章的内容了,今天先把 poll 吃透。
我的建议:如果你的并发连接数在几千以内,poll 完全够用。我手头有几个内部工具就是用 poll 写的,跑了三四年没出过问题。别一上来就上 epoll,简单够用才是王道。
最后留个思考题:上面的 Web 服务器代码中,我用了短连接——每次请求后直接关闭连接。如果改成长连接(Keep-Alive),代码需要做哪些改动?提示:需要区分 POLLIN 和 POLLHUP,还要处理半包问题。