I/O多路复用(中):poll()函数原理、与select对比、基于poll的并发服务器
好,我们接着聊。上一节我把select()扒了个底朝天,你可能会想:「那玩意儿有那么多坑,难道就没有更好的办法?」
有。就是今天的主角——poll()。
说实话,我早年做嵌入式网络开发时,select()用得比较多。后来转到Linux服务端开发,发现大家更爱用poll()。为什么?因为它在设计上确实比select更「体面」。今天我就带你把它吃透。
1. poll() 的核心原理
poll() 和 select() 干的是同一件事——同时监视多个文件描述符,看哪个准备好了。但它的数据结构完全不同。
select 用的是三个位图(fd_set),而 poll 用的是结构体数组。你想想看,数组比位图灵活太多了。
1.1 函数原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
三个参数,简单明了:
- fds:指向 pollfd 结构体数组的指针
- nfds:要监视的描述符个数
- timeout:超时时间(毫秒)
重点是这个 struct pollfd:
struct pollfd {
int fd; /* 要监视的文件描述符 */
short events; /* 感兴趣的事件 */
short revents; /* 实际发生的事件(由内核填充) */
};
每个描述符一个结构体。你告诉内核「我想看什么事件」,内核告诉你「哪些事件发生了」。就这么简单。
关键区别:select 会修改传入的 fd_set,所以每次调用前都要重新设置。而 poll 把 events 和 revents 分开,内核只写 revents,events 保持不变。这意味着——你可以复用同一个 pollfd 数组,不用每次重建。
1.2 事件标志
常用的 events 取值:
| 事件 | 说明 |
|---|---|
| POLLIN | 有数据可读 |
| POLLOUT | 可以写数据 |
| POLLERR | 发生错误(仅 revents) |
| POLLHUP | 对方关闭连接(仅 revents) |
| POLLNVAL | 描述符未打开(仅 revents) |
嗯,这里要注意:POLLERR、POLLHUP、POLLNVAL 你不需要在 events 里设置,它们会自动出现在 revents 中。这是 poll 比 select 更贴心的一个地方——select 里你要自己处理异常情况,poll 帮你标记好了。
2. poll() vs select():到底谁更强?
我在项目中两种都写过,说实话,没有绝对的「谁碾压谁」。但 poll 确实解决了好几个 select 的痛点。
| 对比项 | select() | poll() |
|---|---|---|
| 数据结构 | 位图(fd_set) | 结构体数组(pollfd) |
| 最大描述符数 | 受 FD_SETSIZE 限制(通常1024) | 无上限(受系统内存限制) |
| 事件分离 | 读写异常共用位图,会修改 | events 和 revents 分离,不修改原值 |
| 跨平台 | 几乎所有系统都支持 | POSIX 系统支持,Windows 不支持 |
| 超时精度 | 微秒级(struct timeval) | 毫秒级(int) |
| 触发方式 | 水平触发 | 水平触发 |
说白了,poll 最大的优势就是:没有 1024 的限制。我曾经维护过一个老项目,用 select 管理 2000 多个连接,每次都要改 FD_SETSIZE 重新编译内核,烦得要死。换成 poll 后,世界清净了。
我的建议:如果你在 Linux 上写新项目,而且连接数可能超过 1024,直接用 poll。如果只是几十个连接的小工具,select 也够用,代码还更简单。
3. poll() 的返回值与事件处理
返回值有三种情况:
- 大于 0:有 revents 非 0 的描述符个数(注意,不是事件总数)
- 等于 0:超时,没有描述符就绪
- 等于 -1:出错,检查 errno
处理事件的典型套路:
int ret = poll(fds, nfds, timeout);
if (ret == -1) {
perror("poll");
exit(EXIT_FAILURE);
}
if (ret == 0) {
printf("超时了,没有事件\n");
continue;
}
for (int i = 0; i < nfds; i++) {
if (fds[i].revents == 0) {
continue; // 这个描述符没动静
}
if (fds[i].revents & POLLIN) {
// 有数据可读
}
if (fds[i].revents & POLLOUT) {
// 可以写数据
}
if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
// 出错了,关闭连接
close(fds[i].fd);
fds[i].fd = -1; // 标记为无效
}
}
注意我标记 fd = -1 这步。poll 遇到 fd = -1 时会直接跳过,不会报错。这是 poll 的另一个贴心设计——删除描述符时,不用像 select 那样重新组织位图,直接把 fd 设成 -1 就行。
我曾经踩过的坑:有一次我忘了检查 POLLHUP,结果对方断开连接后,poll 一直返回 POLLIN(因为 EOF 也算可读),我读到的却是 0 字节。折腾了半天才发现是对方已经关了连接。记住:一定要同时检查 POLLIN 和 POLLHUP。
4. 基于 poll 的并发服务器
好,理论说完了,咱们直接上代码。下面是一个完整的 echo 服务器,用 poll 管理多个客户端连接。
#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 1024
#define BUFFER_SIZE 1024
int main() {
int listen_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
struct pollfd fds[MAX_CLIENTS];
char buffer[BUFFER_SIZE];
int nfds = 1; // 当前有效的描述符个数
// 1. 创建监听套接字
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置端口复用
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)) == -1) {
perror("bind");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 3. 开始监听
if (listen(listen_fd, 10) == -1) {
perror("listen");
close(listen_fd);
exit(EXIT_FAILURE);
}
printf("服务器启动,监听端口 %d\n", PORT);
// 4. 初始化 pollfd 数组
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
for (int i = 1; i < MAX_CLIENTS; i++) {
fds[i].fd = -1; // 标记为无效
}
// 5. 主循环
while (1) {
int ret = poll(fds, nfds, -1); // 永久等待
if (ret == -1) {
perror("poll");
break;
}
// 检查监听套接字
if (fds[0].revents & POLLIN) {
client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd == -1) {
perror("accept");
continue;
}
printf("新客户端连接: %d\n", client_fd);
// 找个空位放新连接
int i;
for (i = 1; i < MAX_CLIENTS; i++) {
if (fds[i].fd == -1) {
fds[i].fd = client_fd;
fds[i].events = POLLIN;
break;
}
}
if (i == MAX_CLIENTS) {
printf("客户端太多,拒绝连接\n");
close(client_fd);
} else {
// 更新 nfds
if (i >= nfds) {
nfds = i + 1;
}
}
}
// 检查所有客户端
for (int i = 1; i < nfds; i++) {
if (fds[i].fd == -1) continue;
if (fds[i].revents & POLLIN) {
int n = read(fds[i].fd, buffer, sizeof(buffer) - 1);
if (n <= 0) {
// 连接关闭或出错
if (n == 0) {
printf("客户端 %d 断开连接\n", fds[i].fd);
} else {
perror("read");
}
close(fds[i].fd);
fds[i].fd = -1;
} else {
// 回显数据
buffer[n] = '\0';
printf("收到: %s", buffer);
write(fds[i].fd, buffer, n);
}
}
// 检查错误
if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
printf("客户端 %d 异常断开\n", fds[i].fd);
close(fds[i].fd);
fds[i].fd = -1;
}
}
}
close(listen_fd);
return 0;
}
这段代码我故意写得比较「教学风」,实际项目中你可能会加更多错误处理和日志。但核心逻辑就这些:
- 监听套接字放在 fds[0]
- 新连接来了,找个空位放进去
- 每次 poll 返回后,遍历数组处理事件
- 连接关闭或出错,把 fd 设成 -1
5. poll 的局限性
虽然 poll 比 select 好,但它也不是完美的。我用了几年 poll,发现几个问题:
- 遍历开销:每次都要遍历整个数组,哪怕只有几个活跃连接。连接数上万时,这个 O(n) 的遍历就很疼了。
- 内存拷贝:每次调用 poll,内核要把整个 pollfd 数组从用户态拷贝到内核态。连接多的时候,这个开销不小。
- 水平触发:和 select 一样,如果数据没读完,下次 poll 还会通知你。这本身不是问题,但如果你处理不当,容易陷入忙等。
所以后来 Linux 出了 epoll,专门解决这些问题。下一节我会详细讲 epoll,它才是真正的高并发利器。
6. 本章小结
来,我们捋一下 poll 的核心要点:
- 用结构体数组代替位图,没有 1024 限制
- events 和 revents 分离,不用每次重建
- fd = -1 表示跳过,删除描述符很方便
- 超时单位是毫秒,比 select 的微秒级粗糙一点
- 仍然是水平触发,和 select 一样
我个人觉得,poll 是 select 的一个「体面升级版」。它没有革命性的变化,但把 select 那些让人头疼的小毛病都修了。如果你还在用 select 管理几百个连接,不妨试试 poll,你会觉得舒服很多。
好,poll 就讲到这里。代码你可以直接拿去跑,改改端口就能用。有问题随时问我。
公众号:蓝海资料掘金营,微信deep3321