在以前的一次面试中,我被问到该如何实现一个定时器,当时我并没有思考过这个问题,所以回答的并不令人满意。在日常的开发中,一个需要定时执行的任务是很常见的。我认为这个问题比较有意义,也非常具有代表性。于是,我查阅了一些资料,并整理出这篇博客。

使用linux信号触发定时任务

可以使用操作系统API预订一个 SIGALRM 信号,在指定的时间后程序就能收到信号,在信号的处理函数中就能实现定时任务。具体的系统API如下:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

这里值得一提的是另一个信号 SIGVTALRM ,可以使用 setitimer 系统接口进行设置。这个定时器计算的是程序在用户模式(user mode)消耗的时间。另外,这个定时器还可以通过传入的 struct itimerval 设置为循环触发。

#include <sys/time.h>
int setitimer(int which, const struct itimerval *new_value,
	      struct itimerval *old_value);

需要 注意 的是这两个API在同时使用时可能会相互干扰,它们可能会共用同一个定时器。

通过timerfd实现定时任务

timerfd是linux独有的接口,可以在定时器超时后通过一个文件描述符通知程序。

#include <sys/timerfd.h>
int timerfd_create(int clockid, int flags);
int timerfd_settime(int fd, int flags,
		    const struct itimerspec *new_value,
		    struct itimerspec *old_value);
int timerfd_gettime(int fd, struct itimerspec *curr_value);

当这个文件描述符变为可读(readable)时,说明定时器超时了。最方便的是,这个文件描述符可以通过类似select的多路复用接口管理,这样我们就能同时设置并等待多个定时器了。

在网络编程中借助事件的等待间隔执行定时任务

在网络编程中,通常都会用到一套IO复用接口,如select/poll/epoll等,它们会帮你同时检查多个socket上是否有读写操作。这些接口都有一个等待时间,在这个指定时间是最大等待时间,即使在没有需要处理的IO操作时也会返回。这个时间是一般是个固定值,如10ms/20ms/50ms,它被称作网络库的基准频率。在这段空余时间中可以检查定时器是否超时,并执行超时定时器的回调函数。

伪代码示例如下:

def update_events(milisec = 10):
    result = selector.select(milisec)
    for fd, event in result:
	do something with socket event
    current = time.time()
    update_timer(current)

while 1:
    WAIT_MILLISEC = 10
    update_events(WAIT_MILLISEC)

在两次select的间隔中可以进行一些定时操作,关键就在 update_timer 中的实现:

def update_timer (current):
    for timer in available_timers:
	 while current >= timer.expires:
	     timer.callback(current)
	     timer.expires += timer.period

available_timers 记录着当前所有的定时器, timer.expires 是他们需要被触发的时间,如果当前时间大于等于这个 timer.expires ,认为该定时器触发了。 注意 timer.expires 更新的时候是 += period ,而不是 = current + period ,后者会导致误差积累,长时间运行后偏差会越来越大。同时这里需要 while ,因为定时器的回调函数在执行时可能跨越两个以上周期,当然只触发一次的就不需要了。

可以将定时器存在一个优先队列中,每次从队列的前面取出定时器进行超时判断,直到出现了一个没超时的就终止判断,这样可以避免每次都扫描全部定时器。

在所有定时器的超时时间都相同时,可以将它们存放在一个单向队列中,每次增加时都放在最后,并从前面开始检查超时,这样只需要检查队列头部的那部分定时器了。

另一种思路是动态调整等待的间隔,每次选出最先需要超时的定时器,计算它的超时时间,将这个时间作为select的等待时间。这样每次时间都不一样,每次都基本准确,同时不会占用多余的CPU,这种模式叫做 TICKLESS