Libev设计思路
理清了Libev的代码结构和主要的数据结构,就可以跟着示例中接口进入到Libev中,跟着代码了解其设计的思路。这里我们管struct ev_loop
称作为事件循环驱动器而将各种watcher称为事件监控器。
1.分析例子中的IO事件
这里在前面的例子中我们先把定时器和信号事件的使用注释掉,只看IO事件监控器,从而了解Libev最基本的逻辑。可以结合Gdb设断点一步一步的跟看看代码的逻辑是怎样的。
我们从main开始一步步走。首先执行 struct ev_loop *main_loop = ev_default_loop(0);
通过跟进代码可以跟到函数ev_default_loop
里面去,其主要逻辑,就是全局对象指针ev_default_loop_ptr若为空,也就是不曾使用预制的驱动器时,就让他指向全局对象default_loop_struct,同时在本函数里面统一用名字"loop"来表示该预制驱动器的指针。从而与函数参数为EV_P
以及 EV_A
的写法配合。接着对该指针做 loop_init
操作,即初始化预制的事件驱动器。这里函数的调用了就是用到了EV_A_
这样的写法进行简化。初始化之后如果配置中Libev支持子进程,那么通过信号监控器实现了子进程监控器。这里可以先不用去管他,知道这段代码作用即可。 这里再Libev的函数定义的时候,会看到 “EV_THROW” 这个东西,这里可以不用管它,他是对CPP中"try … throw"的支持,和EV_CPP(extern "C" {)
这样不同寻常的 extern “C” 一样是一种编码技巧。现在我们以分析设计思路为主。在了解了总体后,可以再对其编码技巧进行梳理。否则的话看一份代码会非常吃力,而且速度慢。甚至有的时候这些“hacker”并不一定是有益的。
1.1驱动器的初始化
下面看下驱动器的初始化过程中都做了哪些事情。首先最开始的一段代码判断系统的clock_gettime是否支持CLOCK_REALTIME和CLOCK_MONOTONIC。这两种时间的区别在于后者不会因为系统时间被修改而被修改,详细解释可以参考man page 。接着判断环境变量对驱动器的影响,这个在官方的Manual中有提到,主要就是影响默认支持的IO复用机制。接着是一连串的初始值的赋值,开始不用了解其作用。在后面的分析过程中便可以知道。接着是根据系统支持的IO复用机制,对其进行初始化操作。这里可以去"ev_epoll.c” 和"ev_select.c"中看一下。 最后是判断如果系统需要信号事件,那么通过一个PIPE的IO事件来实现,这里暂且不用管他,在理解了IO事件的实现后,自然就知道这里他做了什么操作。
对于"ev_epoll.c” 和"ev_select.c"中的 xxx_init
其本质是一致的,就像插件一样,遵循一个格式,然后可以灵活的扩展。对于epoll主要就是做了一个 epoll_create*的操作(epoll_create1可以支持EPOLL_CLOEXEC)。
1 |
backend_mintime = 1e- 3 ; |
2 |
backend_modify = epoll_modify; |
3 |
backend_poll = epoll_poll; |
这里就可以看成是插件的模板了,在后面会修改的时候调用backend_modify在poll的时候调用backend_poll.从而统一了操作。
2 |
epoll_events = (struct epoll_event *)ev_malloc (sizeof (struct epoll_event) * epoll_eventmax) |
这个就看做为是每个机制特有的部分。熟悉epoll的话,这个就不用说了。
对于select (Linux平台上的)
1 |
backend_mintime = 1e- 6 ; |
2 |
backend_modify = select_modify; |
3 |
backend_poll = select_poll; |
这个和上面一样,是相当于插件接口
1 |
vec_ri = ev_malloc (sizeof (fd_set)); FD_ZERO ((fd_set *)vec_ri); |
2 |
vec_ro = ev_malloc (sizeof (fd_set)); |
3 |
vec_wi = ev_malloc (sizeof (fd_set)); FD_ZERO ((fd_set *)vec_wi); |
4 |
vec_wo = ev_malloc (sizeof (fd_set)); |
同样,这个是select特有的,表示读和写的fd_set的vector,ri用来装select返回后符合条件的部分。其他的如poll、kqueue、Solaris port都是类似的,可以自行阅读。
1.2IO监控器的初始化
上面的过程执行完了ev_default_loop过程,然后到后面的ev_init(&io_w,io_action);
,他不是一个函数,而是一个宏定义:
1 |
((ev_watcher *)( void *)(ev))->active = ((ev_watcher *)( void *)(ev))->pending = 0 ; |
2 |
ev_set_priority ((ev), 0 ); |
这里虽然还有两个函数的调用,但是很好理解,就是设置了之前介绍的基类中 “active"表示是否激活该watcher,“pending”该监控器是否处于pending状态,“priority"其优先级以及触发后执行的动作的回调函数。
1.3 设置IO事件监控器的触发条件
在初始化监控器后,还要设置其监控监控的条件。当该条件满足时便触发该监控器上注册的触发动作。ev_io_set(&io_w,STDIN_FILENO,EV_READ);
从参数边可以猜出他干了什么事情。就是设置该监控器监控标准输入上的读事件。该调用也是一个宏定义:
1 |
(ev)->fd = (fd_); (ev)->events = (events_) | EV__IOFDSET; |
就是设置派生类IO监控器特有的变量fd和events,表示监控那个文件fd已经其上的可读还是可写事件。
%TODO:补上EV_IOFDSET的作用
1.4注册IO监控器到事件驱动器上
准备好了监控器后就要将其注册到事件驱动器上,这样就形成了一个完整的事件驱动模型。 ev_io_start(main_loop,&io_w);
。这个函数里面会第一次见到一个一个宏 “EV_FREQUENT_CHECK”,是对函数 “ev_verify"的调用,那么ev_verify是干什么的呢?用文档的话“This can be used to catch bugs inside libev itself”,如果看其代码的话,就是去检测Libev的内部数据结构,判断各边界值是否合理,不合理的时候assert掉。在生产环境下,我觉得根据性格来对待。如果觉得他消耗资源(要检测很多东西跑很多循环)可以编译的时候关掉该定义。如果需要assert,可以在编译的时候加上选项。
然后看到 ev_start
调用,该函数实际上就是给驱动器的loop->activecnt增一并置loop->active为真(这里统一用loop表示全局对象的预制驱动器对象default_loop_struct),他们分别表示事件驱动器上正监控的监控器数目以及是否在为监控器服务。
1 |
array_needsize (ANFD, anfds, anfdmax, fd + 1 , array_init_zero); |
2 |
wlist_add (&anfds[fd].head, (WL)w); |
感兴趣的可以去看下Libev里么动态调整数组的实现。这里我们主要看整体逻辑。他的工作过程是先判断数组anfds是否还有空间再加对文件描述符fd的监控,,没有的话则调整数组的内存大小,使其大小足以容下。
这里要介绍下之前没有介绍的一个数据结构,这个没有上下文比较难理解,因此放在这里介绍。
这里首先只用关注一个 “head” ,他是之前说过的wather的基类链表。这里一个ANFD就表示对一个文件描述符的监控,那么对该文件描述的可读还是可写监控,监控的动作是如何定义的,就是通过这个链表,把对该文件描述法的监控器都挂上去,这样就可以通过文件描述符找到了。而前面的说的anfds就是这个对象的数组,下标通过文件描述符fd进行索引。在Redis-ae那篇文章中已经讨论过这样的可以达到O(1)的索引速度而且空间占用也是合理的。
接着的“fd_change”与“fd_reify”是呼应的。前者将fd添加到一个fdchanges的数组中,后者则依次遍历这个数组中的fd上的watcher与anfds里面对饮的watcher进行对比,判断监控条件是否改变了,如果改变了则调用backend_modify也就是epoll_ctl等调整系统对该fd的监控。这个fdchanges数组的作用就在于此,他记录了anfds数组中的watcher监控条件可能被修改的文件描述符,并在适当的时候将调用系统的epoll_ctl或则其他文件复用机制修改系统监控的条件。这里我们把这两个主要的物理结构梳理下:
总结一下注册过程就是通过之前设置了监控条件IO watcher获得监控的文件描述符fd,找到其在anfds中对应的ANFD结构,将该watcher挂到该结构的head链上。由于对应该fd的监控条件有改动了,因此在fdchanges数组中记录下该fd,在后续的步骤中调用系统的接口修改对该fd监控的条件。
1.5 启动事件驱动器
一切准备就绪了就可以开始启动事情驱动器了。就是 ev_run
。 其逻辑很清晰。就是
5 |
} while (condition_is_ok) |
循环中开始一段和fork 、 prepare相关这先直接跳过,到分析与之相关的监控事件才去看他。直接到 /* calculate blocking time */
这里。熟悉事件模型的话,这里还是比较常规的。就是从定时器堆中取得最近的时间(当然这里分析的时候没有定时器)与loop->timeout_blocktime比较得到阻塞时间。这里如果设置了驱动器的io_blocktime,那么在进入到poll之前会先sleep io_blocktime时间从而等待IO或者其他要监控的事件准备。这里进入到backend_poll中的阻塞时间是包括了io_blocktime的时间。然后进入到backend_poll中。对于epoll就是进入到epoll_wait里面。
epoll(或者select、kqueue等)返回后,将监控中的文件描述符fd以及其pending(满足监控)的条件通过 fd_event
做一个监控条件是否改变的判断后到fd_event_nocheck
里面对anfds[fd]数组中的fd上的挂的监控器依次做检测,如果pending条件符合,便通过ev_feed_event
将该监控器加入到pendings数组中pendings[pri]上的pendings[pri][old_lenght+1]的位置上。这里要介绍一个新的数据结构,他表示pending中的wather也就是监控条件满足了,但是还没有触发动作的状态。
这里 W w
应该知道是之前说的基类指针。pendings就是这个类型的一个二维数组数组。其以watcher的优先级为一级下标。再以该优先级上pengding的监控器数目为二级下标,对应的监控器中的pending值就是该下标加一的结果。其定义为ANPENDING *pendings [NUMPRI]
。同anfds一样,二维数组的第二维 ANPENDING *
是一个动态调整大小的数组。这样操作之后。这个一系列的操作可以认为是fd_feed的后续操作,xxx_reify目的最后都是将pending的watcher加入到这个pengdings二维数组中。后续的几个xxx_reify也是一样,等分析到那个类型的监控器类型时在作展开。
这里用个图梳理下结构。
最后在循环中执行宏EV_INVOKE_PENDING
,其实是调用loop->invoke_cb,如果没有自定义修改的话(一般不会修改)就是调用ev_invoke_pending
。该函数会依次遍历二维数组pendings,执行pending的每一个watcher上的触发动作回调函数。
至此一次IO触发过程就完成了。
2总结出Libev的设计思路
在Libev中watcher要算最关键的数据结构了,整个逻辑都是围绕着watcher做操作。Libev内部维护一个基类ev_wathcer和若干个特定监控器的派生类ev_xxx。在使用的时候首先生成一个特定watcher的实例。并通过该派生对象私有的成员设置其触发条件。然后用anfds或者最小堆管理这些watchers。然后Libev通过backend_poll以及时间堆管理运算出pending的watcher。然后将他们加入到一个以优先级为一维下标的二维数组。在合适的时间依次调用这些pengding的watcher上注册的触发动作回调函数,这样便可以按优先级先后顺序实现“only-for-ordering”的优先级模型。