反应式处理超时事件

网络化应用常常以反应式模型为基础,它们在其中响应各种类型的事件,比如I/O活动、到期的定时器或是信号。 ACE Reactor(反应器)框架实现了Reactor模式,具有事务分离、模块化、可移植性高等优点。但是在某些限制使得ACE库不可用时(编程语言:C+ +,空间:ACE 5.5 gcc编译出的非调试版共享库约1.3M),就没法享受ACE带来的好处了。撇开框架不谈,ACE Reactor的一大贡献在于把定时器到期处理和其他事件(网络I/O)处理集成起来。本文专注于以C语言开发一个上述功能的库,期望可复用到今后的 Linux网络化应用程序开发,期望其具有通用性、使用方便、小巧这三方面优点。(注:Linux内核的定时器与本文无关,本文仅关注应用层。)

《C++NPv2》介绍ACE定时器队列类ACE_Timer_Queue时,对开发定时器队列的必要性作了精彩的介绍:
许多网络化应用程序周期性地进行各种活动,或必须在过了规定时段之后收到通知。例如,Web服务器需要“看门狗”定时器,如果客户不在连接后很快发送HTTP GET请求,则释放各种资源。
在许多平台上,本地的OS定时器能力各有不同,但它们都有一些相同的问题:
(1) 定时器数目有限。 许多平台允许应用设置的定时器数目有限。例如,POSIX alarm()和ualarm()系统函数会在每次调用时各自复位一个“闹钟”(Alarm Clock)定时器。因此,管理多个定时器时段常常涉及到定时器队列机制的开发,以对下一个被调度的“到期”进行追踪。如果有必要,调度一个新的“最早的”定时器可以设置或复位闹钟。
(2)定时器到期会引发信号。例如,alarm()系统函数在定时器到期时引发SIGALRM信号。对定时器信号进行编程十分困难,因为应用动作被限制在信号的上下文中。通过使用 sleep()系统函数,或是sigsuspend()系统函数,UNIX平台上的应用可以使在信号上下文中的处理最小化。但是,这些解决方案是不可移植的,并且它们会阻塞调用线程,从而可能妨碍并发,并使编程复杂化。
避免这些问题的一种途径是在事件处理的正常过程中对定时器进行管理,如下所示:
(1)开发定时器队列机制,对定时器进行排序,并使每个定时器与定时器到期时要执行的动作关联起来。
(2)集成“定时器队列”与“应用对同步事件多路分离器(比如select()或WaitForMultipleObject())的使用”,以包定时器到期处理与其他时间处理进程起来。
但是,由于各种能力和限制都具有广泛性,要开发这种类型的、能跨越OS平台移植的定时器机制十分困难。而且,由于定时器队列机制与同步时间多路分离机制之间的紧密耦合,开发者常常会为许多项目重复开发这种能力。为了减少应用开发者对“以一种特殊方式重新编写高效、可伸缩以及可移植的时间驱动的分派器”的需要,ACE Reactor框架定义了可复用的定时器队列类族。

本文参考了ACE定时器队列类实现之一ACE_Timer_Heap。 ACE资料指出:“对于事件处理器的调度、取消和到期来说,其一般和最坏情况的性能都是O(lg n),这也是它之所以是ACE Reactor框架的缺省定时器机制的原因。对于其应用需要可预测、低响应延迟的时间驱动分派的操作系统和中间件来说,基于堆的定时器队列是有用的。”

本方案的功能特性:
(1)将系统函数select(),以及自动化的定时器的到期处理集成到一个单一的函数st_select(),使得其成为一个时间驱动的分派器,能够统一处理普通IO以及定时器到期。
(2)支持多线程环境。可以在一个线程中循环调用st_select(),而其他线程可以增加、删除、修改定时器。
(3)一个进程内可以使用多个定时器队列而互不干扰。
(4)定时器按照其回调被分派的次数可以分为一次性、周期性两类。本方案直接支持一次性定时器,还可以利用回调函数返回值实现对周期性定时器的间接支持。
(5)允许为每个定时器队列配置时间源为两个中的一个。两者的主要区别在于是否受修改系统日期/时间的影响。
(6)基于st_系列API实现了一个定时器线程。此线程使用内部的匿名的定时器队列。应用可以组合使用st_系列API以及定时器线程。

