1. 时间轮算法基本思想


对于一个复杂的软件系统,定时器的对任务的管理和调度至关重要,通常定时器的管理已成为一个 复杂系统的重要基础设施。 定时器有很多种(一文完全理解定时器实现技术),基于升序的定时器时间链表是一种最直接的实现方式:即按照定时器时间到的时间顺序依次存放在一个链表中进行管理。但是这种链表存在效率的不足,就是当插入定时器的时候时间复杂度是 O(n). 因此需要一种更高效地管理定时器的数据结构和算法,这里结合Linux内核中基于时间轮的定时器管理器的具体实现,介绍一种基于时间轮的定时器管理算法。图1为时间轮的基本结构:

java timer定时器时间间隔错乱 java时间轮定时器_二级


图1 定时器基本结构 图1所示的是一个时间轮的基本结构。 时间轮分为N个(例如8个)时间槽slot,每时间槽指向一个定时器链表,这个链表里包含多个定时器,这个链表的定时器Timeout时间相同,因此链表里的定时器无须排序。 时间轮每一个滴答时间转动一格,会指向下一个时间槽。 这里的滴答时间取决于时间轮的具体实现,可以是系统的一个时钟时间,也可以是一个毫秒,一秒钟等。 如果记时间轮的一个滴答时间为si(slot interval),即时间轮每转动一个槽的时间为si,如果有N个槽,那么时间轮转动一圈的时间为N * si。 如果时间轮开始转动的起始时间为ts,那么当有个定时器Timeout时间为t的定时器要加入到时间轮,那么应该将这个定时器放到哪个时间槽对应的链表呢? 可以用下面的公式计算: ((t - ts)/ si) % N 以图1为例,如果时间轮一个滴答时间为1秒,假设时间轮开始转动时间为0,那么一个定时器Timeout=6s的定时器应该加到6号时间槽对应的链表里,一个定时器Timeout=7s的定时器应该加到7号槽对应的链表里。 那么时间轮该如何检查定时器是否时间到呢?同样地,如果时间轮开始转动的起始时间为ts,当前时间为tc,则计算 ((tc - ts)/ si) % N 计算结果则为定时器时间到的那个时间槽对应编号,这个时间槽对应的链表里的定时器全部时间到。 聪明的读者马上会想到一个问题:那么一个定时器Timout=8s的定时器会加到0号槽,岂不是和定时器Timeout=0s(马上时间到)的定时器放到一个槽里了?这是因为图1所示的时间轮刻度只要8个(即只能管理8种不同Timout的定时器),因此为了解决这种问题,需要增加N的值。 增加N的值更聪明的办法是采用多级时间轮,即在图1所示的时间轮外面再环绕一个时间轮,假设外面时间轮的刻度为8,即外轮的时间槽也是8个,每个时间槽也对应一个链表。同时定义时间轮的转动规则:当里面的时间轮转动1圈(8格),外面的时间轮转动1格。可以看到,采用这种方式,二级时间轮可以管理(8*8=64)种不同Timeout的定时器。 在二级时间轮的结构下,一个定时器Timeout=t的定时器怎么加入时间轮呢? 还是假设二级时间轮都有8个槽,假设时间轮的起始时间为ts,则采用如下算法将Timeout=t的定时器加入时间轮: 1)计算t-ts/si; 2)如果t-ts/si < 8,则以t-ts/si的低3位作为索引加入内轮; 3)如果t-ts/si>= 8(当然要小于64,否则又溢出),则以t-ts/si的高3位作为索引加入外轮; 二级时间轮检查时间到的算法与单机时间轮类似,不同的地方就是当内轮的所有时间槽都时间到后,要把外轮的时间槽链表迁移到内轮。 综上所述: 基于排序链表的定时器使用唯一的链表来管理所有定时器,所以插入定时器的数目越多,效率就会越低,而时间轮则是利用哈希表的思想,将定时器散列到不同的链表中,这样每条链表上的数据就会显著少于原来排序链表的数目。 插入操作的效率基本不受定时器数目的影响(不需要排序,直接散列到一个链表上)。 因此插入定时器的时间复杂度和定时器数量n无关,为O(1)。 显然要提高时间轮的精度,就要使si(slot interval)足够小,要提高其执行效率则要N要足够大。 如果最里面一级时间轮的槽采用n1为二进制编码,外面一级时间轮采用n2位二进制编码,则总共可以管理的时间范围为0 ~ 2 (n1+n2)

– 1。 以上面的例子为例,如果二级时间轮都是3位二进制编码(8个时间槽),那么总共可以管理的时间范围为0 ~ 63,即64种Timeout的定时器。 Linux内核采用多级时间轮。 定义了5个链表数组(每个数组里面包含多个定时器链表): tv1-tv5都是一个链表数组,其中tv1的数组大小为TVR_SIZE(256,8位编码), tv2、tv3、tv4、tv5的数组大小为TVN_SIZE(64,6位编码)。 可以看到一共是32位编码,总共可以管理的时间范围为0 ~ 2 32

