高性能定时器实现
1.简介
对于一个复杂的软件系统,定时器的对任务的管理和调度至关重要,定时器的管理已成为一个复杂系统的重要基础设施。定时器的应用场景非常广泛。在游戏开发中,定时器可以被用于实现游戏中的倒计时、技能冷却等功能,为游戏体验增添乐趣。在物联网领域,定时器可以用于实现设备间的通信、数据上报等功能,为物联网系统的运行提供支持。在数据库中,定时器可以用于实现过期键和延时队列等功能。例如,在 Redis 数据库中,定时器可以用于实现缓存过期,保证缓存数据的时效性。
2.实现方式
要想实现高效的定时器,就需要对定时任务进行管理,定时调用(tick
)接口获取超时的任务并执行。所以定时器的实现需要满足一下条件:
- 能够方便地获取过期任务
- 能够方面增加和删除
2.1有序链表
需要排序并且增删方便,很容易想到的一个数据结构就是有序链表
,可以将定时任务按照过期时间从小到大排序放入链表。每次tick
只需要判断队头task是否到期即可。
时间复杂度:
- 插入:O(n)
- 删除: O(n)
- 获取到期任务: O(1)
其中可将要删除的task id单独缓存,在task过期时惰性删除,采用此种方法可以将删除的时间复杂度减少到O(1)
2.2小顶堆
有序链表的插入和删除的成本比较高,进一步想到的数据结构就是小顶堆。将定时任务放在小顶堆中管理,每次tick
从堆顶获取到的就是最近要过期的task。
时间复杂度:
- 插入:O(log2n)
- 删除: O(log2n)
- 获取到期任务: O(1)
陈硕的muduo
库,JDK
的PriorityQueue
等定时器时间就是基于小顶堆。
2.3时间轮
时间轮(TimingWheel
)是一个存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表。
2.3.1单级时间轮
假设我们的定时任务有最大过期时间限制,比如最多支持(2^8-1=255
)ms后过期。那么我们可以
1.开辟一个TimeWheelSize(2^8)
大小的数组TimeWheel
,表示一个时间轮,这个时间轮的最小单位为tickms
,此处为1ms
- 一个表盘指针
currentTime
,指向当前所处的时间,每次tick向前推进tickms
流程推演
1.初始节点currentTime
为0。假设此时来了个100ms后过期的task,则将其插入到TimeWheel[100%TimeWheelSize]
2.currentTime不断推进,直到推进到100,发现TimeWheel[100]中有任务,则将其执行。
3.若此时又来了个100ms后过期的task,则将其插入到TimeWheel[(currentTime+100)%TimeWheelSize],即TimeWheel[200]中
4.currentTime不断推进,直到推进到200,发现TimeWheel[200]中有任务,则将其执行。
5.若此时又来了个100ms后过期的task,则将其插入到TimeWheel[(currentTime+100)%TimeWheelSize],即TimeWheel[44]中
6.currentTime继续推进,推进到了TimeWheelSize-1,则重置为0. 所以叫做时间轮。然后从0继续推进推进到44时,执行TimeWheel[44]中的任务。
2.3.2 多级时间轮
一个时间轮的大小总是有限的,例如上述提到的时间轮最大只支持(2^8-1=255
)ms后过期的定时任务。那么如果需要插入256ms后过期的定时任务该怎么办呢?想到钟表上秒针转一圈,分针动一下,分针转一圈后时针动一下,我们定时器也可以采用这种思想使用多级时间轮。
例如如果需要支持最多(2^32-1)ms
(四十多天了,一般足够用了)的延迟任务。则我们可以分为4级时间轮TimeWheel
,每个时间轮是一个(2^8-1)
大小的数组。其中TimeWheel[0]相当于上述的单级时间轮。
第N级 | 表盘 | 表盘大小 | 最小单位 | 表盘指针 | TimeWheelSize |
---|---|---|---|---|---|
0 | TimeWheel[0] | (2^8-1) | 1ms | currentTime[0] | TimeWheelSize[0]= (2^8)ms |
1 | TimeWheel[1] | (2^8-1) | (2^8)ms | currentTime[1] | TimeWheelSize[1]=(2^16)ms |
2 | TimeWheel[2] | (2^8-1) | (2^16)ms | currentTime[2] | TimeWheelSize[2]=(2^24)ms |
3 | TimeWheel[3] | (2^8-1) | (2^24)ms | currentTime[3] | TimeWheelSize[3]=(2^32)ms |
流程推演
-
初始阶段currentTime[0],currentTime[1]都为0。
-
此时
- 插入200ms后到期的任务A、将其插入到TimeWheel[0][200]中。
- 插入300ms后到期的任务B,发现TimeWheelSize[1]>300>TimeWheelSize[0],则将其插入TimeWheel[1][300%TimeWheelSize[1]] = 则将其插入TimeWheel[1][1]中。
- 插入400ms后到期的任务C,同理也插入到TimeWheel[1][1]中
-
currentTime[0]持续推进,直到currentTime[0]=200时执行任务A
-
currentTime[0]持续推进,直到转了一圈后currentTime[0]=0,此时currentTime[1]+1=1,发现TimeWheel[1][1]中有两个任务B和C。此时B还有44ms过期,C还有144ms过期。则将其从TimeWheel[1][1]中删除并分别插入TimeWheel[0][44],TimeWheel[0][144]中。完成级联。
多级时间轮级联的流程与上述类似。
3.总结
目前用得比较多得时间轮实现是基于小顶堆和基于时间轮实现。
定时器实现 | 优点 | 缺点 | 适用场景 | 举例 |
---|---|---|---|---|
时间轮 | 时间复杂度为O(1),添加、删除和执行定时任务的时间效率高。支持高并发的定时任务处理。可以使用多级时间轮来处理更大的定时任务范围。 | 精度受到时间轮刻度的限制。定时器链表的长度可能会增长,需要进行定期清理。 | 定时任务较多,且定时间隔较短。需要高并发处理定时任务的场景。 | 操作系统的定时器机制。网络游戏中的定时器处理。 |
小顶堆 | 精度高,可以实现毫秒级别的定时任务。可以动态调整定时器的优先级,支持定时器的重新调度。定时器的长度不会随时间增长而增加。 | 时间复杂度为O(logn),添加、删除和执行定时任务的时间效率较低。不支持高并发的定时任务处理。 | 定时任务较少,且定时间隔较长。对定时器精度要求较高的场景。 | 定时器框架Redis中的定时器处理。 |
实际实现还有一些优化点。比如采用事件机制,当定时器里有任务到期时才tick,避免无任务到期时频繁调用tick空转。