一、为什么定时器可以使用最小堆
上一篇中已经了解了定时器最基本的工作过程:
添加定时任务
↓
记录任务到期时间
↓
等待任务到期
↓
执行回调函数
↓
删除任务
真正需要考虑的问题是:
如果程序中同时存在几百甚至几万个定时任务,怎样快速知道下一个应该执行哪个任务?
例如现在有几个定时任务:
任务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
已经不符合最小堆:
父节点 <= 子节点
的规则。
所以需要把 2000 和 3000 交换:
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
这四个核心部分,再去看完整代码,就会清楚很多。
而除了最小堆之外,定时任务还可以使用红黑树、跳表以及时间轮进行管理。它们解决的本质问题相同,但是内部的数据组织方式和适用场景不同,后续再继续分析这些方案之间的区别以及具体实现。