【C++】定时器进阶:使用最小堆管理定时任务

一、为什么定时器可以使用最小堆

上一篇中已经了解了定时器最基本的工作过程:

复制代码
添加定时任务
    ↓
记录任务到期时间
    ↓
等待任务到期
    ↓
执行回调函数
    ↓
删除任务

真正需要考虑的问题是:

如果程序中同时存在几百甚至几万个定时任务,怎样快速知道下一个应该执行哪个任务?

例如现在有几个定时任务:

复制代码
任务A:5000ms 后执行
任务B:1000ms 后执行
任务C:8000ms 后执行
任务D:3000ms 后执行

我们最关心的其实不是所有任务,而是:

复制代码
谁最先到期?

这里最先执行的是:

复制代码
任务B:1000ms

而最小堆正好具有一个非常重要的特点:

堆顶元素始终是所有元素中的最小值。

如果把定时任务的:

复制代码
time

作为比较依据,那么:

复制代码
             1000
           /      \
        3000      8000
       /
    5000

堆顶:

复制代码
heap[0]

始终就是最近即将到期的任务。

所以定时器并不需要每次都遍历全部任务,只需要查看堆顶即可。

最小堆常见操作的时间复杂度为:

复制代码
获取最小元素:O(1)
插入任务:O(logN)
删除堆顶:O(logN)
调整任务:O(logN)

因此,当定时任务数量比较多时,最小堆是一种非常适合定时器的数据结构。

二、定时任务和最小堆的数据结构

首先定义一个定时任务:

复制代码
typedef struct timer_entry_s timer_entry_t;

// 定时任务到期后执行的回调函数
typedef void (*timer_handler_pt)(timer_entry_t *ev);

struct timer_entry_s {
    uint32_t time;              // 定时任务到期时间
    uint32_t min_heap_idx;      // 当前任务在最小堆中的位置
    timer_handler_pt handler;   // 定时任务回调函数
    void *privdata;             // 用户自定义数据
};

这里最重要的是三个成员。

第一个:

复制代码
uint32_t time;

保存任务真正的到期时间。

假设当前时间为:

复制代码
10000ms

现在添加一个:

复制代码
3000ms 后执行

的任务,那么:

复制代码
time = 10000 + 3000;

最终:

复制代码
time = 13000ms

也就是说,这里保存的不是"延迟多少毫秒",而是:

真正的到期时间。

第二个成员:

复制代码
timer_handler_pt handler;

保存定时器到期之后需要执行的函数。

例如:

复制代码
void hello_world(timer_entry_t *te) {
    printf("hello world time = %u\n", te->time);
}

当定时器到期以后,就可以通过:

复制代码
te->handler(te);

执行这个回调函数。

第三个成员:

复制代码
uint32_t min_heap_idx;

用于记录当前定时任务在最小堆中的位置。

例如:

复制代码
下标        0       1       2       3

任务      1000    3000    5000    8000

那么:

复制代码
1000任务:min_heap_idx = 0
3000任务:min_heap_idx = 1
5000任务:min_heap_idx = 2
8000任务:min_heap_idx = 3

记录这个位置以后,如果需要删除某个指定定时任务,就不需要重新遍历整个堆去寻找它。

接下来定义最小堆本身:

复制代码
typedef struct min_heap {
    timer_entry_t **p; // 保存定时任务指针的动态数组
    uint32_t n;        // 当前实际元素数量
    uint32_t a;        // 当前数组容量
} min_heap_t;

这里可以简单理解为:

复制代码
min_heap
    │
    ├── p:真正保存任务
    ├── n:现在有多少任务
    └── a:最多能保存多少任务

初始化也非常简单:

复制代码
void min_heap_ctor_(min_heap_t *s) {
    s->p = 0;
    s->n = 0;
    s->a = 0;
}

也就是刚创建时:

复制代码
数组为空
元素数量 = 0
容量 = 0

三、添加定时任务:插入后为什么要向上调整

有了最小堆以后,就可以开始添加定时任务。

首先获取当前时间:

复制代码
static uint32_t current_time() {
    struct timespec ti;
    clock_gettime(CLOCK_MONOTONIC, &ti);

    uint32_t t = (uint32_t)ti.tv_sec * 1000;
    t += ti.tv_nsec / 1000000;

    return t;
}

这里使用:

复制代码
CLOCK_MONOTONIC

单调时钟来计算时间。

它不会因为用户修改系统日期或者系统时间发生跳变,因此非常适合:

复制代码
定时器
超时检测
程序运行时间统计

添加定时器的代码如下:

复制代码
timer_entry_t *add_timer(uint32_t msec, timer_handler_pt callback) {
    // 创建一个新的定时任务
    timer_entry_t *te = (timer_entry_t *)malloc(sizeof(*te));
    if (!te) return NULL;

    memset(te, 0, sizeof(timer_entry_t));

    // 保存回调函数
    te->handler = callback;

    // 到期时间 = 当前时间 + 延迟时间
    te->time = current_time() + msec;

    // 插入最小堆
    if (min_heap_push_(&min_heap, te) != 0) {
        free(te);
        return NULL;
    }

    return te;
}

