事件驱动模型Libev(二)

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)。

backend_mintime = 1e-3; /* epoll does sometimes return early, this is just to avoid the worst */
backend_modify  = epoll_modify;
backend_poll    = epoll_poll;

这里就可以看成是插件的模板了,在后面会修改的时候调用backend_modify在poll的时候调用backend_poll.从而统一了操作。

epoll_eventmax = 64; /* initial number of events receivable per poll */
epoll_events = (struct epoll_event *)ev_malloc (sizeof (struct epoll_event) * epoll_eventmax)   

这个就看做为是每个机制特有的部分。熟悉epoll的话,这个就不用说了。

对于select (Linux平台上的)

backend_mintime = 1e-6;
backend_modify  = select_modify;
backend_poll    = select_poll;

这个和上面一样,是相当于插件接口

vec_ri  = ev_malloc (sizeof (fd_set)); FD_ZERO ((fd_set *)vec_ri);
vec_ro  = ev_malloc (sizeof (fd_set));
vec_wi  = ev_malloc (sizeof (fd_set)); FD_ZERO ((fd_set *)vec_wi);
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);,他不是一个函数,而是一个宏定义:

((ev_watcher *)(void *)(ev))->active = ((ev_watcher *)(void *)(ev))->pending = 0;
ev_set_priority ((ev), 0);
ev_set_cb ((ev), cb_);  

这里虽然还有两个函数的调用,但是很好理解,就是设置了之前介绍的基类中 “active"表示是否激活该watcher,“pending”该监控器是否处于pending状态,“priority"其优先级以及触发后执行的动作的回调函数。

1.3 设置IO事件监控器的触发条件

在初始化监控器后,还要设置其监控监控的条件。当该条件满足时便触发该监控器上注册的触发动作。ev_io_set(&io_w,STDIN_FILENO,EV_READ);从参数边可以猜出他干了什么事情。就是设置该监控器监控标准输入上的读事件。该调用也是一个宏定义:

(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),他们分别表示事件驱动器上正监控的监控器数目以及是否在为监控器服务。

array_needsize (ANFD, anfds, anfdmax, fd + 1, array_init_zero);
wlist_add (&anfds[fd].head, (WL)w);

感兴趣的可以去看下Libev里么动态调整数组的实现。这里我们主要看整体逻辑。他的工作过程是先判断数组anfds是否还有空间再加对文件描述符fd的监控,,没有的话则调整数组的内存大小,使其大小足以容下。

这里要介绍下之前没有介绍的一个数据结构,这个没有上下文比较难理解,因此放在这里介绍。

typedef struct
{
  WL head;
  unsigned char events; /* the events watched for */
  unsigned char reify;  /* flag set when this ANFD needs reification (EV_ANFD_REIFY, EV__IOFDSET) */
  unsigned char emask;  /* the epoll backend stores the actual kernel mask in here */
  unsigned char unused;
  unsigned int egen;    /* generation counter to counter epoll bugs */
} ANFD;  /* 这里去掉了对epoll的判断和windows的IOCP*/

这里首先只用关注一个 “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或则其他文件复用机制修改系统监控的条件。这里我们把这两个主要的物理结构梳理下: anfds的结构

总结一下注册过程就是通过之前设置了监控条件IO watcher获得监控的文件描述符fd,找到其在anfds中对应的ANFD结构,将该watcher挂到该结构的head链上。由于对应该fd的监控条件有改动了,因此在fdchanges数组中记录下该fd,在后续的步骤中调用系统的接口修改对该fd监控的条件。

1.5 启动事件驱动器

一切准备就绪了就可以开始启动事情驱动器了。就是 ev_run。 其逻辑很清晰。就是

do{
    xxxx;
    backend_poll();   
    xxxx
}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也就是监控条件满足了,但是还没有触发动作的状态。

typedef struct
{
  W w;
  int events; /* the pending event set for the given watcher */
} ANPENDING;