(1)定时器队列的初始化与销毁
typedef long TimerId;
typedef struct SyncTimerQueue{
  TimerQueue queue;
  int internal_sock[2];
  pthread_mutex_t queue_mutex;
  pthread_t locking_owner;
  int locking_count;
  TimerId current_timer;
  TimeSourceFunc gettime;
} SyncTimerQueue;
void st_init(SyncTimerQueue *s_queue, long initial_max_size, int realtime);
void st_destroy(SyncTimerQueue *s_queue);

定时器ID类型TimerId对应用而言是透明的。合法的定时器ID都是某种类型的非负整数。
“定时器队列“类型SyncTimerQueue的定义对应用是透明的,除了current_timer成员。current_timer成员表示当前到期的定时器的ID。由于多个定时器的callback可能使用同一个函数,此回调函数有必要知道是哪个定时器到期导致本函数被调用的。而current_timer成员就是用于传达这一信息。
在第一次使用一个SyncTimerQueue之前,务必用st_init()初始化它,否则其他操作函数(st_select()等)的行为不可预期。参数initial_max_size传达了定时器队列最初的容量(即容纳定时器的数目)。这一容量可以按需增长直到TIMTERS_MAX(当前设置为65536)。参数realtime传达了定时器使用哪个时间源。0表示使用gettimeofday()查询当前时间,1表示使用POSIX:TMR扩展规定的REALTIME时钟查询当前时间。两者的主要区别在于前者受到修改系统日期和/或时间的影响,而后者不会。虽然REALTIME时钟的时间刻度是ns级别的粒度,但是据ACE文档所称,如此细粒度的定时器并没有任何优势。
在不需要定时器队列时用st_destroy()销毁它,这将取消所有未到期的定时器。
所有以“st_”打头的API,内部都借助互斥锁queue_mutex成员保护临界数据,回调函数执行也是被此锁保护的。如果采用默认类型的互斥锁,回调函数调用st_cancel()等函数就会造成死锁。为了避免这一情况,必须使用递归互斥锁。由于本人Linux开发环境尚不支持递归互斥锁,所以在本文所开发的库内部模拟了递归互斥锁。
由于刻意避免了使用全局变量,一个进程内可以使用多个定时器队列而互不干扰。这个特性是非常有价值的。如果允许一个进程仅使用一个SyncTimerQueue,那么如下两个问题就难以解决:[1]基于st_select()实现一个定时器线程,同时允许应用使用另一个定时器队列。[2]有的回调函数占用执行时间过长(例如调用connect()建立TCP连接在失败情形下可能花费10多秒),并且要求的回调间隔并不严格,这类定时器不应当影响应用使用的那个主要的定时器队列。

(2)定时器的调度与删除
typedef long TimerId;
typedef long (*TimedCallback)(void *data);
typedef void (*FreeCallback)(void *data);
TimerId st_schedule(SyncTimerQueue *s_queue, struct timeval* timeout_abs, TimedCallback fn_cbk, void *data, FreeCallback fn_free);
TimerId st_schedule_relative(SyncTimerQueue *s_queue, struct timeval* delay, TimedCallback fn_cbk, void *data, FreeCallback fn_free);
int st_cancel(SyncTimerQueue *s_queue, TimerId timer_id);
void st_cancel_all(SyncTimerQueue *s_queue);

st_schedule ()调度一个定时器。在绝对时间timeout_abs到期时,将执行回调函数fn_cbk。void指针data由定时器队列存储在内部,并在fn_cbk和fn_free被分派时不作变动的传回。fn_cbk的返回值有特殊的含义:如果为0或负数则此定时器将被自动删除,否这此定时器的到期时间点将被自动调整为原来值加上返回值所指定的秒数。所以,可以利用fn_cbk的返回值实现周期性的定时器。一个定时器被删除时将执行回调函数fn_free。

