8、I/O多路复用(poll):poll函数原理、pollfd结构体、poll与select对比、poll服务器实现
聊完了select,咱们来看看它的“升级版”——poll。说实话,我在早期做网络编程时,select用得比较多。但后来项目规模一上来,select那点“家底”就不够看了。尤其是那个1024的文件描述符上限,真是让人头疼。有一次我负责一个高并发网关,客户端连接数轻松破千,select直接罢工。嗯,从那以后,我就开始认真研究poll了。
8.1 poll函数原理
poll的核心思想其实和select差不多——都是让内核帮我们盯着多个文件描述符,看哪个准备好了就通知我们。但poll的实现方式更优雅一些。
它的原型长这样:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数解释一下:
- fds:指向pollfd结构体数组的指针,说白了就是你要监控的文件描述符列表
- nfds:数组的大小,注意不是字节数,是元素个数
- timeout:超时时间,单位是毫秒。传-1表示永远等,传0表示立即返回
返回值:成功时返回就绪的文件描述符数量;超时返回0;出错返回-1。
核心区别:poll没有select那个1024的限制。它用链表管理fd,理论上可以监控任意数量的描述符。当然,实际受系统资源限制。
8.2 pollfd结构体详解
这个结构体是poll的灵魂。我刚开始用的时候,总觉得它比select的fd_set要直观得多。
struct pollfd {
int fd; /* 要监控的文件描述符 */
short events; /* 感兴趣的事件 */
short revents; /* 实际发生的事件(由内核填充) */
};
每个pollfd结构体就管一个fd。你想想看,这比select那套“位图操作”清晰多了吧?
常用的事件标志:
| 事件 | 说明 | 使用场景 |
|---|---|---|
| POLLIN | 有数据可读 | 普通socket、管道 |
| POLLOUT | 可以写入数据 | 发送缓冲区有空闲 |
| POLLERR | 发生错误 | 一般由内核设置 |
| POLLHUP | 对方关闭连接 | 检测客户端断开 |
| POLLNVAL | 文件描述符未打开 | fd无效时出现 |
个人经验:我习惯在每次poll返回后,先检查revents是否包含POLLERR或POLLHUP。这两个事件容易被忽略,但处理不好会导致连接泄漏。曾经有一次线上事故,就是因为没处理POLLHUP,导致一堆死连接占着fd不释放。
8.3 poll与select对比
这两个东西经常被放在一起比较。我直接说结论:poll是select的“改良版”,但不是“完美版”。
| 对比维度 | select | poll |
|---|---|---|
| 最大fd数 | 1024(默认) | 无上限(受系统限制) |
| 数据结构 | 位图(fd_set) | 链表(pollfd数组) |
| 事件注册方式 | 每次调用都要重新设置 | 只需设置一次events |
| 就绪fd查找 | 遍历所有fd(O(n)) | 遍历所有fd(O(n)) |
| 跨平台性 | 几乎所有平台 | POSIX系统 |
| 超时精度 | 微秒级 | 毫秒级 |
你看,poll在fd数量上完胜select。但遍历效率上,两者半斤八两——都是O(n)。为什么会这样?因为内核不知道哪个fd就绪了,只能一个个检查。
避坑指南:我曾经在项目中用poll监控了5000个连接,结果每次poll返回后,遍历整个数组花了大量CPU。后来才意识到,poll虽然突破了1024限制,但性能瓶颈从“fd数量”转移到了“遍历开销”。所以,连接数上万时,还是得用epoll。
8.4 poll服务器实现
光说不练假把式。咱们写一个简单的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 MAX_CLIENTS 1024
#define PORT 8888
int main() {
int listen_fd, conn_fd;
struct sockaddr_in server_addr;
struct pollfd fds[MAX_CLIENTS];
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);
}
// 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");
exit(1);
}
// 3. 开始监听
if (listen(listen_fd, 10) < 0) {
perror("listen");
exit(1);
}
// 4. 初始化pollfd数组
memset(fds, -1, sizeof(fds));
fds[0].fd = listen_fd;
fds[0].events = POLLIN; // 只关心可读事件
printf("Poll server started on port %d\n", PORT);
// 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;
}
// 找个空位放新连接
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;
printf("New client connected, fd=%d\n", conn_fd);
break;
}
}
if (i == MAX_CLIENTS) {
printf("Too many clients\n");
close(conn_fd);
}
}
// 检查所有客户端fd
for (i = 1; i < nfds; i++) {
if (fds[i].fd == -1) continue;
if (fds[i].revents & POLLIN) {
char buf[1024];
int n = read(fds[i].fd, buf, sizeof(buf) - 1);
if (n <= 0) {
// 客户端断开或出错
if (n == 0) {
printf("Client disconnected, fd=%d\n", fds[i].fd);
} else {
perror("read");
}
close(fds[i].fd);
fds[i].fd = -1;
} else {
buf[n] = '\0';
printf("Received from fd=%d: %s", fds[i].fd, buf);
// 简单回显
write(fds[i].fd, buf, n);
}
}
}
}
close(listen_fd);
return 0;
}
这段代码的核心逻辑其实很简单:
- 监听socket放在fds[0],只关心POLLIN事件
- 新连接来了,找个空位放进去
- 每次poll返回后,遍历所有客户端fd,处理就绪事件
- 客户端断开时,关闭fd并标记为-1
我的习惯:实际项目中,我不会用这种“线性遍历”的方式管理fd数组。一般会维护一个“空闲列表”或者用链表来管理,避免每次都要从头到尾找空位。另外,我建议把fd和业务数据绑定在一起,比如用结构体数组,每个元素包含fd、缓冲区、状态等。这样代码更清晰。
8.5 poll的局限性
虽然poll比select进步了,但它也不是万能的。我总结几个实际开发中要注意的点:
- 遍历开销:每次poll返回后,你得遍历整个数组才能知道哪些fd就绪了。连接数上万时,这个遍历本身就很耗时。
- 内存拷贝:每次调用poll,内核要把整个pollfd数组从用户态拷贝到内核态。fd多了,这个开销也不小。
- 水平触发:poll默认是水平触发模式。也就是说,如果某个fd一直有数据没读完,每次poll都会通知你。这可能导致“惊群”问题。
说白了,poll适合连接数在几千以内的场景。再往上,就得请出epoll了。不过那是后面章节的内容,咱们先打好poll的基础。
总结一下:poll用起来比select顺手,没有1024限制,事件管理也更直观。但它的性能瓶颈在于“遍历所有fd”和“用户态-内核态数据拷贝”。理解这些,你就能在合适的场景选对工具。
这张图把poll的整个流程串起来了。你看,从用户态调用poll开始,内核遍历数组,有就绪的返回,没有就阻塞。返回后用户再遍历一遍,检查每个fd的revents字段。说白了,poll把“监控”和“通知”分开了,但“遍历”这个活始终逃不掉。