16、poll模型:poll()函数详解、pollfd结构体、与select的对比
聊完了select,咱们来看看它的老大哥——poll。
说实话,我刚开始做网络编程那会儿,用的就是select。后来项目越做越大,连接数一上来,select那点家底就不够看了。这时候,poll就派上了用场。
poll和select干的是同一件事:帮你盯着多个文件描述符,看哪个准备好了。但实现方式上,poll要优雅得多。
pollfd结构体:核心数据结构
先看poll的核心数据结构。它比select的fd_set要直观不少。
#include <poll.h>
struct pollfd {
int fd; /* 要监视的文件描述符 */
short events; /* 关心的事件类型 */
short revents; /* 实际发生的事件类型 */
};
你看,每个pollfd结构体就管一个描述符。fd就是你要监视的套接字或文件。events是你关心的事件,revents是内核返回给你的结果。
events和revents可以取这些值:
| 事件常量 | 含义 | 可用于events | 可用于revents |
|---|---|---|---|
| POLLIN | 有数据可读 | 是 | 是 |
| POLLOUT | 可以写入数据 | 是 | 是 |
| POLLERR | 发生错误 | 否 | 是 |
| POLLHUP | 对方关闭连接 | 否 | 是 |
| POLLNVAL | 文件描述符未打开 | 否 | 是 |
我个人习惯,每次初始化pollfd时,都把fd设成-1,events设成0。这样后面方便跳过无效的描述符。
poll()函数:用法详解
函数原型长这样:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
- fds:pollfd结构体数组的首地址
- nfds:数组中有多少个有效的描述符
- timeout:超时时间,单位毫秒
timeout的取值很有意思:
- -1:永远等待,直到有事件发生
- 0:立即返回,不阻塞
- 大于0:等待指定的毫秒数
返回值:
- 大于0:就绪的描述符数量
- 等于0:超时,没有描述符就绪
- -1:出错,errno被设置
小技巧:timeout设为0时,poll就变成了非阻塞模式。我经常用它来做一次性的状态检查,比如在游戏循环里快速扫一眼有没有新数据。
实战代码:一个简单的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 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;
struct pollfd fds[MAX_CLIENTS];
int nfds = 1, i;
char buffer[BUFFER_SIZE];
// 创建监听套接字
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 设置地址重用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定地址
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(8888);
if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listen_fd);
exit(1);
}
// 开始监听
if (listen(listen_fd, 10) < 0) {
perror("listen");
close(listen_fd);
exit(1);
}
// 初始化pollfd数组
memset(fds, -1, sizeof(fds));
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
printf("Poll echo server started on port 8888...\n");
while (1) {
int ret = poll(fds, nfds, -1);
if (ret < 0) {
perror("poll");
break;
}
// 检查监听套接字
if (fds[0].revents & POLLIN) {
client_len = sizeof(client_addr);
client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
continue;
}
// 找个空位放新连接
for (i = 1; i < MAX_CLIENTS; i++) {
if (fds[i].fd == -1) {
fds[i].fd = client_fd;
fds[i].events = POLLIN;
if (i >= nfds) nfds = i + 1;
printf("New client connected, fd=%d\n", client_fd);
break;
}
}
if (i == MAX_CLIENTS) {
printf("Too many clients, rejecting\n");
close(client_fd);
}
}
// 检查客户端连接
for (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("Client fd=%d disconnected\n", fds[i].fd);
} else {
perror("read");
}
close(fds[i].fd);
fds[i].fd = -1;
} else {
buffer[n] = '\0';
printf("Received from fd=%d: %s", fds[i].fd, buffer);
// 原样返回
write(fds[i].fd, buffer, n);
}
}
if (fds[i].revents & POLLERR) {
printf("Error on fd=%d\n", fds[i].fd);
close(fds[i].fd);
fds[i].fd = -1;
}
}
}
// 清理
for (i = 0; i < nfds; i++) {
if (fds[i].fd != -1) {
close(fds[i].fd);
}
}
return 0;
}
注意:poll返回后,revents字段会被内核修改。你必须在每次poll调用后重新检查所有描述符。我曾经犯过这个错——以为revents会自己清零,结果程序跑着跑着就乱了。
poll vs select:到底差在哪?
这两个家伙,说白了都是I/O多路复用的老前辈。但poll确实比select强不少。
| 对比项 | select | poll |
|---|---|---|
| 描述符数量限制 | FD_SETSIZE(通常1024) | 无硬性限制(受内存限制) |
| 数据结构 | fd_set位图 | pollfd结构体数组 |
| 事件分离 | 读、写、异常三个集合 | 统一在events/revents中 |
| 重新初始化 | 每次调用前都要重新设置 | events字段保持不变 |
| 跨平台 | 几乎所有平台 | POSIX系统,Windows不支持 |
| 性能(大量连接) | O(n)遍历,n为最大描述符 | O(n)遍历,n为实际描述符数 |
你看,poll最大的优势就是没有那个1024的限制。我记得有个项目,需要同时管理5000多个连接,用select根本搞不定,换成poll就轻松多了。
另外,poll不需要每次重新构造整个集合。select每次调用前都得把三个fd_set重新填一遍,poll的events字段会保留你上次的设置。这个细节,写代码时能省不少事。
但poll也不是完美的。它和select一样,都是线性扫描——每次返回后,你得遍历整个数组才能知道哪些描述符就绪了。连接数上万时,这个开销就很可观了。
核心区别一句话:select用位图,有上限;poll用结构体数组,无上限。但两者都是O(n)的遍历模型。
poll的避坑指南
用poll这些年,我踩过不少坑。分享几个典型的:
- revents不清零:poll不会自动清零revents。每次调用前,你得手动把revents设成0。我习惯在循环开始前用memset清零整个数组。
- fd重复:同一个fd不要出现在多个pollfd条目中。否则revents会被覆盖,结果不可预测。
- timeout精度:poll的timeout是毫秒级。如果你需要微秒级的精度,得用ppoll或者epoll。
- 信号中断:poll被信号中断时,会返回-1,errno设为EINTR。这时候要不要重试,得看你的业务逻辑。
嗯,说到信号中断,我有个亲身经历。有一次线上服务莫名其妙地超时,查了半天才发现是定时器信号打断了poll。后来加了EINTR重试逻辑,问题就解决了。
poll的工作流程
下面这张图,把poll的整个流程串起来了。你看一遍,应该就能理解它的工作方式。
你看,整个流程其实很清晰。用户设置events,内核填充revents,然后你遍历数组处理就绪的描述符。循环往复,直到程序退出。
说实话,poll虽然比select先进,但在高并发场景下,它和select一样,都有O(n)的遍历开销。如果你要处理上万甚至十万级的连接,那还是得用epoll或者kqueue。不过对于中小型项目,poll完全够用了。
好了,poll就聊到这儿。记住它的核心思想:用结构体数组管理描述符,用events/revents分离关心的事件和发生的事件。这个设计思路,后来被很多框架借鉴了。
公众号:蓝海资料掘金营,微信deep3321