st_schedule_relative()与st_schedule()功能相同,不同之处仅在于前者的超时点使用相对时间,而后者使用绝对时间。
st_cancel()删除指定的定时器。这将执行此定时器对应的回调函数fn_free。st_cancel_all()取消所有的定时器。

(3)定时器到期时间点的调整
int st_reschedule(SyncTimerQueue *s_queue, TimerId timer_id, struct timeval *abs);
int st_reschedule_relative(SyncTimerQueue *s_queue, TimerId timer_id, struct timeval *rela);
int st_reschedule_delay(SyncTimerQueue *s_queue, TimerId timer_id, struct timeval *delay);

应用还可以借助st_reschedule系列API主动地调整一定时器的超时点。这三个函数将超时点分别调整为指定时间点、系统当前时间之后rela所指时间、原来的超时点之后delay所指时间。注意:回调函数无法使用上面的某个函数修改当前定时器的到期时间点,因为其返回值决定了当前定时器的删除与否以及到期点如何调整。

(4)时间驱动的分派器
int st_select(int nfs,fd_set *readfds, fd_set *writefds,fd_set *exceptfds,
          struct timeval *timeout, SyncTimerQueue *s_queue);

st_select()扩展了POSIX函数select(),使得定时器队列的超时处理与网络IO的处理集成到统一的框架中。st_select()返回值以及前5个参数的含义与select()函数的保持一致。最后一个参数是分派器使用的定时器队列。
如果传入的定时器队列指针为NULL,则等价于以前5个参数调用函数select()。否则相当于以前5个参数调用函数select(),同时自动处理定时器队列中所有到期的定时器。定时器的回调函数可以利用如下几种方式影响st_select()的行为:[1]返回0或负数表示删除本定时器;[2]返回正数表示重新调度本定时器,新的到期时间点为原来的到期时间点加上返回值(以s计);[3]调用st_notify_unlocked()发送打断通知使得st_select()立即返回。
st_select()内部首先将相对时间timeout(如果不为NULL)转化为绝对时间,然后直到这个时间点到来,循环地调用系统函数select(),并使用最早的定时器到期时间限制select()等待I/O事件的时间长度。如果最早的定时器到期,则分派其对应的回调函数,然后进入下一轮循环。如果I/O事件到来,则st_select()立即返回。如果收到打断通知则设置errno为EINTR并返回-1。
注意:一般不应当在多个线程中同时对同一组插口或同一个定时器队列调用st_select(),因为这可能导致超时事件处理、IO处理的乱序。

(5)通知
void st_notify(SyncTimerQueue *s_queue, int interrupt);

定时器队列拥有一种通知机制。这种通知机制在多线程应用中是非常重要的,因为它避免了死等或者等待时间过长。如果st_select()当前阻塞在select(),一个通知将打断它。如果interrupt为0,则st_select()进入下一轮迭代继续等待。如果interrupt为1,则st_select()返回-1并设置errno为EINTR。所有涉及定时器队列变化(定时器增、删、改)的函数内部已正确处理通知,所以应用程序没必要在这些变化之后调用本函数。
情形1:线程1循环调用st_select()以反应式地处理网络 I/O和定时器到期。最近的定时器超时点在60s之后。线程2调用 st_schedule()插入了一个新的定时器,其超时点在2s之后。线程1必须立即调整等待时间为2s而不是继续等待。
情形2:线程2如何优雅并且高效地结束线程1呢?一个方案就是线程1中的while循环以一个全局变量g_quit为循环继续与否的标记;线程2在设置g_quit之后调用st_notify(s_queue,1)使得线程1中的st_select()立即返回并且跳出while循环。
定时器队列的通知机制是通过SyncTimerQueue结构内部的UNIX域插口实现的。 st_select()内部调用的select()所监听的可读描述符集合由原来的readfds和一个内部的UNIX域插口构成。而st_notify()使用与此内部插口相连的另一个内部插口,发送数据以通知分派器。

