Redis网络模型-IO多路复用模型-poll模式

一、前言:承上启下的改良者

在上一篇文章中,我们探讨了 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 不需要在每次调用前重新构建整个结构,因为 eventsrevents 是分开的。


四、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 : 这是核心函数。

    cpp 复制代码
    static 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) 的线性扫描。
  • 步骤 2poll 调用本身。
  • 整个过程:依然依赖用户态的遍历,没有利用内核级的数据结构来优化。

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 在无法使用 epollkqueue 时的第二选择


六、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

相关推荐
dFObBIMmai1 小时前
如何在 CSS 中实现元素的绝对定位,使其不受窗口尺寸变化影响
jvm·数据库·python
treesforest2 小时前
IP精准定位服务:从城市轮廓到街道坐标,技术如何重塑空间感知
网络·数据库·网络协议·tcp/ip·ip
大明者省2 小时前
宝塔开了端口,Ubuntu 还得开相应端口才能打通
服务器·数据库·ubuntu
平行侠2 小时前
A15 工业路由器IP前缀高速检索与内存压缩系统
网络·tcp/ip·算法
Teable任意门互动2 小时前
AI原生开源多维表格有哪些?主流开源多维表格对比解析
数据库·开源·excel·钉钉·飞书·开源软件·ai-native
yyyyy_abc2 小时前
子网掩码是什么
网络·智能路由器
9命怪猫3 小时前
[K8S小白问题集] - Calico好在哪里?
网络·云原生·容器·kubernetes
TDengine (老段)3 小时前
MNode 内部机制深度解析 — SDB、事务引擎与 DDL 处理全链路
大数据·数据库·物联网·时序数据库·iot·tdengine·涛思数据
这个DBA有点耶3 小时前
数据库上云 vs 自建:从成本到人力的三维对比与决策框架
数据库·经验分享·sql·创业创新·dba