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 不需要在每次调用前重新构建整个结构,因为 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 : 这是核心函数。

    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) 的线性扫描。
  • 步骤 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 时的第二选择。


六、结语

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

相关推荐
hweiyu008 小时前
Redis命令:HSTRLEN
redis·缓存
阳光九叶草LXGZXJ8 小时前
达梦数据库-报错-15-列【XXX】长度超出定义
linux·运维·数据库·sql·学习
字节渡客9 小时前
Redis键明明过期了,业务还在读到旧数据
数据库·redis·spring
前端兰博11 小时前
05-Redis
redis·后端
OnlineProxy11 小时前
亚马逊多账号运营:如何在规避“关联封号”风险的同时实现电商规模化扩张
服务器·数据库·redis
盟接之桥11 小时前
当大模型遇见线束制造:不是通用AI,而是行业AI
大数据·网络·人工智能·安全·制造
91刘仁德12 小时前
IP协议详解:从IP协议头到网段划分、路由与NAT
linux·服务器·网络·网络协议·tcp/ip
辻弋20112 小时前
五年前的旅行视频糊成马赛克?Video2X用Real-ESRGAN逐帧重建细节,但只支持Windows、集显用户建议直接放弃
服务器·数据库·windows·游戏引擎·电脑
可乐鸡翅yeah_13 小时前
业务中 M3U8 水印相关坑,硬水印和动态水印区别
前端·网络·数据库·ffmpeg·m3u8在线
石头麻辣鱼14 小时前
Bamboo 调度系统 OceanBase 适配实战:存储过程迁移踩过的三个大坑
数据库