这里 W w应该知道是之前说的基类指针。pendings就是这个类型的一个二维数组数组。其以watcher的优先级为一级下标。再以该优先级上pengding的监控器数目为二级下标,对应的监控器中的pending值就是该下标加一的结果。其定义为 ANPENDING *pendings [NUMPRI]。同anfds一样,二维数组的第二维 ANPENDING *是一个动态调整大小的数组。这样操作之后。这个一系列的操作可以认为是fd_feed的后续操作,xxx_reify目的最后都是将pending的watcher加入到这个pengdings二维数组中。后续的几个xxx_reify也是一样,等分析到那个类型的监控器类型时在作展开。 这里用个图梳理下结构。 pendings结构梳理图

最后在循环中执行宏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”的优先级模型。

 

 思路流程图

 

另外两个重要的监控器

前面通过IO监控器将Libev的整个工作流程过了一遍。中间滤过了很多与其他事件监控器相关的部分,但是整体思路以及很明晰了,只要针对其他类型的watcher看下其初始化和注册过程以及在ev_run中的安排即可。这里我们再分析另两个常用的watcher

1.分析定时器监控器

定时器在程序中可以做固定周期tick操作,也可以做一次性的定时操作。Libev中与定时器类似的还有个周期事件watcher。其本质都是一样的,只是在时间的计算方法上略有不同,并有他自己的一个事件管理的堆。对于定时器事件,我们按照之前说的顺序从ev_init开始看起。

1.1定时器监控器的初始化

定时器初始化使用 ev_init(&timer_w,timer_action);,这个过程和之前的IO类似,主要就是设置基类的active、pending、priority以及触发动作回调函数cb。

1.2设置定时器监控器的触发条件

通过 ev_timer_set(&timer_w,2,0);可以设置定时器在2秒钟后被触发。如果第三个参数不是0而是一个大于0的正整数n时,那么在第一次触发(2秒后),每隔n秒会再次触发定时器事件。

其为一个宏定义 do { ((ev_watcher_time *)(ev))->at = (after_); (ev)->repeat = (repeat_); } while (0) 也就是设置派生类定时器watcher的“at”为触发事件,以及重复条件“repeat”。

1.3将定时器注册到事件驱动器上

ev_timer_start(main_loop,&timer_w);会将定时器监控器注册到事件驱动器上。其首先 ev_at (w) += mn_now; 得到未来的时间,这样放到时间管理的堆“timers”中作为权重。然后通过之前说过的“ev_start”修改驱动器loop的状态。这里我们又看到了动态大小的数组了。Libev的堆的内存管理也是通过这样的关系的。具体这里堆的实现,感兴趣的可以仔细看下实现。这里的操作就是将这个时间权重放到堆中合适的位置。这里堆单元的结构为:

typedef struct {
    ev_tstamp at;
    WT w;
} ANHE;

其实质就是一个时刻at上挂一个放定时器watcher的list。当超时时会依次执行这些定时器watcher上的触发回调函数。

1.4定时器监控器的触发

最后看下在一个事件驱动器循环中是如何处理定时器监控器的。这里我们依然抛开其他的部分,只找定时器相关的看。在“/ calculate blocking time /”块里面,我们看到计算blocking time的时候会先:

if (timercnt) {
    ev_tstamp to = ANHE_at (timers [HEAP0]) - mn_now;
    if (waittime > to) waittime = to;
}

如果有定时器,那么就从定时器堆(一个最小堆)timers中取得堆顶上最小的一个时间。这样就保证了在这个时间前可以从backend_poll中出来。出来后执行timers_reify处理将pengding的定时器。

timers_reify中依次取最小堆的堆顶,如果其上的ANHE.at小于当前时间,表示该定时器watcher超时了,那么将其压入一个数组中,由于在实际执行pendings二维数组上对应优先级上的watcher是从尾往头方向的,因此这里先用一个数组依时间先后次存下到一个中间数组loop->rfeeds中。然后将其逆序调用ev_invoke_pending插入到pendings二维数组中。这样在执行pending事件的触发动作的时候就可以保证,时间靠前的定时器优先执行。函数 feed_reversefeed_reverse_done就是将超时的定时器加入到loop->rfeeds暂存数组以及将暂存数组中的pending的watcher插入到pengdings数组的操作。把pending的watcher加入到pendings数组,后续的操作就和之前的一样了。回依次执行相应的回调函数。

