本文只聊 x86_64 下的 redis 多路复用的 epoll,以及其 timer 实现。

aeEventLoop

如作者所说,这确实是一个简单的事件驱动的多路复用库(event-driven muiltplexing)

用一个不标准的 UML 表示其关系(标准的 UML 不利于理解代码,因为少了类型信息)

image

events, filred 是数组,timeEventHead 是链表头节点,beforeSleep, afterSleep 是函数指针。至于 apiData,是存放 epoll、poll 这些异步基础设施的某些数据的指针(所以是 void *)

这里的 typedef 是把一个函数名设置为一个类型名。这种做法有点奇怪,因为系统调用的 signal 给的是一个函数指针。但仔细一想好像也正常,因为 C/C++ 语言中函数名、函数指针、解函数名等等,都是同一个地址。所以你能够看到下面的代码输出的结果都是一样的。

image

aeEventLoop 的基本特点是:

  1. 利用 Linux 总是返回最小的可用 fd,采用数组的方式组织监听的 fd

  2. 使用链表组织 timer,利用 epoll 的 timeout 实现 timer,而不是 timerfd。这样无法修改某个定时器定时的时间。不过对于 redis 而言这不是个问题,因为它是单线程的。

  3. 在 epoll wait 前后会处理 beforeSleep 和 afterSleep

epoll 的创建

稍微过了一遍 ae.c,发现 redis 的异步基础设施是编译期决定的,而不是在运行时。具体是如下的预处理:

#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

也就是说,对应的 .c 文件实现了对应的函数即可供 redis 使用。

static int aeApiCreate(aeEventLoop *eventLoop) {
    aeApiState *state = zmalloc(sizeof(aeApiState));

    if (!state) return -1;
    state->events = zmalloc(sizeof(struct epoll_event)*eventLoop->setsize);
    if (!state->events) {
        zfree(state);
        return -1;
    }
    state->epfd = epoll_create(1024); /* 1024 is just a hint for the kernel */
    if (state->epfd == -1) {
        zfree(state->events);
        zfree(state);
        return -1;
    }
    anetCloexec(state->epfd);
    eventLoop->apidata = state;
    return 0;
}

至于 epoll 的创建,值得提不多:

  1. epoll_create 有两个版本,一个是 epoll_create,另一个是 epoll_create1。epoll_create 是 epoll_create1 的旧版本。它们声明是:
int epoll_create(int size);
int epoll_create1(int flags);

在 linux 2.6 后加入 epoll_create(size),此时 size 是告诉内核我可能要监听多少文件描述符(此时内部实现可能是数组,具体我不清楚)。后续 2.68 中,size 参数不再使用(此时改成黑红树了),在 2.6.27 中加入了 epoll_create1,此时可以使用 CLOEXEC 这一类 flag。

  1. 一开始 redis 使用的是 epoll_create1,具体可以看 issue 8242 https://github.com/redis/redis/pull/8242

简单说就是旧版本的内核没有 epoll_create1 或支持不完善,CLOEXEC 无法正常运作,所以要改成 epoll_create,然后加入了 anetCloexec 这个函数。anetCloexec 的实现也很简单,一次 fcntl get, 一次 fcntl set。

  1. epoll 运行的信息存放在 apidata 指针指向的内存处

创建、删除 Event

创建即初始化 aeFileEvent 结构体,然后把 fd,通过 epoll_ctl 加入到监听列表中。如果已经在监听了,就要 EPOLL_MOD,如果是新增 fd,就要 EPOLL_ADD。

删除包括删除监听某事件和完整删除文件描述符。

static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int delmask) {
...
    if (mask != AE_NONE) {
        epoll_ctl(state->epfd,EPOLL_CTL_MOD,fd,&ee);
    } else {
        /* 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);
    }
}

epoll wait

在 epoll wait 后会把有事件要处理的 fd 以及事件类型存入 aeEventLoop->fired 中。

基于时钟周期的 timer 实现——monotonic.c

这东西可太有意思了。

传统获取事件的方法,要么是 c 标准的 time(NULL),要么是系统调用 gettimeofday(timeval*, timezone) 或者 clock_gettime。time 的精度不行,gettimeofday 据说正在弃用,clock_gettime 是系统调用,比较慢。见 issue 7644 https://github.com/redis/redis/pull/7644

其实现简单说就是,如果你的 CPU 支持 constant_tsc,并且获取了你的 CPU 频率,那么就可以用这个信息,用一行汇编指令获取两个时间点的时钟周期数,相减后乘以频率即可算出这两个时间点之间过了多长事件。

猜你在想,现代 CPU 不是会动态调整频率吗?确实,但是 tsc 是恒定的。更加具体我也不知道,反正 redis 在用。

x86_64 可以用 __rdstc(),arm 就得自己写汇编了。

初始化的时候,会检查你的 CPU 是否支持 constant_tsc,以及获取频率。做法很简单,读取 /proc/cpuinfo 即可。之后会设置 getMonotonicUs 这个函数指针,这样用户就可以直接调用对应的实现。

timer 结合 aeEventLoop

epoll_wait 前后会处理 timer。唯一值得提的是它确定 timeout 的方法是遍历 timer 链表,找到最早的那个 timer,据此设置 timeout。而所有的 timer 都在 aftersleep 后进行。timer 结构体中有一个 refcount,它是用于处理删除难的问题的。

创建的时候:

  1. 每个 timer 创建的时候会分配一个唯一的、递增的 id。

  2. refcount 初始化为 0。说实话我认为这有些奇怪,可能是用 C++ 智能指针导致的。

  3. 用 monotonic 获取执行 timer 事件的时间,然后头插到 timer 链表中。

删除的时候:

  1. aeDeleteTimeEvent 不是真正释放内存的时候,它只是设置 flag 为 deleted。

  2. 真正释放内存的时候是 processTimeEvents 中遍历链表的时候。

  3. 真正释放内存之前执行 finalizerProc。

执行 timer 事件的时候:

  1. 递增 refcount

  2. 执行 timer->timeProc

  3. 递减 refcount

为什么在这处理 refcount?可能是 future defense,预防未来的修改让代码不正确,比如把真正释放内存的地方放在 aeDeleteTimeEvent 的时候,说不定用户给的回调删除了链表中的节点。

另一个 future defense 是:防止未来改变代码导致的执行新加入的 timer,做法是比较 maxid。