Redis事件驱动库

--------------云中楼阁原创,欢迎转载交流---------------------

事件驱动的编程方式已经很普及了,原因自然是互联网的疾速膨胀,现在要写个服务器不用事件驱动,出门都不好意思跟人打招呼。但是实现事件库并不是那么容易,首先它与人们亦步亦趋的思考方式有点儿冲突,其次事件库的底层实现必须平台相关,如Linux使用epoll,FreeBSD使用kqueue。

事件驱动库是很多系统软件的基础设施,如Lighttpd、NodeJS使用了libev,Memcached使用了libevent,Nginx和Redis自己实现了一套。通用的事件库一般比较复杂,有很多我们并不需要的功能,而自己写一个又费时间,可能Boss不会同意。本文将分析一个小巧的事件库,它内置于Redis。如果你事先没看Redis事件库的源码(ae.h ae.c ae_epoll.c),读起来可能会有困难。

一个事件库的必要组成元素有哪些呢:

1)事件,一般由外在因素触发,比如有网络数据到达;
2)事件处理函数,事情发生以后要靠它处理;
3)事件与处理函数之间的映射关系,将上述两种概念联系起来;
4)循环监控,基于事件驱动的程序一般主体是个循环,在每一遍循环中检查发生了哪些事儿,然后调用相应的处理函数;

事件:
操作系统产生的事件包括文件的读写,网络接口的读写,操作系统信号,超时事件。前两个Linux统一为文件描述符,信号事件Redis没有在事件库中实现(确实没这个必要),超时事件Redis只向操作系统借了个获取系统时间的系统调用。于是库中事件只有两种:文件读写和超时。
struct aeFileEvent {
    int mask;                 /* AE_READABLE表示可读,AE_WRITABLE表示可写 */
    aeFileProc *rfileProc;    /* 处理读事件 */
    aeFileProc *wfileProc;    /* 处理写事件 */
    void *clientData;         /* 传递用户数据给读写函数,这是Linux C的惯用法 */
}; 

struct aeTimeEvent {
    long long id;             /* 标识TimeEvent,实际用处下面再说 */
    long when_sec;
    long when_ms; 
    aeTimeProc *timeProc;     /* 超时处理函数 */
    aeEventFinalizerProc *finalizerProc;    /* TimeEvent被删掉时会被调用,通常被设为NULL */
    void *clientData;
    aeTimeEvent *next;        /* 所有的TimeEvent组成一个链表 */

};

映射关系:
超时处理简单,在每一轮循环(事件驱动程序的主体是个循环)中,通过系统调用获取当前时间,然后遍历TimeEvent链表,把该处理的都给处理了。文件该怎么办呢,我怎么知道它到底能不能读写,用户程序是无法知道的,只能借助于系统调用,在Linux中使用epoll,当文件事件发生时,epoll机制会捕获它,然后我们把发生的事件保存起来,这样在每一轮主循环中就知道发生了哪些文件事件了。epoll会将发生的事件保存在 aeFiredEvent中:
struct aeFiredEvent {         /* 哪个文件发生了什么事儿 */
    int fd;
    int mask;
}; 
“将发生的文件事件保存在FireEvent中”这个过程主要是由操作系统帮你完成的,不是在主循环内实现的,你要是能在用户程序中完成那就是见着鬼了。  

 

基本信息就介绍到这儿了,我不会深入到琐碎的代码细节,而是以问答的方式展现有意义的细节。读者要先读源码,并时刻对比下面这张图:

 

 

Q1:Redis是先处理FileEvent,还是先处理TimeEvent?
从aeProcessEvents函数可以看出是先处理FileEvent。

Q2:我看了下aeProcessEvents函数,确实是先FileEvent,再TimeEvent。但是在处理FileEvent前,还有一长串代码,那是干什么用的?
呵呵,这个说起来比较烦,我现在不知道怎么表达,你问些其他的问题吧,说不定等下就说清楚了。

Q3:如果我要延迟5秒输出“Hello, World”,该怎么办?
/* 先定义好超时处理函数 */
int print5(struct aeEventLoop *loop, long long id, void *clientData)

{
    printf("Hellow, World\n");
    return -1;                 /* 返回 -1 很重要,否则会出错 */
}

int main(void)
{
    aeEventLoop *loop = aeCreateEventLoop();
    /* 创建5秒超时事件,处理函数是print5 */
    createTimeEventProc(loop, 5000, print5, NULL, NULL);
    aeMain(loop);              /* 启动主循环 */
    aeDeleteEventLoop(loop);
    return 0;
}