这个过程中还判断定时器的 w->repeat 的值,如果不为0,那么会重置该定时器的时间,并将其压入堆中正确的位置,这样在指定的时间过后又会被执行。如果其为0,那么调用ev_timer_stop关闭该定时器。 其首先通过clear_pending置pendings数组中记录的该watcher上的回调函数为一个不执行任何动作的哑动作。

总结一下定时器就是在backend_poll之前通过定时器堆顶的超时时间,保证blocking的时间不超过最近的定时器时间,在backend_poll返回后,从定时器堆中取得超时的watcher放入到pendings二维数组中,从而在后续处理中可以执行其上注册的触发动作。然后从定时器管理堆上删除该定时器。最后调用和ev_start呼应的ev_stop修改驱动器loop的状态,即loop->activecnt减少一。并将该watcher的active置零。

对于周期性的事件监控器是同样的处理过程。只是将timers_reify换成了periodics_reify。其内部会对周期性事件监控器派生类的做类似定时器里面是否repeat的判断操作。判断是否重新调整时间,或者是否重复等逻辑,这些看下代码比较容易理解,这里不再赘述。·

2.分析信号监控器

分析完了定时器的部分,再看下另一个比较常用的信号事件的处理。Libev里面的信号事件和Tornado.IOLoop是一样的,通过一个pipe的IO事件来处理。直白的说就是注册一个双向的pipe文件对象,然后监控上面的读事件,待相应的信号到来时,就往这个pipe中写入一个值然他的读端的读事件触发,这样就可以执行相应注册的触发动作回调函数了。

我们还是从初始化-》设置触发条件-》注册到驱动器-》触发过程这样的顺序介绍。

2.1信号监控器的初始化

ev_init(&signal_w,signal_action);这个函数和上面的一样不用说了

2.2设置信号监控器的触发条件

ev_signal_set(&signal_w,SIGINT);该函数设置了Libev收到SIGINT信号是触发注册的触发动作回调函数。其操作和上面的一样,就是设置了信号监控器私有的(ev)->signum为标记。

2.3将信号监控器注册到驱动器上

这里首先介绍一个数据结构:

typedef struct
{
  EV_ATOMIC_T pending;
  EV_P;
  WL head;
} ANSIG;
static ANSIG signals [EV_NSIG - 1];

EV_ATOMIC_T pending;可以认为是一个原子对象,对他的读写是原子的。一个表示事件驱动器的loop,以及一个watcher的链表。

ev_signal_start中,通过signals数组存储信号监控单元。该数组和anfds数组类似,只是他以信号值为索引。这样可以立马找到信号所在的位置。从 Linux 2.6.27以后,Kernel提供了signalfd来为信号产生一个文件描述符从而可以用文件复用机制epoll、select等来管理信号。Libev就是用这样的方式来管理信号的。 这里的代码用宏控制了。其逻辑大体是这样的

#if EV_USE_SIGNALFD
    res = invoke_signalfd
# if EV_USE_SIGNALFD
if  (res is not valied)
# endif
{
    use evpipe to instead
}

这个是框架。其具体的实现可以参考使用signalfd和evpipe_init实现。其实质就是通过一个类似于管道的文件描述符fd,设置对该fd的读事件监听,当收到信号时通过signal注册的回调函数往该fd里面写入,使其读事件触发,这样通过backend_poll返回后就可以处理ev_init为该信号上注册的触发回调函数了。

在函数evpipe_init里面也用了一个可以学习的技巧,和上面的#if XXX if() #endif {} 一样,处理了不支持eventfd的情况。eventfd是Kernel 2.6.22以后才支持的系统调用,用来创建一个事件对象实现,进程(线程)间的等待/通知机制。他维护了一个可以读写的文件描述符,但是只能写入8byte的内容。但是对于我们的使用以及够了,因为这里主要是获得其可读的状态。对于不支持eventfd的情况,则使用上面说过的,用系统的pipe调用产生的两个文件描述符分别做读写对象,来完成。

