Redis源码探究系列—epoll、kqueue 在 Redis 中的封装实现(上)

欢迎各位同学关注我哦~
在这个 AI 喧嚣的时代
不忘初心,戒骄戒躁,认真沉淀
Deepincode

在之前的文章中我们分析过Redis事件循环框架,了解过aeEventLoop 的整体设计,其中 apidata 字段用于存储底层多路复用机制的私有数据。
Redis通过统一的抽象层,屏蔽了不同操作系统I/O多路复用接口的差异:

机制操作系统性能
epollLinux
kqueueBSD/macOS
evportSolaris
select跨平台低(兜底方案)

本文将深入剖析Redis对 epoll 的封装实现,下一篇文章将分析 kqueue 的封装。

一、多路复用机制的选择策略

1.1 编译时选择

Redis在编译时根据操作系统自动选择最优的多路复用机制:

// src/ae.c:49-61
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif

编译时按性能从高到低的顺序检测系统支持的多路复用机制,选择第一个可用的:首先是Solaris的evport,其次是Linux的epoll,然后是BSD/macOS的kqueue,
最后是作为兜底方案的select。

1.2 统一的API抽象

Redis定义了一套统一的多路复用API,每种实现都遵循相同的接口规范:

// 以下是需要实现的统一接口(内部使用)
static int aeApiCreate(aeEventLoop *eventLoop);           // 创建多路复用实例
static int aeApiResize(aeEventLoop *eventLoop, int setsize);  // 调整容量
static void aeApiFree(aeEventLoop *eventLoop);            // 释放资源
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask);  // 注册事件
static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask); // 删除事件
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp);   // 等待事件
static char *aeApiName(void);                             // 返回机制名称

二、epoll基础知识

epoll是Linux 2.6内核引入的I/O多路复用机制,相比传统的select/poll,它在处理大量并发连接时有显著的性能优势。

2.1 epoll的核心优势

相比传统的select/poll,epoll具有以下优势:

特性select/pollepoll
fd数量限制有限制(FD_SETSIZE,默认1024)几乎无限制
效率O(n)遍历所有fdO(1)只返回就绪的fd
内存拷贝每次调用需要拷贝共享内存,无需拷贝
触发模式仅水平触发支持边缘触发(ET)

2.2 epoll的三个核心系统调用

// 创建epoll实例,返回epoll文件描述符
int epoll_create(int size);
// 管理epoll实例中的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 等待事件发生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

2.3 epoll_event结构体

epoll_event 是epoll机制中用于描述事件的核心结构体:

struct epoll_event {
    uint32_t events;    // 事件类型(EPOLLIN、EPOLLOUT 等)
    epoll_data_t data;  // 用户数据(可存储fd或指针)
};

typedef union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

events 是一个位掩码字段,用于表示关注的事件类型。最常用的是 EPOLLIN(可读)和 EPOLLOUT(可写),
此外还有 EPOLLERREPOLLHUP等事件。通过按位或操作可以同时监听多种事件。还有一个特殊的 EPOLLET 标志用于启用边缘触发模式,
不过Redis并没有使用这个特性,它采用的是默认的水平触发模式。

data 用于存储用户自定义数据。最常见的用法是 data.fd 存储文件描述符,或者 data.ptr 存储一个指针指向更复杂的结构体。
Redis选择了前者,因为它的 aeEventLoop 中已经维护了一个以fd为索引的 events 数组,只需要从epoll拿到触发事件的fd,就能快速定位到对应的事件处理信息。

三、Redis中epoll的封装实现

3.1 数据结构定义

Redis使用 aeApiState 结构体封装epoll相关状态:

// src/ae_epoll.c:34-37
typedef struct aeApiState {
    int epfd;                   // epoll实例的文件描述符
    struct epoll_event *events; // 用于接收epoll_wait返回的事件数组
} aeApiState;