例如:

复制代码
add_timer(3000, hello_world);

整个过程就是:

复制代码
current_time()
      ↓
假设当前时间 = 10000ms
      ↓
10000 + 3000
      ↓
time = 13000ms
      ↓
插入最小堆

真正负责插入的是:

复制代码
int min_heap_push_(min_heap_t *s, timer_entry_t *e) {
    // 如果容量不够,先扩容
    if (min_heap_reserve_(s, s->n + 1)) return -1;

    // 插入到数组末尾,然后向上调整
    min_heap_shift_up_(s, s->n++, e);

    return 0;
}

这里有一个非常关键的操作:

复制代码
min_heap_shift_up_();

也就是:

向上调整。

为什么需要向上调整?

假设原来的最小堆为:

复制代码
        1000
       /    \
    3000    5000

现在添加一个:

复制代码
2000

首先肯定只能放到数组最后:

复制代码
        1000
       /    \
    3000    5000
    /
  2000

但是现在:

复制代码
2000 < 3000

已经不符合最小堆:

复制代码
父节点 <= 子节点

的规则。

所以需要把 20003000 交换:

复制代码
        1000
       /    \
    2000    5000
    /
  3000

这就是所谓的:

复制代码
向上调整

代码如下:

复制代码
void min_heap_shift_up_(min_heap_t *s, unsigned hole_index, timer_entry_t *e) {
    unsigned parent = (hole_index - 1) / 2;

    // 如果当前任务比父节点更早到期,就继续向上移动
    while (hole_index && s->p[parent]->time > e->time) {
        s->p[hole_index] = s->p[parent];
        s->p[hole_index]->min_heap_idx = hole_index;

        hole_index = parent;
        parent = (hole_index - 1) / 2;
    }

    // 找到正确位置以后放进去
    s->p[hole_index] = e;
    e->min_heap_idx = hole_index;
}

因此添加定时器可以简单理解成:

复制代码
创建任务
   ↓
计算到期时间
   ↓
放到堆尾
   ↓
不断和父节点比较
   ↓
向上调整
   ↓
重新满足最小堆结构

四、查找并执行已经到期的定时任务

最小堆最大的优势,就是可以非常方便地找到:

复制代码
最近即将到期的任务

因为堆顶永远是最小值,所以:

复制代码
timer_entry_t *min_heap_top_(min_heap_t *s) {
    return s->n ? *s->p : 0;
}

实际上就是:

复制代码
return s->p[0];

如果现在有:

复制代码
1000ms
3000ms
5000ms
8000ms

那么:

复制代码
min_heap_top_(&min_heap);

得到的就是:

复制代码
1000ms

因此可以计算距离最近一个任务还有多久:

复制代码
int find_nearest_expire_timer() {
    timer_entry_t *te = min_heap_top_(&min_heap);

    // 当前没有任何定时任务
    if (!te) return -1;

    // 最近任务到期时间 - 当前时间
    int diff = (int)te->time - (int)current_time();

    // 如果已经到期直接返回0
    return diff > 0 ? diff : 0;
}

假设:

复制代码
当前时间 = 10000ms
最近任务 = 13000ms

那么:

复制代码
13000 - 10000 = 3000ms

说明程序最多还可以等待:

复制代码
3000ms

当时间到达以后,就需要执行已经过期的任务:

复制代码
void expire_timer() {
    uint32_t cur = current_time();

    for (;;) {
        // 每次只看堆顶
        timer_entry_t *te = min_heap_top_(&min_heap);

        // 没有定时任务
        if (!te) break;

        // 堆顶还没有到期,后面的任务肯定也没有到期
        if (te->time > cur) break;

        // 执行回调函数
        te->handler(te);

        // 删除已经执行完成的堆顶任务
        min_heap_pop_(&min_heap);

        // 释放任务内存
        free(te);
    }
}

这里:

复制代码
if (te->time > cur) break;

非常重要。

因为堆顶已经是:

复制代码
整个最小堆中到期时间最早的任务

如果连堆顶都还没有到期:

复制代码
堆顶 > 当前时间

那么后面的任务只会更晚,所以根本没有必要继续检查。

删除堆顶以后,同样需要重新维护最小堆结构。

例如原来的堆:

复制代码
        1000
       /    \
    3000    2000
   /
5000

现在删除:

复制代码
1000

一般会先把最后一个元素:

复制代码
5000

移动到堆顶:

复制代码
        5000
       /    \
    3000    2000

这时候显然不满足最小堆结构。

因此需要执行:

复制代码
向下调整

最终变成:

复制代码
        2000
       /    \
    3000    5000

对应的核心代码就是:

