时间轮
介绍
第一次看到时间轮定时器实现是在 skynet 中,源码:skynet_timer.c
什么是时间轮,我觉得可以从我们生活中使用到的时钟来介绍,更形象些,比如,我们明天早上9点上班,这就像一个定时任务,到某个时间点做某件事。转成计算机数据结构,我们可以用三个数组来存储时钟秒针,分针,时针对应的定时任务,数组的每个槽位,分别代表了时钟的秒针,分针,时针的刻度单位。
我们用一个无符号正数计数器变量来表示时钟的秒针,分针,时针。为啥一个计数器就够了呢,而不需要三个计数器呢,我们知道,秒针转动一圈,分针转动一格,分针转动一圈,时针转动一格。
如果我们的时间轮单位是秒,计数器从0开始,每秒执行一次累加,不断累加时间,每次累加之后,都会检测秒针数组对应的槽位有没有定时任务,有就取出来执行。如果计数器模60,为0了,说明秒针已经走完一圈,接着除于60,得到的结果-1(减1是因为c语言数组下标从0开始的),就对应了分针数组的槽位,然后查看槽位有没有定时任务,有就取出来执行。同理,计数器要是模 (60 * 60) 为0了,就说明现在的累加时间对应到时针数组了,同样 /(60 * 60) % 60 得到槽位,然后查看槽位有没有定时任务,有就取出来执行。
更好的理解,就是秒针数组,每格1s为单位;分针数组,每格 60s 为单位;时针每格 (60*60)s为单位。
如果我们在某个时刻的定时任务不止一个,比如3s后,有两个定时任务,我们可以在3s的槽位上,使用单向链表来把这些定时任务给串起来。如下图:
网上找的图https://blog.csdn.net/baiduwaimai/article/details/130271751
如果我们的定时器任务是在1分20秒(对应 80s)后执行的,那么秒钟数组最多只能放60s内的定时任务,放不下,接着就去查找分针数组,发现80s < 60 * 60s 放得下,此时,就应该放到分针数组里的1号槽位( 向下取整(80 /60) = 1),当计数器走到分针数组1号槽位时,取出定时任务,查看时间比现在大了20s,那么就再把这个定时任务放到秒针数组第19号槽位(c语言数组下标从0开始)。等过了20s后,再从秒针数组取出,执行。
由此可见,秒针数组保存着即将要执行的任务,而别的数组随时间跨度越来越大,随着时间的流逝,任务会慢慢从高跨度数组流到秒针数组上面。
skynet 时间轮
接着看下 skynet 时间轮实现,用到的数据结构:
#define TIME_NEAR_SHIFT 8
#define TIME_NEAR (1 << TIME_NEAR_SHIFT)
#define TIME_LEVEL_SHIFT 6
#define TIME_LEVEL (1 << TIME_LEVEL_SHIFT)
#define TIME_NEAR_MASK (TIME_NEAR-1)
#define TIME_LEVEL_MASK (TIME_LEVEL-1)
struct timer_node { //每次分配timer_node 与 timer_event (参数)的空间, 将time_event 放在node 后面
struct timer_node *next;
uint32_t expire; //计时器事件触发时间
};
struct link_list { //计时器node列表
struct timer_node head;
struct timer_node *tail;
};
struct timer {
struct link_list near[TIME_NEAR]; //最近的时间,TIME_NEAR = 256
struct link_list t[4][TIME_LEVEL]; //根据时间久远分级,TIME_LEVEL = 64
struct spinlock lock;
uint32_t time; // 计时器,每百分之一秒更新一次
uint32_t starttime; //起始时间 秒
uint64_t current; // 当前时间与starttime的时间差 单位为百分之一秒
uint64_t current_point; //上一次update的时间, 百分之一秒
};
skynet 的实现是一个分层时间轮,一共分了五层。最接近要执行的时间点在 near 数组里,有 256个槽位,其余的分层在 t 里,有 4 层,每层有 64个槽位,每个槽位,是一个链表,链表中每个节点存放定时任务。
一个整数,用32位来存储,所以,time的表示范围只能是2^32之间,那么32位可以划分为 near 的低八位,2^8=256 来存储最近的时间,4个2^6 数组来存储较远的时间, 2^32 == 2^8 + 2^6 + 2^6 + 2^6 + 2^6
添加定时任务
定时器对外接口:skynet_timeout
,只需要一个超时时间 time,超时过后,需要知道要回调哪个服务 handle,此外,还需要知道服务对应的是哪次会话 session。因为一个服务里头有可能需要多个超时任务回调,所以用 session 唯一区分。
定时任务结构如下:
static void
add_node(struct timer *T,struct timer_node *node) {
uint32_t time=node->expire;
uint32_t current_time=T->time;
if ((time|TIME_NEAR_MASK)==(current_time|TIME_NEAR_MASK)) {
link(&T->near[time&TIME_NEAR_MASK],node);
} else {
int i;
uint32_t mask=TIME_NEAR << TIME_LEVEL_SHIFT;
for (i=0;i<3;i++) {
if ((time|(mask-1))==(current_time|(mask-1))) {
break;
}
mask <<= TIME_LEVEL_SHIFT;
}
// 右移位,相对于除法,最后 & 相对于取模,看看落到数组中的哪个位置
link(&T->t[i][((time>>(TIME_NEAR_SHIFT + i*TIME_LEVEL_SHIFT)) & TIME_LEVEL_MASK)],node);
}
}
将定时任务 timer_node 添加到合适的定时队列,其原理是先比较当前时间的高24位与期望时间的高24位,如果相等,说明期望时间与当前时间接近,那么就将其存储到near[index]中,其中index为期望时间的低八位数字。
如果不满足上一个要求,就再比较当前时间与期望时间的高18位,如果它们相等,那么就将其存储到t[0][index]中,其中index为期望时间的低九至十五位数字。依次比较高12位,高6位,将定时任务存放到 t[1],t[2],t[3] 某个数组合适的位置中。
更新时间
static void
timer_shift(struct timer *T) {
int mask = TIME_NEAR;
uint32_t ct = ++T->time;
if (ct == 0) {
move_list(T, 3, 0);
} else {
uint32_t time = ct >> TIME_NEAR_SHIFT;
int i=0;
while ((ct & (mask-1))==0) {
int idx=time & TIME_LEVEL_MASK;
if (idx!=0) {
move_list(T, i, idx);
break;
}
mask <<= TIME_LEVEL_SHIFT;
time >>= TIME_LEVEL_SHIFT;
++i;
}
}
}
static void
timer_update(struct timer *T) {
SPIN_LOCK(T);
// try to dispatch timeout 0 (rare condition)
timer_execute(T);
// shift time first, and then dispatch timer message
timer_shift(T);
timer_execute(T);
SPIN_UNLOCK(T);
}
void
skynet_updatetime(void) {
uint64_t cp = gettime();
if(cp < TI->current_point) {
skynet_error(NULL, "time diff error: change from %lld to %lld", cp, TI->current_point);
TI->current_point = cp;
} else if (cp != TI->current_point) {
uint32_t diff = (uint32_t)(cp - TI->current_point);
TI->current_point = cp;
TI->current += diff;
int i;
for (i=0;i<diff;i++) {
timer_update(TI);
}
}
}
timer 线程会每隔 2.5 毫秒更新一次时间,当可以触发时间更新时,会计算当前时间和上次时间的偏移量,然后为每个偏移量调用 timer_update 更新定时器。虽然理论上来说不太可能连续多次触发,但是为了防止特殊情况造成的定时被忽略,所以要对每个偏移量执行 timer_update 操作。
核心是 timer_shift
实现,像之前介绍时钟那样,如果当前时间 ct % TIME_NEAR 为0(更高效的计算方式:&(TIME_NEAR-1) ==0),说明已经走完 near 数组一圈,接着检测到 t[0],如果也走完一圈,那么势必会有 ct % (1<<(TIME_NEAR +TIME_LEVEL_SHIFT * 1)) 为0,如果 ct % (1<<(TIME_NEAR +TIME_LEVEL_SHIFT * 2)) 不为0,说明时间走到 t[2-1] 数组中,即当前时间走到 t[1] 这个数组中的 (ct >> (TIME_NEAR_SHIFT + TIME_LEVEL_SHIFT * 2)) & TIME_LEVEL_MASK
位置,后面就是把这个位置上的定时任务重新打落到 near数组中(因为定时任务的期望时间还没立马到来),待后面 timer_execute
再检测是否需要执行定时任务。
执行事件
static inline void
dispatch_list(struct timer_node *current) {
do {
struct timer_event * event = (struct timer_event *)(current+1);
struct skynet_message message;
message.source = 0;
message.session = event->session;
message.data = NULL;
message.sz = (size_t)PTYPE_RESPONSE << MESSAGE_TYPE_SHIFT;
skynet_context_push(event->handle, &message);
struct timer_node * temp = current;
current=current->next;
skynet_free(temp);
} while (current);
}
static inline void
timer_execute(struct timer *T) {
int idx = T->time & TIME_NEAR_MASK;
while (T->near[idx].head.next) {
struct timer_node *current = link_clear(&T->near[idx]);
SPIN_UNLOCK(T);
// dispatch_list don't need lock T
dispatch_list(current);
SPIN_LOCK(T);
}
}
这部分就比较简单了,只需要取出当前时间对应的 near 槽位上的链表,如果有定时任务,就取出来,封装到一个 timer_event 对象中,最后派发给对应的服务 handle。
延时回绕问题
由于计数器 T->time
是用无符号整数 uint32_t 表示的,当服务器一直运行下去,T->time
一直累加,就会出现 回绕问题,++T->time == 0。那回绕后,对应的落在 near,还是 t[0],t[1],t[2],t[3] 中呢,首先它能回绕,证明 time 所有位都是1了,是一个很大的正数,那么肯定是在 t[3] 这个最高层时间轮,time 为0,说明,下标计算:time>>(TIME_NEAR_SHIFT + i*TIME_LEVEL_SHIFT)) & TIME_LEVEL_MASK
,即 0>>(256+3*64) & (64-1) 结果也是0,所以当前时间回绕,只会发生在 t[3][0] 这个槽位中,我们仔细看之前 timer_shift
函数 if (ct == 0) 部分代码,可以看出,直接写死 move_list(T, 3, 0)
只需要处理 t[3][0] 就可以了。具体看看 skynet_timer延时回绕问题 #784
时间轮多层划分依据
为啥 skynet 的时间轮是划分5层的,最近触发定时任务的 near 数组大小是256,其他4层数组大小是 64 呢,我之前看到的一篇博客时间轮定时器的实现 (参考 Linux 源码),说Linux内核定时器也是这么划分的,具体为啥,我也不清楚。
chatgpt 给的解释:
选择256和64这样的数字往往是因为它们是2的幂次方,这有利于计算机快速进行位运算并索引到正确的时间槽。之所以第一层不直接选择64个槽位,可能是考虑到:
- 更细粒度的第一层可以提供更好的短期定时精度。
- 在大多数应用场景中,短期定时器需求更多且密集,因此更多的槽位能够分散负载,避免过多定时器集中在较少的槽内导致处理效率降低。
至于为什么不是512个槽位,可能出于以下考虑:
- 计算机存储空间和计算资源的平衡。过多的槽位虽然可以提供更高的精度,但是也会占用更多的内存和增加查找成本。
- 实际需求与系统性能之间的权衡。设计者可能基于实际系统中定时器事件的分布规律以及期望达到的最小时延要求来决定最优的槽位数。
总之,这种特定的设计取决于具体的实现目标和约束条件,包括但不限于系统的定时器需求、可用内存大小、以及对响应时间和精确度的要求。