文章目录
定时器概述
定时器使用场景
- ⼼跳检测
- 技能冷却
- 武器冷却
- 倒计时
- 优快云文章的定时发布
- 其它需要使⽤定时机制的功能
定时器触发方式
网络事件与定时事件在一个线程中处理
定时器通常是与网络组件一起工作,⽹络事件和时间事件在⼀个线程当中配合使⽤;例如nginx、redis,我们将epoll_wait的第四个参数timeout设置为最近要触发的定时器的时间差,这样就可以兼顾对网络事件的处理,又可以兼顾对时间事件的处理。
while (!quit) {
int now = get_now_time();// 单位:ms
//找出最近要触发的定时器时间
int timeout = get_nearest_timer() - now;
if (timeout < 0) timeout = 0;
int nevent = epoll_wait(epfd, ev, nev, timeout);
for (int i=0; i<nevent; i++) {
//... ⽹络事件处理
}
update_timer(); // 时间事件处理
}
但是epoll_wait毕竟涉及到内核态与用户态的切换,以及网络事件处理的时间开销,所以定时事件就会一段时间的延时了。换句话说,受网络事件处理和系统调用的影响,定时器误差有点大。
网络事件和定时事件在不同线程中处理
大量定时任务怎么处理
如果有大量的定时任务,我们首先要想到用哪一个数据结构去组织这些大量的定时任务。定时器的本质是越近要触发的任务,其优先级越高。也就是说,需要根据时间这个key来排序。那么有序的数据结构有哪些呢?红黑树,最小堆,跳表,时间轮。
- 红黑树(单线程):nginx
- 跳表(单线程):redis
- 最小堆(单线程):libevent,go(最小四叉堆),libev(最小四叉堆);大部分都是用最小堆来实现定时器
- 时间轮(多线程):netty,kafka,skynet,crontab,linux内核
定时器设计
接口设计
// 初始化定时器
void init_timer();
// 添加定时器
Node* add_timer(int expire, callback cb);
// 删除定时器
bool del_timer(Node* node);
// 找到最近要触发的定时任务
Node* find_nearest_timer();
// 更新检测定时器
void update_timer();
// 清除定时器
void clear_timer();
说明:
- find_nearest_timer接口只有在网络事件和定时事件在同一个线程中处理的时候才需要(因为同一个线程中一般是按照时间顺序组织延时任务的,而时间轮中是按执行顺序组织的,所以不需要这个接口),其他几个接口无论是定时器的哪种触发方式都需要
数据结构设计
从接口上我们可以看出定时器数据结构的基本要求:
- 能够快速插入删除结点
- 能够快速找到最小的结点
本文对红黑树与最小堆以及跳表不做详细介绍,主要介绍时间轮
红黑树:插入O(logn),删除O(logn),快速找到最小的结点O(logn)
跳表:插入O(logn),删除O(logn),快速找到最小的结点O(1)
最小堆:插入O(logn),删除O(logn),快速找到最小的结点O(1)
时间轮(哈希表+链表):插入O(1),删除O(1),快速找到最小的结点O(1)(存在踏空问题,后续介绍)
在上面也写过,红黑树和最小堆适用于单线程,其核心原因在于如果在多线程环境下,锁红黑树或者最小堆,那就要锁整个,粒度太大了。而时间轮的时间复杂度可以到O(1),粒度较小,所以时间轮更适合多线程环境。
时间轮
时间轮的概念
从时钟表盘出来,描述此时间轮的时间精度是1秒,时间范围是12小时(即tick的取值范围在0-12小时之内)。
时间轮参考时钟进行理解,秒针tick走一圈,则分针走一格,分针走一圈则时针走一格。随着时间tick的踏步,任务不断的从上层流到下一层,最终流到秒针轮上,当秒针走到对应的格子上时,执行链表内的所有任务。
如图所示,秒针和分钟对应60个格子,秒针每走一步,则执行其对应格子指向链表内的时间任务。比如现在tick=1,要添加一个3s后的任务,则在第4格链表中添加一个任务即可,如果要在60秒后执行一个任务,由于60大于了秒针的范围,则要把该任务放到分钟上。可以看到秒针的时间精度一格是1秒,而分钟的一格时间精度是60秒,时针的一格时间精度是60*60秒。
正因为如此,当分钟指向第一个格子上时,会把其对应的链表任务重新映射到下一层,即秒针。当时针走到11时,会把对应任务重新映射到分针上面。
由此可见,秒针轮保存着即将要执行的任务,而别的轮的时间跨度则越来越大,随着时间的流逝,任务会慢慢的从上层流到秒针轮中进行执行。
注意到上面写的重新映射了吗,这意味着时间轮无法删除任务,那么这个问题该如何解?我们可以添加一个删除标记,在函数回调中根据这个标记判断是否需要处理。
truct timer_node {
struct timer_node *next;
uint32_t expire;
handler_pt callback;
uint8_t cancel;//是否删除
};
设计单层级时间轮
**场景:**客户端每 5 秒钟发送⼼跳包;服务端若 10 秒内没收到⼼跳数据,则清除连接;
**普通做法:*我们假设使⽤ map<int, conn> 来存储所有连接数;每秒检测 map 结构,那么每秒需要遍历所有的连接,如果这个map结构包含⼏万条连接,那么我们做了很多⽆效检测;考虑极端情况,刚添加进来的连接,下⼀秒就需要去检测,实际上只需要10秒后检测就⾏了。
**时间轮做法:**只需检测快过期的连接, 采用hash数组+链表形式,相同过期时间的放入一个数组,因此,每次只需检测最近过期的数组即可,不需要遍历所有。
时间轮需要考虑两个因素,1. 时间轮的大小,2. 时间精度。
时间轮的大小
因为10秒一检测,所以时间轮的大小要大于10。我们一般将时间轮的大小,也就是数组长度,设置为2^n
因为我们总是要进行取余操作,m%n在计算机内部等于m−n∗floor(m/n) ,乘法除法运算效率太低,我们可以通过位运算来优化。
m % 2^n = m & (2^n − 1)
m % 16 = m & 15
时间精度
时间精度就要看业务需求了,目前的需求是以秒为单位,那么时间精度就设置为秒即可
空推进问题
对于单层级的时间轮来说,如果大小设置太大了,就会出现踏空的现象,空推进。这里时间轮设置了1024大小,但是定时任务只有两个,从5到1022都是没有任务的,那么这就是空推进的情况。这是分布式定时器必须要解决的问题,分布式定时器一般都是用单层级时间轮。
那么怎么解决这个问题呢?第一种做法就是使用辅助数据结构最小堆+单层级时间轮。用最小堆告诉tick下一次检测是第几个格子,直接跳跃,而不是一格一格的走。时间精度设置不当也会造成空推进。第二种做法就是多层级时间轮。
多层级时间轮
定时任务时间跨度特别大,有几秒的任务,几个小时的任务,几天的定时任务。那么对于单层级时间轮来说,无论它怎么设置都解决不了这个问题,肯定会出现空推进的问题。
那么我们设计把最近要触发的定时任务放到第一层,几分钟的放到第二层,几个小时的放到第三层…这就是多层级的意思。
我们以时钟这个时间轮来举例
时间轮中的数组是指针数组,因为数据下面要挂链表的。
小于1分钟的任务放在秒针层级,大于1分钟小于1小时的任务放在分针层级,大于1小时小于12小时的任务放在时针层级;时间表盘上是有3个指针的,而我们算法实现的时候只需要1个指针就行(让这一个指针在0-12*3600范围内活动即可),因为我们根据这1个指针就可以算出时针在哪里( ( tick / (60 * 60) ) % 12),分针在哪里( (tick / 60