复制代码
timer_entry_t *min_heap_pop_(min_heap_t *s) {
    if (!s->n) return 0;

    // 保存原来的堆顶
    timer_entry_t *e = *s->p;

    // 最后一个元素放到堆顶,然后向下调整
    min_heap_shift_down_(s, 0u, s->p[--s->n]);

    // 表示当前任务已经不在堆中
    e->min_heap_idx = -1;

    return e;
}

所以最小堆最重要的两个操作其实就是:

复制代码
插入任务 → 向上调整

删除堆顶 → 向下调整

理解这两个操作以后,最小堆定时器的核心逻辑基本就已经理解了。

五、最小堆定时器与 epoll 配合的完整流程

最后来看定时器真正运行起来时的流程。

首先初始化最小堆:

复制代码
void init_timer() {
    min_heap_ctor_(&min_heap);
}

添加一个 3 秒以后执行的任务:

复制代码
void hello_world(timer_entry_t *te) {
    printf("hello world time = %u\n", te->time);
}

添加:

复制代码
add_timer(3000, hello_world);

然后创建 epoll

复制代码
int epfd = epoll_create(1);
struct epoll_event events[512];

事件循环:

复制代码
for (;;) {
    // 找到距离最近一个定时任务还有多少毫秒
    int nearest = find_nearest_expire_timer();

    // 将这个时间作为 epoll_wait 的超时时间
    int n = epoll_wait(epfd, events, 512, nearest);

    // 处理网络IO事件
    for (int i = 0; i < n; i++) {
        // 处理 socket 读写事件
    }

    // 检查并执行已经到期的定时任务
    expire_timer();
}

这里其实是整个最小堆定时器设计中非常关键的一步。

假设现在:

复制代码
距离最近一个定时任务还有3000ms

那么:

复制代码
epoll_wait(epfd, events, 512, 3000);

最多阻塞:

复制代码
3000ms

会出现两种情况。

第一种:

复制代码
3000ms 内发生网络事件

那么 epoll_wait 提前返回:

复制代码
epoll_wait
    ↓
处理网络事件
    ↓
expire_timer()
    ↓
继续下一轮

第二种:

复制代码
3000ms 内没有任何网络事件

那么 epoll_wait 超时返回:

复制代码
3000ms 到达
    ↓
epoll_wait 返回
    ↓
expire_timer()
    ↓
执行到期任务

因此整个运行过程可以整理成:

复制代码
                添加定时任务
                     ↓
               插入最小堆
                     ↓
              堆顶 = 最近任务
                     ↓
          计算距离到期还有多久
                     ↓
          作为 epoll_wait timeout
                     ↓
              epoll_wait 等待
                 ↙        ↘
           网络事件       超时返回
                 ↘        ↙
                expire_timer
                     ↓
             检查最小堆堆顶
                     ↓
              任务是否到期
                ↙       ↘
              是          否
              ↓           ↓
          执行回调       结束检查
              ↓
           删除堆顶
              ↓
           向下调整
              ↓
          继续事件循环

到这里,一个基于最小堆的定时器基本流程就完整串起来了。

它的核心其实可以浓缩成几句话:

复制代码
1. 每个定时任务保存一个到期时间;
2. 使用最小堆按照到期时间组织任务;
3. 堆顶始终是最近即将到期的任务;
4. 插入任务通过向上调整维护最小堆;
5. 删除堆顶通过向下调整维护最小堆;
6. 将最近任务的剩余时间交给 epoll_wait;
7. epoll_wait 返回以后检查并执行超时任务。

所以学习最小堆定时器时,不需要一开始就陷入大量代码细节。

首先抓住:

复制代码
定时任务
   +
到期时间
   +
最小堆
   +
epoll_wait

这四个核心部分,再去看完整代码,就会清楚很多。

而除了最小堆之外,定时任务还可以使用红黑树、跳表以及时间轮进行管理。它们解决的本质问题相同,但是内部的数据组织方式和适用场景不同,后续再继续分析这些方案之间的区别以及具体实现。

0voice · GitHub

相关推荐
hansang_IR1 小时前
【题解】 [省选联考 2021 A/B 卷] 卡牌游戏
c++·算法
这个DBA有点耶2 小时前
COUNT慢不是因为用了*,是这5个原因——1000万行数据实测+执行计划深度解析
数据库·mysql·算法
贾伟康2 小时前
【口算王|01】HarmonyOS ArkTS 口算题生成实战:按年级、运算类型和难度生成可控题目
算法·harmonyos·arkts·随机生成·口算题
lzx_0022 小时前
C++11(一)
开发语言·c++·算法
529宝宝起名网2 小时前
用 Python 实现名字寓意评分算法:基于 NLP 语义分析的名字内涵深度评估
python·算法·自然语言处理
带多刺的玫瑰3 小时前
Leecode#26刷题之删除有序数组中的重复项
数据结构·算法·leetcode
小七在进步3 小时前
C++入门(2)
java·jvm·c++
en.en..4 小时前
C语言核心解析:#define与typedef本质区别
开发语言·c++·算法
码匠许师傅4 小时前
【设计模式精讲】26.策略模式(Strategy)
c++·设计模式·策略模式·uml