– 1。 这5个数组就好比是5个齿轮,它们随着滴答时间的增长而不停地转动,每次只需处理第一个齿轮的某一个齿节,低一级的齿轮转动一圈,高一级的齿轮转动一个齿,同时自动把即将到期的定时器迁移到上一个齿轮中,所以低分辨率定时器通常又被叫做时间轮: time wheel。 事实上,它的实现是一个很好的空间换时间软件算法。 参考Linux的实现,具体代码如下: 首先定义如下宏:

java timer定时器时间间隔错乱 java时间轮定时器_数组_02

2. 定时器的添加


要加入一个新的定时器,按以下步骤进行处理: 1)计算定时器到期时间和当前cpu定时器所经历过的毫秒数的差值,记为idx 2)根据idx的值,选择该定时器应该被放到tv1--tv5中的哪一个链表数组中,可以认为tv1-tv5分别占据一个32位数的不同比特位,tv1占据最低的8位,tv2占据紧接着的6位,然后tv3再占位,以此类推,最高的6位分配给tv5。 最终的选择规则如下表所示:

java timer定时器时间间隔错乱 java时间轮定时器_数组_03

java timer定时器时间间隔错乱 java时间轮定时器_链表_04

java timer定时器时间间隔错乱 java时间轮定时器_链表_05


确定链表数组后, 接着要确定把该定时器放入数组中的哪一个链表中,如果时间差idx小于256,按规则要放入tv1中,因为tv1包含了256个链表,所以可以简单地使定时器的expires的低8位作为数组的索引下标,把定时器链接到tv1中相应的链表中即可。 如果时间差idx的值在256--18383之间,则需要把定时器放入tv2中,同样的,使用定时器的expires的8--14位作为数组的索引下标,把定时器链接到tv2中相应的链表中,。 定时器要加入tv3tv4 tv5使用同样的原理。 经过这样分组后的定时器,在后续的tick事件中,系统可以很方便地定位并取出相应的到期定时器进行处理。 代码如下:

java timer定时器时间间隔错乱 java时间轮定时器_二级_06



java timer定时器时间间隔错乱 java时间轮定时器_链表_07

3. 定时器到期处理


系统中的定时器按到期时间有规律地放置在tv1--tv5各个链表数组中,其中tv1中放置着在接下来的256个滴答时间(如毫秒)即将到期的定时器列表。系统滴答值一直在随着系统的运行而动态地增加,原则上是每个tick事件会加1,定时器加入tv1中使用的下标索引是定时器到期时间expires的低8位,所以假设当前的滴答值是0x345678 26 ,则马上到期的定时器是在tv1.vec[0x26]中,如果这时候系统加入一个在滴答值0x345678 28 到期的定时器,他将会加入到tv1.vec[0x28]中,运行两个tick后,系统滴答的值会变为0x34567828, 很显然,在每次tick事件中,定时器系统只要以当前滴答值的低8位作为索引,取出tv1中相应的链表,里面正好包含了所有在该滴答值到期的定时器列表。 那什么时候处理tv2--tv5中的定时器? 每当当前系统滴答值的低8位为0值时,这表明当前系统滴答值的第8-13位有进位发生,这6位正好代表着tv2,这时只要按当前系统滴答值的第8-13位的值作为下标,移出tv2中对应的定时器链表,然后用第2节的步骤把它们重新加入到定时器系统中来,因为这些定时器一定会在接下来的256个tick期间到期,所以它们肯定会被加入到tv1数组中,这样就完成了tv2往tv1迁移的过程。 同样地,当当前系统滴答值的第8-13位为0时,这表明当前系统滴答值的第14-19位有进位发生,这6位正好代表着tv3,按当前系统滴答值的第14-19位的值作为下标,移出tv3中对应的定时器链表,然后用第2节的步骤把它们从新加入到定时器系统中来,显然它们会被加入到tv2中,从而完成tv3到tv2的迁移,tv4,tv5的处理可以以此作类推。 具体地,定时器时间到需要实现下面二个函数: check和cascade,其中cascade完成时间轮的从外轮向里轮的进位。 基于Linux内核的时间轮实现代码,可以在应用程序层面实现一个基于时间轮的管理器。 部分代码如下所示:

java timer定时器时间间隔错乱 java时间轮定时器_链表_08

java timer定时器时间间隔错乱 java时间轮定时器_java timer定时器时间间隔错乱_09

java timer定时器时间间隔错乱 java时间轮定时器_数组_10

java timer定时器时间间隔错乱 java时间轮定时器_数组_11


TimerManager 类的定义如下:

java timer定时器时间间隔错乱 java时间轮定时器_二级_12

java timer定时器时间间隔错乱 java时间轮定时器_链表_13