(6)定时器线程
某些情形下,在一个独立的线程中执行超时处理可以避免对正常处理的干扰。基于上面的st_select()等函数,本文进一步开发出了这样一个定时器线程。它提供如下API:
pthread_t start_timer_thread(int realtime);
void stop_timer_thread();
TimerId set_timed_cbk(long sec,TimedCallback fn_cbk,void* data,FreeCallback fn_free);
int remove_timed_cbk(TimerId timer_id);


示例:
[协议规格]
考察一下某协议的会话建立和会话过程中Server端如何使用定时器。
某简单的自定义协议支持双向音频会话,并且符合Client/Server模型。Server必须支持同时与多个Client的音频会话,Server不使用组播技术。会话信令和媒体流共用相同的UDP端口:Server在自身IP和某知名UDP端口监听会话信令、返回响应、接收和发送音频流。Client 使用自身IP和某随机端口发起会话、接收和发送音频流。
建立音频会话的正常过程:
(1)Client发送Audio-Setup-Request,包含期望的音频会话类型、音频编码格式、用户名、密码
(2)Server如果接受此呼叫,则返回Audio-Setup-Accept,否则返回Audio-Setup-Reject
(3)Client收到Audio-Setup-Accept或Audio-Setup-Reject,返回Audio-Setup-ACK。如果收到的是前者,则会话建立完成,开始双向传输音频流;否则会话终止。
(4)Server收到Audio-Setup-ACK后,如果此前返回的是Audio-Setup-Accept,则会话建立完成,开始双向传输音频流;如果此前返回的是Audio-Setup-Reject,则会话终止。
Server端的时间性规定:
(1)返回Audio-Setup-Accept或Audio-Setup-Reject后,如果再次收到同一Client发来的Audio-Setup-Request,认为是收到了重发的请求,此时Server重发响应。
(2)最后一次返回Audio-Setup-Accept或Audio-Setup-Reject到接收到Audio-Setup-ACK,时间不得超过3s。
(3)会话期间,若10s内接收不到任何音频包,则终止本次会话。

[设计思路]
Server可由main和media_process共2个线程构成:main线程负责协议信令报文的收发、会话状态的维护、接收各Client发来的音频流。 media_process线程负责从DSP读取音频数据并发送给各Client。main线程通过以下函数控制media_process线程:
(1)start_media_process()/stop_media_process()启动和停止媒体流线程。
(2)set_media_socket()设置媒体流线程使用的插口。
(3)add_audio_dest()/remove_audio_dest()函数操控媒体流线程发送音频流的目的地列表。
每个音频会话都有一个定时器,其操作如下:
(1)在每次发送(无论是第一次还是重复)Audio-Setup-Accept或Audio-Setup-Reject后,设置3s超时。
(2)收到Audio-Setup-ACK后设置10s周期性超时。每个会话都有一个计数器recv_audio_per_interval,其含义是10s内 Server收到的音频报文数目,并且在每次10s到期时清0,每次收到音频报文时加1。如果规定“前后两次收到的音频报文时间间隔不超过10s”,那么每次收到音频报文时都需要记录当前时间或者修改定时器,显然这样规定导致的实现没有效率。
音频会话具有以下状态:
typedef enum{
  AUDIO_SESSION_REJECTED,
  AUDIO_SESSION_ACCEPTED,
  AUDIO_SESSION_ESTABLISHED,
  AUDIO_SESSION_END
} ASession_State;

所有定时器使用如下回调函数:
static long cbk_timer_timeout(void *data);
此函数根据data获取会话信息,并进一步得知会话的状态,然后执行状态跃迁动作。在更复杂的情形下,一个会话可以具有多个定时器。这时可根据定时器队列的current_timer成员判断出当前到期的定时器ID,然后根据上述获得的(会话,状态,定时器ID)执行状态跃迁动作。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值