2.4信号事件监控器的触发

在上面设置信号的pipe的IO事件是,根据使用的机制不同,其实现和触发有点不同。对于signalfd。

ev_io_init (&sigfd_w, sigfdcb, sigfd, EV_READ);  /*  for signalfd */
ev_set_priority (&sigfd_w, EV_MAXPRI);
ev_io_start (EV_A_ &sigfd_w);

也就是注册了sigfdcb函数。该函数:

ssize_t res = read (sigfd, si, sizeof (si));
for (sip = si; (char *)sip < (char *)si + res; ++sip)
    ev_feed_signal_event (EV_A_ sip->ssi_signo);

首先将pipe内容读光,让后续的可以pengding在该fd上。然后对该signalfd上的所有信号弟阿勇ev_feed_signal_event吧每个信号上的ANSIG->head上挂的watcher都用ev_feed_event加入到pendings二维数组中。这个过程和IO的完全一样。

而对于eventfd和pipe则是:

  ev_init (&pipe_w, pipecb); 
  ev_set_priority (&pipe_w, EV_MAXPRI);      
  ev_io_set (&pipe_w, evpipe [0] < 0 ? evpipe [1] : evpipe [0], EV_READ);
  ev_io_start (EV_A_ &pipe_w);

pipe_w是驱动器自身的loop->pipe_w。并为其设置了回调函数pipecb:

#if EV_USE_EVENTFD
    if (evpipe [0] < 0)
    {
    uint64_t counter;
    read (evpipe [1], &counter, sizeof (uint64_t));
    }
    else
#endif
    {
        char dummy[4];      
        read (evpipe [0], &dummy, sizeof (dummy));

    }
...
xxx
...
for (i = EV_NSIG - 1; i--; )
    if (expect_false (signals [i].pending))
        ev_feed_signal_event (EV_A_ i + 1);

这里将上面的技巧#if XXX if() #endif {}拓展为了#if XXX if() {} else #endif {} 。这里和上面的操作其实是一样的。后续操作和signalfd里面一样,就是读光pipe里面的内容,然后依次将watcher加入到pendings数组中。

 

 

其他监控器

最主要的几个监控器搞定了。其他的我觉得比较可以看的还有ev_child和ev_stat。其实和之前的三个基本原理的是一样。暂不赘述。未来可能补充。

Libev中的Tips

如果将Libev当成组件去用的话。官方文档是一份很好的选择。这里说下看Libev过程中的感受。

如果使用Libev但又觉得它没有提供必要的功能而要去该其代码。可能Libuv为我们做了一个很好的示例。Libuv之前是用Libev作为其底层事件库。后来作者重写了自己的一套网络库Libuv。严格意义上说,Libev仅仅是一个事件模型框架,并不能算上是一个完整的网络库,正因为如此他才提供了如此多的事件类型。而对于网络库可能最重要的就是定时器、IO、以及信号事件。当然网络还包括了socket、收发控制等内容。因此,我的感觉是可以将Libev当成一个很好的学习对象,不论是其设计思想、还是代码中个各种小tips、还有其对跨平台支持的方法都是很好的示例。虽然用宏包裹的比较严密,只要稍加分析,理清其思路还是比较容易的。

将Libev和之前的Redis-ae进行对比。可以发现Libev在设计思想上更完整,提供的服务也更全,但是做的检测多了,逻辑复杂了,消耗的资源也必定比简单的封装更多。从这个两个模型可以看出事件模型的框架都是:

取得一个合适的时间,用这个时间去poll。然后标记poll之后pending的文件对象。poll出来后判断定时器然后统一处理pending对象

这里绘制一个整体的结构图,不是很规范UML或者其他什么学术的图,只是一个帮助理解的过程:

整体结构图

至此Libev的分析差不多完成了,主要去了解实现的思路。具体如何实现以及从什么样的角度去设计。其结果需要在生产环境中去检验。

 

 

转载:http://my.oschina.net/u/917596/blog/177573

 

 

 

posted on 2016-01-12 20:07  小桌子  阅读(647)  评论(0编辑  收藏  举报

导航