一、前言:承上启下的改良者
在上一篇文章中,我们探讨了 IO 多路复用的开山鼻祖 select,并见识了它在高并发下的种种局限。为了弥补 select 的不足,poll 应运而生。
poll 可以看作是 select 的一次重要"升级",它解决了 select 最致命的"连接数限制"问题。然而,它并未从根本上改变低效的"轮询"本质。虽然 Redis 在现代 Linux 系统上优先选择 epoll,但理解 poll 的改进与妥协,是完整理解 IO 多路复用技术演进的关键一环。
核心价值 :
poll通过引入链表结构,突破了select的 1024 连接数限制,但它依然保留了 O(N) 的线性扫描开销。它是从"能用"到"好用"之间的重要过渡,也是理解为何epoll如此重要的必经之路!
本文将带你:
- 搞懂
poll如何改进select - 剖析
poll为何依然无法摆脱性能瓶颈 - 理解 Redis 为何在拥有
poll的情况下,依然坚定地选择了epoll
二、poll 是什么?select 的"扩容版"
2.1 核心思想
poll 的核心思想与 select 几乎完全一致:允许一个进程通过一次系统调用,同时监视多个文件描述符,并阻塞等待,直到其中至少有一个 fd 变得"就绪"。
你可以把它想象成一位比 select 的"轮询官"更灵活的"新轮询官"。他不再受限于一份固定大小的名单,而是可以拿着一本可以随时加页的记事本(链表),记录任意数量的监视对象。
2.2 函数原型与参数详解
cpp
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds: 指向一个struct pollfd类型的数组。这个数组包含了所有我们想要监视的文件描述符及其关心的事件。nfds:fds数组中元素的个数。timeout: 超时时间(毫秒)。设为-1表示永久阻塞;设为0表示非阻塞轮询;正值表示等待指定时间。
struct pollfd 结构体
cpp
struct pollfd {
int fd; // 要监视的文件描述符
short events; // 我们关心的事件类型(输入)
short revents; // 实际发生的事件类型(输出)
};
events: 在调用poll前,由应用程序设置,告诉内核我们关心这个 fd 的哪些事件(如POLLIN可读,POLLOUT可写)。revents:poll调用返回后,由内核填充,告诉我们这个 fd 上实际发生了哪些事件。
关键改进 :
poll不再使用fd_set位图,而是使用pollfd结构体数组。这意味着它不再受限于 1024 个文件描述符。
三、poll 的工作流程
poll 的工作流程与 select 非常相似,但代码更加简洁。
cpp
// 1. 初始化 pollfd 数组
struct pollfd fds[MAX_CONNECTIONS];
int num_fds = 0;
// 将监听 socket 加入数组
fds[num_fds].fd = server_sock;
fds[num_fds].events = POLLIN;
num_fds++;
while (1) {
// 2. 阻塞等待事件
int activity = poll(fds, num_fds, -1);
if (activity < 0) {
// 错误处理
continue;
}
// 3. 遍历数组,找出就绪的 fd
for (int i = 0; i < num_fds; i++) {
// 4. 检查是否有新连接
if (fds[i].fd == server_sock && (fds[i].revents & POLLIN)) {
int client_sock = accept(server_sock, ...);
// 将新客户端 socket 加入数组
fds[num_fds].fd = client_sock;
fds[num_fds].events = POLLIN;
num_fds++;
}
// 5. 检查客户端 socket 是否有数据可读
else if (fds[i].fd != server_sock && (fds[i].revents & POLLIN)) {
handle_client_request(fds[i].fd);
}
}
}
️ 注意 :与
select不同,poll不需要在每次调用前重新构建整个结构,因为events和revents是分开的。
四、poll 的改进与遗留问题
4.1 改进:突破连接数限制
select使用固定大小的fd_set位图,上限为 1024。poll使用pollfd数组,其大小由应用程序决定。在内核中,它通常使用链表 来存储这些pollfd结构。- 结果 :
poll理论上没有文件描述符数量的硬性限制 (仅受限于系统内存和ulimit配置)。这是一个巨大的进步。
4.2 遗留问题:O(N) 的线性扫描
尽管 poll 解决了连接数问题,但它完全继承了 select 的低效扫描机制。
- 用户态开销 :每次
poll返回后,应用程序必须线性遍历 整个fds数组,检查每个元素的revents字段,以找出哪些 fd 是就绪的。 - 内核态开销 :内核收到
poll调用后,同样需要线性扫描 整个fds数组(或其在内核中的链表副本),去检查每个 fd 的状态。 - 后果 :和
select一样,无论有多少个 fd 是活跃的,这个 O(N) 的扫描过程都无法避免。当监听 10,000 个连接时,即使只有 10 个活跃,CPU 也要做 10,000 次检查。
4.3 遗留问题:内存拷贝
- 每次
poll调用,都需要将整个fds数组从用户空间拷贝到内核空间。 - 调用返回时,内核又需要将修改后的
fds数组从内核空间拷贝回用户空间。 - 这个开销依然存在,并且随着
nfds的增大而线性增长。
总结 :
poll是一次成功的"扩容",但它没有改变"轮询"的本质。它解决了"能不能"的问题,但没有解决"快不快"的问题。
五、Redis 如何封装 poll?
和 select 一样,Redis 也通过其事件驱动框架对 poll 进行了封装。
5.1 ae_poll.c 的实现
Redis 的 ae_poll.c 文件实现了基于 poll 的后端。
-
aeApiCreate: 负责初始化。对于poll,它同样不需要做太多事。 -
aeApiAddEvent/aeApiDelEvent: 这两个函数在poll实现中也几乎是空操作。 -
aeApiPoll: 这是核心函数。cppstatic int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) { aeApiState *state = eventLoop->apidata; int retval, j, numevents = 0; // 1. 遍历所有已注册的事件,准备 pollfd 数组 for (j = 0; j <= eventLoop->maxfd; j++) { aeFileEvent *fe = &eventLoop->events[j]; if (fe->mask != AE_NONE) { state->fds[j].fd = j; state->fds[j].events = 0; if (fe->mask & AE_READABLE) state->fds[j].events |= POLLIN; if (fe->mask & AE_WRITABLE) state->fds[j].events |= POLLOUT; } } // 2. 调用 poll! retval = poll(state->fds, eventLoop->maxfd+1, tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1); // 3. 处理 poll 返回的结果 if (retval > 0) { for (j = 0; j <= eventLoop->maxfd; j++) { int mask = 0; aeFileEvent *fe = &eventLoop->events[j]; // 4. 检查每个 fd 的 revents if (fe->mask & AE_READABLE && (state->fds[j].revents & POLLIN)) mask |= AE_READABLE; if (fe->mask & AE_WRITABLE && (state->fds[j].revents & POLLOUT)) mask |= AE_WRITABLE; if (mask) { // 5. 将就绪事件记录到 fired 数组 eventLoop->fired[numevents].fd = j; eventLoop->fired[numevents].mask = mask; numevents++; } } } return numevents; }
关键洞察 :
这段代码再次暴露了
poll的本质:
- 步骤 1 和 4:清晰地展示了 O(N) 的线性扫描。
- 步骤 2 :
poll调用本身。- 整个过程:依然依赖用户态的遍历,没有利用内核级的数据结构来优化。
5.2 编译时的选择
Redis 的编译脚本会按优先级检测系统特性:
cpp
// ae.c
#ifdef HAVE_EPOLL
#include "ae_epoll.c" // 优先选择 epoll
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#ifdef HAVE_POLL
#include "ae_poll.c" // 其次选择 poll
#else
#include "ae_select.c" // 最后 fallback 到 select
#endif
#endif
#endif
这意味着,poll 是 Redis 在无法使用 epoll 或 kqueue 时的第二选择。
六、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!