Q4:如果我要每隔5秒输出“Hello, World”,又该怎么办呢?
这个好办,把Q3的代码原样拷来,只做一处修改:print5函数不返回-1,而返回5000
其实返回-1代表删掉对应的超时事件,这样只会打印一次“Hello, World”;若是返回正整数n,则代表n毫秒后再触发该事件,于是就会循环打印。注意千万不能返回0或小于-1的负数,否则会陷入死循环,这也算是个瑕疵吧。 

Q5:Redis是怎么处理超时事件的?

从图中可看出TimeEvent被组织为一个单向链表,表头指针timeEventHead保存在核心数据结构aeEventLoop中。aeMain函数在每一轮循环中都会遍历该链表,针对每个TimeEvent,先调用gettimeofday获取系统当前时间,如果它比TimeEvent中的时间要小,则说明TimeEvent还没触发,应继续前进,否则说明TimeEvent已经触发了,立即调用超时处理函数,接下来根据处理函数的返回值分两种情况讨论:
1)若处理函数返回-1,那么把这个TimeEvent删掉。
2)否则,根据返回值修改当前的TimeEvent。比如返回5000,这个TimeEvent就会在5秒后再次被触发。
好了,这个TimeEvent已经搞定了,按理说应该继续前进处理下一个TimeEvent了,但是且慢,由于情况1)我们不能由当前结点到达下一结点,于是作者就又从表头开始遍历。

这个算法不怎么好,假如我从表头开始遍历,碰到第100个结点时才发现该调用处理函数了,这样处理完之后,又得从表头开始遍历,前面的很多结点都做了无用的重复计算。作者也承认这个算法不怎么好,应该改进。

Q6:你已经说过在每一轮主循环中,是先处理FileEvent,再处理TimeEvent,如此往复。有没有可能这一轮的TimeEvent永远处理不完,从而导致后来发生的FileEvent得不到处理?
有可能,如果TimeEvent的处理函数返回0或者除 -1以外的负数,那么会再次无休止地调用这个处理函数。

Q7:如果每个TimeEvent函数都会调用aeCreateTimeEvent函数,那么会不会导致和Q6一样的问题?
不会, 由aeCreateTimeEvent函数创造的TimeEvent都不会在此轮得到处理,而是会在下一轮处理完FileEvent后再处理。这个功能是由struct aeEventLoop中的timeEventNextId成员完成的,具体怎么实现的请读者看源码,很简单。

Q8:Redis是怎么处理FileEvent的?
FileEvent和TimeEvent的处理方式差别很大,用户程序不可能去遍历文件描述符,而是在循环中调用epoll_wait系统调用。这个系统调用是阻塞式的,直到发现有文件事件触发才会返回到用户空间,进而处理FileEvent。

Q9:假设在执行epoll_wait时,一直等不到文件事件触发,那岂不是程序就一直这样阻塞着,连后面的TimeEvent相关的代码都没机会运行了?
解决这个问题有赖于epoll_wait函数可以接受一个参数,用来确定最长等待时间,如果在这段时间一直没有文件事件触发,epoll_wait不会傻傻等待,而会返回到用户空间。问题关键是如何确定这个最长等待时间:一轮循环内,在处理FileEvent之前,会先查找最近的TimeEvent,将其时间设为最长等待时间。epoll_wait在这段时间内都没有等到文件事件触发就会返回到用户空间,继而执行后面的事件处理流程。确定最长等待时间的代码在文件事件处理之前,现在你不会有Q2的疑问了。

Q10:上面的回答似乎令人信服,但是有一种特殊情况,如果TimeEvent链表为空,你如何确定最长等待时间?
这确实是个好问题。其实在主循环启动前我们要决定设不设AE_DONT_WAIT这个标志。当碰到TimeEvent为空的情况时, 如果设置了AE_DONT_WAIT,epoll_wait会立即返回,不再等待文件事件;如果没设此标志,epoll_wait会永远等待,直到有文件事件触发为止。

总结:
如果把上面10个问题搞清楚了,那么对Redis事件库已经很了解了。其实Redis对事件库的功能要求很简单,完全不需要libevent和libev中的各种复杂功能,事实上很多时候我们也并不需要多强大的库,或许下次你可以把Redis的事件库运用到你的作品中去。

移植时要注意以下几个问题:
1)ae.{c|h}实现主要的逻辑;ae_select.cae_epoll.c,ae_kqueue.c分别是三种底层实现,根据你的系统选其一即可
2)上面那些文件依赖了config.h zmalloc.{c|h},移植的话要对上面的文件做些修改。 

自己编写事件库时应注意以下问题:
1)文件读写和时间超时是两种不同的事件。我们要根据应用情况决定是处理一种,还是两者包办,甚至有没有必要处理信号事件。
2)防止饥饿现象,Q6,Q7,Q9,Q10都是关于饥饿现象的。

posted @ 2010-12-27 17:14  张万凯  阅读(2129)  评论(0编辑  收藏  举报