libco 的定时器实现:时间轮

定时器是网络框架中非常重要的组成部分,往往可以利用定时器做一些超时事件的判断或者定时清理任务等。

定时器有许多经典高效的实现。例如,libevent 采用了最小堆实现定时器,redis 则结合自己场景直接使用了简单粗暴的双向链表。

时间轮也是一种非常经典的定时器实现方法。Linux 2.6 内核之前就采用了多级时间轮作为其低精度定时器的实现。而在微信的协程库 libco 中,则用了单级时间轮来管理其内部的超时事件。

在 libco 的时间轮实现中,对超时事件的添加删除查询操作均可以达到 O(1) 的时间复杂度,是一个非常高效的数据结构。

同时,最新版的 libco 时间轮也支持了无限超时时间,见下文。

时间轮的表示

在 libco 中,使用以下数据结构表示一个时间轮:

co_routine.cpp
struct stTimeout_t { stTimeoutItemLink_t *pItems; int iItemSize; long long llStartIdx; unsigned long long ullStart; };

时间轮 stTimeout_t 负责管理 libco 中所有超时事件。 其中各属性的意义如下:

  1. pItems 是一个数组,数组长度为 iItemSize。而数组中的每个元素是 stTimeoutItemLink_t 类型,这是一个双向链表。而对于同一个链表中的每个元素,它们的超时时间都是相同的。
  2. llStartIdx 代表当前最近超时时间对应的 index。
  3. ullStart 代表当前最近超时时间的时间戳,单位是毫秒

总体来说,libco 的时间轮是一个环形数组的实现,如下图所示:

time wheel

在这个环形数组中,数组中每个元素代表 1ms。而 libco 将环形数组的总长度设为 60*1000, 即最多可以表达 1 分钟以内的超时事件,且超时精度是毫秒。

而且,有可能会有多个超时事件在同一时刻发生,因此数组中的元素是个链表,代表同在该时刻触发的超时事件。

在 libco 初始化时,ullStart 被初始化为当前时刻的时间戳 (单位为毫秒),llStartIdx 初始化为 0。

添加一个超时事件

我们看下 libco 是怎么添加一个超时事件的:

  1. 将相对时间转化为时间戳,代码如下:
unsigned long long now = GetTickMS();
apItem.ullExpireTime = now + timeout;

这点不难理解,只有统一成标准的时间表示,才可以和其他超时事件统一的放在一起。

  1. 计算该超时事件的触发时间距离时间轮中最近的超时时间 ullStart 的时间差值
int diff = apItem->ullExpireTime - apTimeout->ullStart;

计算得到了这个时间差值,才可以进一步计算新的超时事件在时间轮中的位置。
当然,在把超时事件放入时间轮之前,需要先判断下该超时事件是否越界了。如果比 ullStart 大于 1 分钟, 则 libco 时间轮没有办法表示这个超时事件,将会报错。相关代码如下

if(diff>= apTimeout->iItemSize )
{
	co_log_err("CO_ERR: AddTimeout line %d diff %d",__LINE__,diff);
	return __LINE__;
}
  1. 计算得出该超时事件在时间轮中的位置,并将其插入到时间轮中。
AddTail(apTimeout->pItems + ( apTimeout->llStartIdx + diff ) % apTimeout->iItemSize , apItem );

这里其实有两步:

  • 计算在时间轮中的位置。其实很简单,就是一个简单的取余过程,这个也是环形数组的普遍做法。得到的 apTimeout->pItems + ${index},即是该超时事件所在的位置。
  • 将该超时事件追加在该位置上的链表最后。前面讲过,有可能多个超时事件可能会在同一时刻触发。

超时事件的判断及取出

libco 是如何判断事件是否超时以及取出所有已超时的事件呢?过程如下:

  • 如果当前的时间小于 ullStart,说明目前没有事件超时
  • 如果大于等于 ullStart,用当前时间减去 ullStart,就可以得出一共过去了多少毫秒,一毫秒代表一个数组元素,从 llStartIdx 开始遍历即可。

时间轮是典型的空间换时间的做法,需要预先把环形数组的内存空间都分配好,这也是 libco 的超时事件存取高效的原因。

讲到这里,其实 libco 的整个时间轮算法已经全部分析完成了。

但是对于 libco 的时间轮大家可能会有一些疑问:

  1. libco 的时间轮最多只能支持 1 分钟的超时时间。虽然这个时间对于后台服务的场景已经完全足够了,但是如果我们在其他场景需要更长的超时时间呢?
  2. libco 中的一个数组元素代表 1ms。如果我们需要更长的时间那岂不是内存空间也随之线性增长了?

那接下来我们就简单讲下对于时间轮的进一步优化:

  1. 单级时间轮的优化。我们可以对 libco 的单级时间轮做一些简单的优化,例如给每个超时事件加一个 rotation 参数,代表该超时事件会在第几轮触发,这样就可以在一个单级时间轮中存放无限长的超时事件了。但这样代价是超时事件的判断和取出将不会是 O(1) 了。
  2. 多级时间轮。Linux 内核中就采用了多级时间轮的机制,模拟了现实生活中水表刻度。即第一级的时间轮与普通的单级时间轮相同,而第二级时间轮的每个元素的时长等于第一级时间轮的全部总时长,依次类推。Linux 内核中一共采用了五级时间轮。第一级的时间轮所有事件消耗完成后,会触发第二级时间轮的事件迁移。

libco对无限超时时间的支持 (更新)

上文说到libco最多支持60s的超时时间。对于此问题,libco已在这个commit中支持了无限超时时间的支持了: 8ce6dfef26。同时也在issues/46中有相关讨论。

具体的支持方法非常简单,当插入时间时,即使超时时间超出1分钟,依然允许插入。同时当进行超时时间判断时,只要这个槽符合超时时间的判断,那这个槽的所有时间事件都会被取出来。
那这样也意味着,这个槽里面可能会有时间事件未触发。在具体遍历每个超时事件时,再单独判断是否真的超时,如果未超时,则会将其再重新放回时间轮中。

if (lp->bTimeout && now < lp->ullExpireTime) 
{
	int ret = AddTimeout(ctx->pTimeout, lp, now);
	if (!ret) 
	{
		lp->bTimeout = false;
		lp = active->head;
		continue;
	}
}