结构体很简单,只有两个字段:epfd 保存 epoll_create 返回的epoll实例文件描述符,
events 则是预分配的事件数组,用于接收 epoll_wait 返回的就绪事件。预先分配好这个数组可以避免每次调用 epoll_wait 时临时分配内存。

3.2 创建epoll实例 aeApiCreate

// src/ae_epoll.c:39-56
static int aeApiCreate(aeEventLoop *eventLoop) {
    aeApiState *state = zmalloc(sizeof(aeApiState));  // 分配状态结构体

    if (!state) return -1;
    // 预分配事件数组,大小为setsize
    // setsize决定了Redis能处理的最大fd数量
    state->events = zmalloc(sizeof(struct epoll_event)*eventLoop->setsize);
    if (!state->events) {
        zfree(state);
        return -1;
    }
    // 创建epoll实例
    // 参数size在Linux 2.6.8后已被忽略,但必须>0
    state->epfd = epoll_create(1024); /* 1024 is just a hint for the kernel */
    if (state->epfd == -1) {
        // 创建失败,释放已分配的资源
        zfree(state->events);
        zfree(state);
        return -1;
    }
    // 将epoll状态保存到事件循环的apidata字段
    eventLoop->apidata = state;
    return 0;
}

整个函数的逻辑比较清晰:先分配 aeApiState 结构体,然后预分配 epoll_event 数组,数组大小由 eventLoop->setsize 决定,这个值对应Redis能处理的最大文件描述符数量。
接着调用 epoll_create 创建epoll实例,参数1024在Linux 2.6.8以后已经被忽略,所以只需要传一个大于0的值即可。
最后将状态结构体保存到 eventLoop->apidata,完成初始化。

3.3 调整容量 aeApiResize

// src/ae_epoll.c:58-63
static int aeApiResize(aeEventLoop *eventLoop, int setsize) {
    aeApiState *state = eventLoop->apidata;

    // 重新分配事件数组的大小
    // 当Redis需要动态调整最大连接数时调用
    state->events = zrealloc(state->events, sizeof(struct epoll_event)*setsize);
    return 0;
}

这个函数用于动态调整事件数组的大小。当通过 CONFIG SET maxclients 修改最大客户端连接数时,
会触发容量调整,对应的 setsize 也会变化,此时需要重新分配 events 数组以适应新的容量。

3.4 释放资源 aeApiFree

// src/ae_epoll.c:65-71
static void aeApiFree(aeEventLoop *eventLoop) {
    aeApiState *state = eventLoop->apidata;

    close(state->epfd);      // 关闭epoll实例文件描述符
    zfree(state->events);    // 释放事件数组
    zfree(state);            // 释放状态结构体
}

3.5 注册事件 aeApiAddEvent

// src/ae_epoll.c:73-88
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
    aeApiState *state = eventLoop->apidata;
    struct epoll_event ee = {0};
    // 判断操作类型:如果该fd已有事件注册,则使用MOD;否则使用ADD
    int op = eventLoop->events[fd].mask == AE_NONE ?
            EPOLL_CTL_ADD : EPOLL_CTL_MOD;

    ee.events = 0;
    // 合并已有的事件类型(MOD操作时保留之前注册的事件)
    mask |= eventLoop->events[fd].mask; /* Merge old events */
    // 将 Redis 的事件类型转换为epoll的事件类型
    if (mask & AE_READABLE) ee.events |= EPOLLIN;   // 可读事件
    if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;  // 可写事件
    ee.data.fd = fd;  // 将fd存储在epoll_event.data中
    // 调用epoll_ctl注册或修改事件监听
    if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;
    return 0;
}

这个函数的关键在于判断该使用 EPOLL_CTL_ADD 还是 EPOLL_CTL_MOD。如果这个fd之前没有任何事件注册(mask == AE_NONE),
就使用ADD操作;如果已经有事件在监听,就使用MOD操作来修改。MOD操作时要注意合并已有的事件,比如一个fd已经注册了可读事件,
现在又要注册可写事件,就需要把两种事件合并后一起设置,否则会覆盖掉之前的监听。

