一、为什么一层时间轮还不够
上一篇介绍的单层时间轮可以简单理解成:
0 → 1 → 2 → 3 → 4 → 5 → ... → 59
↑ ↓
← ← ← ← ← ← ← ← ← ← ← ← ← ← ←
假设:
一个槽 = 1秒
一共60个槽
那么一个完整时间轮可以表示:
60秒
处理:
5秒后执行
20秒后执行
50秒后执行
这样的任务没有问题。
例如:
int slot = (currentSlot + delay / SLOT_INTERVAL) % SLOT_COUNT;
就可以找到对应位置。
但是如果现在来了一个:
2小时后执行
的任务,就有点麻烦了。
因为:
2小时 = 7200秒
而当前时间轮只能表示:
60秒
一种解决办法是把时间轮直接扩大:
7200个槽
甚至为了支持更长任务继续扩大:
86400个槽
604800个槽
...
显然并不合理。
另一种办法就是上一篇提到的:
struct TimerNode {
uint64_t expire;
int rotation;
};
通过:
rotation
记录任务还需要转多少圈。
例如:
时间轮一圈 = 60秒
任务延迟 = 180秒
那么:
rotation = 180 / 60;
得到:
rotation = 3
每次时间轮转到对应槽:
rotation--;
直到:
rotation == 0
才执行。
这种方式可以工作,但是如果系统里存在大量:
几分钟
几小时
甚至更长
的任务,一个任务可能需要跟着低层时间轮被检查很多次。
因此就出现了:
多层时间轮
Hierarchical Timing Wheel
它的思想其实和现实中的钟表非常像:
秒针
↓
分针
↓
时针
不同时间范围的任务由不同层负责管理。
二、多层时间轮是怎么划分的
先设计一个简单的三层时间轮。
例如:
第0层:毫秒级时间轮
第1层:秒级时间轮
第2层:分钟级时间轮
可以设计成:
static const int TVR_SIZE = 256;
static const int TVN_SIZE = 64;
第一层:
256个槽
每个槽1ms
大致管理:
0 ~ 255ms
第二层:
64个槽
每个槽256ms
能够管理更长时间范围。
第三层继续扩大:
64个槽
每个槽 = 64 × 256ms
这样每往上一层:
一个槽代表的时间跨度越来越大
可以简单理解成:
Level 0
每格很小
适合最近即将执行的任务
↓
Level 1
每格时间更长
保存稍远一些的任务
↓
Level 2
每格时间更长
保存更远的任务
如果用钟表来类比:
Level 0 ≈ 秒针
Level 1 ≈ 分针
Level 2 ≈ 时针
所以多层时间轮不是:
所有任务全部放在一个大轮子里
而是:
根据任务距离当前时间还有多久
决定放在哪一层
例如:
10ms 后执行
属于非常近的任务,放:
Level 0
而:
5秒后执行
可能就放:
Level 1
再比如:
10分钟后执行
可能就放:
Level 2
这样不同时间跨度的任务就被分开管理了。
三、一个任务到底应该放在哪一层
加入定时任务时,首先还是计算真正的到期时间:
uint64_t expire = currentTime() + delay;
然后计算:
uint64_t delta = expire - currentTime();
这里:
delta
表示:
这个任务距离现在还有多久。
接下来根据 delta 判断层级。
例如可以简化成:
void addTimer(TimerNode *node) {
uint64_t now = currentTime();
uint64_t delta = node->expire - now;
if (delta < 256) {
addToLevel0(node);
} else if (delta < 256 * 64) {
addToLevel1(node);
} else {
addToLevel2(node);
}
}
可以理解成:
delta < 256ms
↓
Level 0
256ms ~ 16384ms
↓
Level 1
更长时间
↓
Level 2
假设:
当前时间 = 1000ms
现在添加三个任务:
任务A:50ms 后执行
任务B:2000ms 后执行
任务C:30000ms 后执行
那么:
任务A
delta = 50
↓
Level 0
任务B:
delta = 2000
↓
Level 1
任务C:
delta = 30000
↓
Level 2
所以多层时间轮最重要的第一步就是:
根据任务距离当前时间的远近
选择不同层级
每一层内部仍然是时间槽。
例如:
std::list<TimerNode *> level0[256];
std::list<TimerNode *> level1[64];
std::list<TimerNode *> level2[64];
任务真正进入哪一个槽,同样可以通过计算得到。
例如第一层:
int slot = node->expire & 255;
这里:
255 = 256 - 1
如果槽位数量是:
2的幂
那么可以直接通过位运算快速计算槽位。
这也是很多时间轮实现中经常能看到:
& 255
>> 8
>> 14
这类代码的原因。
它们本质上是在:
根据时间值不同的位
计算任务应该进入哪一层、哪一个槽
四、高层任务为什么要向低层迁移
多层时间轮最关键的机制就是:
任务迁移
也可以叫:
cascade
级联
假设现在有:
Level 0:负责最近256ms
Level 1:负责更远的任务
一个任务:
1000ms 后执行
开始时距离当前时间比较远,所以放在:
Level 1
但是随着时间不断前进:
1000ms
800ms
500ms
300ms
256ms
这个任务已经越来越接近执行时间。
最后它进入了:
Level 0可以管理的范围
那么就需要把它从:
Level 1
移动到:
Level 0
这就是:
cascade
整个过程可以理解成:
任务刚加入
↓
距离执行时间很远
↓
放在高层时间轮
↓
时间不断推进
↓
任务逐渐接近
↓
高层对应槽到期
↓
把该槽任务取出来
↓
重新计算剩余时间
↓
放入更低一层
↓
最终进入Level 0
↓
真正执行
例如定义一个简单的迁移函数:
void cascade(std::list<TimerNode *> &slot) {
auto timers = std::move(slot);
slot.clear();
for (TimerNode *timer : timers) {
addTimer(timer); // 根据当前剩余时间重新选择层级
}
}
这里并不是直接执行高层任务。
而是:
addTimer(timer);
重新判断:
现在这个任务距离执行还有多久?
然后再选择:
Level 0
Level 1
Level 2
假设任务原本:
Level 2
随着时间前进:
Level 2
↓
Level 1
↓
Level 0
↓
执行
所以多层时间轮中的任务不是一直待在原来的槽里。
而是随着时间越来越接近:
不断从高层向低层移动。
这也是理解多层时间轮最关键的一点。
五、多层时间轮完整运行流程
最后把整个流程串起来。
首先定义一个定时任务:
struct TimerNode {
uint64_t expire; // 真正到期时间
std::function<void()> callback; // 到期后执行的回调函数
};
加入一个任务:
TimerNode *timer = new TimerNode;
timer->expire = currentTime() + 5000;
timer->callback = []() {
std::cout << "timer expired" << std::endl;
};
addTimer(timer);
addTimer() 首先计算:
uint64_t delta = timer->expire - currentTime();
然后:
判断delta范围
↓
选择时间轮层级
↓
计算对应槽位
↓
加入槽中的链表
时间轮开始推进以后:
void tick() {
++currentTick;
// 处理最低层当前槽
int index0 = currentTick & 255;
if (index0 == 0) {
// Level 0转完一圈,需要从Level 1迁移任务
int index1 = (currentTick >> 8) & 63;
cascade(level1[index1]);
if (index1 == 0) {
// Level 1也转完一圈,继续从Level 2迁移
int index2 = (currentTick >> 14) & 63;
cascade(level2[index2]);
}
}
executeSlot(level0[index0]);
}
这里先不用纠结:
>> 8
>> 14
这些位运算。
只需要理解:
Level 0转完一圈
↓
推动Level 1前进
Level 1转完一圈
↓
推动Level 2前进
和钟表完全一样:
秒针转一圈
↓
分针走一格
分针转一圈
↓
时针走一格
执行最低层任务:
void executeSlot(std::list<TimerNode *> &slot) {
for (TimerNode *timer : slot) {
if (timer->expire <= currentTime()) {
timer->callback();
delete timer;
} else {
addTimer(timer);
}
}
slot.clear();
}
所以完整运行过程可以整理成:
添加定时任务
↓
计算expire
↓
计算距离当前时间delta
↓
根据delta选择层级
↙ ↓ ↘
Level 0 Level 1 Level 2
↓ ↓ ↓
放入对应时间槽
↓
时间不断推进
↓
Level 0不断转动
↓
Level 0转完一圈
↓
从Level 1迁移任务
↓
Level 1转完一圈
↓
从Level 2迁移任务
↓
任务不断向下移动
↓
到达Level 0
↓
时间真正到期
↓
执行callback
这样再回头看单层时间轮和多层时间轮,就能发现二者最大的区别。
单层时间轮:
一个轮子
+
多个时间槽
+
可能需要rotation
多层时间轮:
多个轮子
+
不同时间粒度
+
高层任务不断向低层迁移
从性能角度看,多层时间轮最大的价值在于:
不需要维护一个巨大的有序结构
同时又可以支持:
很短的定时任务
+
很长的定时任务
+
大量并发定时任务
因此多层时间轮常用于:
网络连接超时
心跳检测
RPC超时
游戏服务器
高并发服务
大量延迟任务
如果把前面几篇内容放在一起,其实已经可以形成一个比较完整的定时器知识体系:
普通有序定时器
↓
最小堆
↓
红黑树 / 跳表
↓
单层时间轮
↓
多层时间轮
其中最小堆、红黑树解决的是:
怎样高效维护任务的时间顺序
而时间轮解决的是:
怎样利用时间本身的位置
直接组织大量定时任务
多层时间轮则进一步解决:
怎样同时管理不同时间跨度的任务
理解到这里以后,再去看实际工程中的时间轮代码,遇到:
slot
tick
cascade
expire
level
这些概念时,基本就能知道它们分别在做什么,而不会只看到大量位运算和链表操作。