【C++】 定时器进阶:多层时间轮原理与任务迁移

一、为什么一层时间轮还不够

上一篇介绍的单层时间轮可以简单理解成:

复制代码
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

这些概念时,基本就能知道它们分别在做什么,而不会只看到大量位运算和链表操作。

0voice · GitHub

相关推荐
David猪大卫1 小时前
【C++修炼】哈希表的实现
数据结构·c++·笔记·学习·算法·哈希算法·散列表
广州山泉婚姻1 小时前
列表初始化:C++11全新初始化体系
c++·人工智能
莫生灬灬1 小时前
灵码 LingBuilder:用中文写代码,确定性生成真实 C++ 工程(开源)
c++·开源·mfc
浅念-1 小时前
C++ Protobuf 入门实战:基于通讯录项目吃透proto3语法与序列化
linux·服务器·网络·c++·protobuf·proto·proto3
汉克老师3 小时前
CSP-J 初赛(以满分为目标):第三十九课《数学与逻辑②——阶乘、排列数与组合数:公式到底从哪里来?》
c++·csp-j·小学生·学c++编程
水饺编程3 小时前
第5章,[Win32 章节] :在平面直角坐标系中描点
c语言·c++·windows·visual studio
linx2953 小时前
单元九 · 零基础路线图-第 7–9 周·类与 RAII
c语言·开发语言·数据结构·c++·嵌入式硬件·算法
布莱克6053 小时前
C++ 七大排序算法详解:选择、冒泡、插入、计数、堆、快速与归并排序
数据结构·c++·算法·排序算法
一只旭宝3 小时前
Python 与 C/C++ 内存模型对比总结
c语言·c++·python
心态还需努力呀3 小时前
把 Flameshot 编译进鸿蒙 PC:一次 Qt C++ 截图工具的源码级移植实战
c++·qt·harmonyos