事件类型转换也很直观,Redis的 AE_READABLE 对应 epoll 的 EPOLLINAE_WRITABLE 对应 EPOLLOUT

事件注册示例

假设fd=5当前无任何事件注册:
  1. 调用aeApiAddEvent(eventLoop, 5, AE_READABLE)
  2. op = EPOLL_CTL_ADD(因为mask == AE_NONE)
  3. ee.events = EPOLLIN
  4. 调用epoll_ctl(epfd, EPOLL_CTL_ADD, 5, &ee)

假设fd=5已注册AE_READABLE,现在要添加AE_WRITABLE:
  1. 调用aeApiAddEvent(eventLoop, 5, AE_WRITABLE)
  2. op = EPOLL_CTL_MOD(因为mask != AE_NONE)
  3. ee.events = EPOLLIN | EPOLLOUT(合并已有事件)
  4. 调用 epoll_ctl(epfd, EPOLL_CTL_MOD, 5, &ee)

3.6 删除事件 aeApiDelEvent

// src/ae_epoll.c:90-106
static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int delmask) {
    aeApiState *state = eventLoop->apidata;
    struct epoll_event ee = {0}; /* avoid valgrind warning */
    // 计算删除后剩余的事件类型
    int mask = eventLoop->events[fd].mask & (~delmask);

    ee.events = 0;
    // 重新构建 epoll 事件掩码
    if (mask & AE_READABLE) ee.events |= EPOLLIN;
    if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
    ee.data.fd = fd;

    if (mask != AE_NONE) {
        // 还有其他事件,使用MOD操作更新
        epoll_ctl(state->epfd,EPOLL_CTL_MOD,fd,&ee);
    } else {
        // 所有事件都已删除,从epoll中移除该fd
        /* Note, Kernel < 2.6.9 requires a non null event pointer even for
         * EPOLL_CTL_DEL. */
        epoll_ctl(state->epfd,EPOLL_CTL_DEL,fd,&ee);
    }
}

删除事件时需要考虑一个问题:这个fd可能同时注册了可读和可写事件,我们只想删除其中一个。
所以函数首先计算删除后剩余的事件类型,用位运算 mask & (~delmask) 来实现。如果删除后还有剩余事件,
就用MOD操作更新;如果全部删完了,就用DEL操作把这个fd从epoll中移除。
注意内核2.6.9之前的版本要求DEL 操作时event参数不能为NULL,所以这里即使删除也传了一个有效的 ee 指针。

事件删除示例

假设 fd=5已注册AE_READABLE | AE_WRITABLE:

场景1:删除AE_WRITABLE
  1. mask = (AE_READABLE | AE_WRITABLE) & (~AE_WRITABLE) = AE_READABLE
  2. ee.events = EPOLLIN
  3. 调用epoll_ctl(epfd, EPOLL_CTL_MOD, 5, &ee)

场景2:删除AE_READABLE | AE_WRITABLE(全部删除)
  1. mask = (AE_READABLE | AE_WRITABLE) & (~(AE_READABLE | AE_WRITABLE)) = AE_NONE
  2. 调用epoll_ctl(epfd, EPOLL_CTL_DEL, 5, &ee)

3.7 等待事件 aeApiPoll

