定时是指在一段时间之后触发某段代码的机制,我们可以在这段代码中依次处理所有到期的定时器。换言之,定时机制是定时器得以被处理的原动力。Linux 提供了三种定时方法:
SO_RCVTIMEO
和 SO_SNDTIMEO
;SIGALRM
信号;SO_RCVTIMEO
和 SO_SNDTIMEO
这两个选项仅对与数据接收和发送相关的 socket 专用系统调用有效,如:send、sendmsg、recv、recvmsg、accept 和 connect。
系统调用 | 有效选项 | 系统调用超时后的行为 |
---|---|---|
send | SO_SENDTIMEO | 返回-1,设置 errno 为 EAGAIN 或 EWOULDBLOCK |
sendmsg | SO_SENDTIMEO | 返回-1,设置 errno 为 EAGAIN 或 EWOULDBLOCK |
recv | SO_RCVTIMEO | 返回-1,设置 errno 为 EAGAIN 或 EWOULDBLOCK |
recvmsg | SO_RCVTIMEO | 返回-1,设置 errno 为 EAGAIN 或 EWOULDBLOCK |
accept | SO_RCVTIMEO | 返回-1,设置 errno 为 EAGAIN 或 EWOULDBLOCK |
connect | SO_SENDTIMEO | 返回-1,设置 errno 为 EINPROGRESS |
在程序中,我们可以根据系统调用的返回值以及 errno 来判断超时时间是否已到,进而决定是否开始处理定时任务。
SIGALRM
信号由 alarm 和 setitimer 函数设置的实时闹钟一旦超时,将触发 SIGALRM 信号。如果要处理多个定时任务,就需要不断地触发 SIGALRM 信号,并在其信号处理函数中执行到期的任务。一般而言,SIGALRM 信号按照固定的频率生成,由 alarm 和 setitimer 函数设置的定时周期 T 保持不变。如果某个定时任务的超时时间不是 T 的整数倍,那么实际被执行的时间会与预期有偏差。因此 T 反映了定时的精度。
定时器通常包括两个成员:超时时间和任务回调函数。有的时候还包括回调函数的参数以及是否重启定时器等信息。
链表作为容器的话还需要指向上一个或下一个定时器的指针成员。
// P196 + P200
Linux 下的三组 I/O 复用系统调用都带有超时参数,因此它们不仅可以统一处理信号和 I/O 事件,也能统一处理定时事件。但是由于 I/O 复用系统调用可能在超时时间到期之前就返回(有 I/O 事件发生),所以如果要利用它们来定时,就需要不断更新定时参数以反映剩余的时间。
#define TIMEOUT 5000
int timeout = TIMEOUT;
time_t start = time(NULL);
time_t end = time(NULL);
while(1){
start = time(NULL);
int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, timeout);
if((number < 0) && (errno != EINTR)){
// epoll failure
break;
}
if(number == 0){
// 如果返回0,则说明超时时间已到,此时可以处理超时任务,并重置定时时间
timeout = TIMEOUT;
continue;
}
end = time(NULL);
// 更新定时参数
timeout -= (end - start) * 1000;
if(timeout <= 0){
// 处理超时任务,并重置定时时间
timeout = TIMEOUT;
}
}
基于排序链表的定时器存在一个问题:添加定时器的效率偏低,时间轮解决了这个问题。
指针指向轮子上的一个槽,它以恒定的速度顺时针转动,每转动一步就指向下一个槽,每次转动称为一个滴答(tick)。一个滴答的时间称为时间轮的槽间隔 si,转动一周的时间是 N × si。每个槽指向一条定时器链表,每条链表上的定时器具有相同的特征:**它们的定时时间相差 N × si 的整数倍。**时间轮正是利用这个关系将定时器散列到不同的链表中。
计算待插入的定时器应该被插入到哪个槽中:
假如现在指针指向槽 cs,要添加一个定时时间为 ti 的定时器,则该定时器将被插入槽 ts 对应的链表中:ts = (cs + (ti / si)) % N
。
对于时间轮而言:
要提高定时精度,就要使 si 足够小;
要提高执行效率,就要使 N 足够大;
添加定时器的时间复杂度是:O(1)
删除定时器的时间复杂度是:O(1)
执行定时任务的时间复杂度是:O(n)
// P207
时间堆的特殊之处:不再使用固定的频率调用 tick,而是将所有定时器中超时时间最小的一个定时器的超时值作为心搏间隔。
一旦 tick 被调用,超时时间最小的定时器一定到期,就可以在 tick 函数中处理该定时器。然后,再次从剩余的定时器中找出超时时间最小的一个,并将这段最小时间设置为下一次心搏间隔。如此反复,就实现了较为精确的定时。
由于最小堆是一种完全二叉树,所以可以用数组来组织其中的元素。对于数组中的任意位置 i 上的元素,其左儿子节点在位置 2i+1 上,其右儿子节点在位置 2i+2 上,其父节点在位置 [(i - 1) / 2] (i > 0) 上。与用链表来表示堆相比,用数组表示堆不仅节省空间,而且更容易实现堆的插入、删除等操作。
// P212