// src/ae_epoll.c:108-131
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
    aeApiState *state = eventLoop->apidata;
    int retval, numevents = 0;

    // 调用epoll_wait等待事件发生
    // 参数:epoll实例fd、事件数组、数组大小、超时时间(毫秒)
    retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
            tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
    if (retval > 0) {
        int j;

        numevents = retval;
        // 遍历所有触发的事件
        for (j = 0; j < numevents; j++) {
            int mask = 0;
            struct epoll_event *e = state->events+j;

            // 将 epoll 事件类型转换回Redis事件类型
            if (e->events & EPOLLIN) mask |= AE_READABLE;
            if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
            // 错误和挂断事件也标记为可写,便于上层处理
            if (e->events & EPOLLERR) mask |= AE_WRITABLE;
            if (e->events & EPOLLHUP) mask |= AE_WRITABLE;
            // 将触发的事件信息填充到fired数组
            eventLoop->fired[j].fd = e->data.fd;
            eventLoop->fired[j].mask = mask;
        }
    }
    // 返回触发的事件数量
    return numevents;
}

这是事件循环的核心函数。它调用 epoll_wait 阻塞等待事件发生,超时参数 tvp 为NULL时表示无限等待,
否则需要把秒和微秒转换成毫秒传给epoll。当有事件返回时,遍历所有就绪的事件,把epoll的事件类型转换回Redis内部的类型,
然后填充到 eventLoop->fired 数组中供上层使用。

值得注意的是对 EPOLLERREPOLLHUP 的处理。这两个异常事件被标记为可写事件,这样上层的写回调函数就能被触发去处理异常情况。
这是一种巧妙的设计,避免了单独处理异常事件的复杂性。

3.8 获取机制名称 aeApiName

// src/ae_epoll.c:133-135
static char *aeApiName(void) {
    return "epoll";
}

用于日志输出和调试信息。

四、epoll封装的完整调用链

在这里插入图片描述

五、Redis对epoll的优化点

5.1 预分配事件数组

state->events = zmalloc(sizeof(struct epoll_event)*eventLoop->setsize);

在创建epoll实例时就预先分配好事件数组,避免每次调用 epoll_wait 时临时分配内存。对于Redis这种高频调用I/O多路复用的服务来说,
减少内存分配次数对性能有实际意义。

5.2 事件合并策略

mask |= eventLoop->events[fd].mask; /* Merge old events */

添加新事件时,先查询该fd已注册的事件,然后合并后再设置。这样即使一个fd需要分别注册可读和可写事件,也不需要调用两次 epoll_ctl,减少系统调用次数。

5.3 使用水平触发(LT)

Redis使用epoll的默认触发模式——水平触发(Level Triggered),而非边缘触发(Edge Triggered)。

水平触发的特点是只要fd处于可读或可写状态,epoll_wait 就会持续通知。相比之下,边缘触发只在状态变化时通知一次,必须一次性把数据读完或写完,否则可能遗漏事件。

Redis选择水平触发的原因很简单:编程模型更直观,可以灵活控制每次读写的数据量,配合非阻塞I/O使用也不会有阻塞风险。
边缘触发虽然理论上性能更高,但编程复杂度也随之增加,对于Redis这种单线程事件驱动的架构来说,水平触发已经足够高效。

5.4 动态容量调整

static int aeApiResize(aeEventLoop *eventLoop, int setsize) {
    aeApiState *state = eventLoop->apidata;
    state->events = zrealloc(state->events, sizeof(struct epoll_event)*setsize);
    return 0;
}

Redis支持运行时通过 CONFIG SET maxclients 调整最大连接数,事件数组的大小也需要相应调整。这个函数使得Redis无需重启就能根据负载情况动态调整容量。

六、总结

本文详细分析了Redis对epoll的封装实现,核心要点如下:

APIepoll系统调用功能
aeApiCreateepoll_create创建epoll实例
aeApiAddEventepoll_ctl(ADD/MOD)注册/修改事件
aeApiDelEventepoll_ctl(MOD/DEL)删除事件
aeApiPollepoll_wait等待事件触发
aeApiFreeclose(epfd)释放资源

下一篇文章将分析Redis对 kqueue 的封装实现,展示Redis是如何在BSD/macOS系统上实现高性能I/O多路复用。

欢迎各位同学关注我哦~
在这个 AI 喧嚣的时代
不忘初心,戒骄戒躁,认真沉淀
Deepincode
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值