定时器设计
定时器应用:
- 游戏的Buff实现,Redis中的过期任务,Linux中的定时任务等等
- 心跳检测,如服务器接收队列满了,tcp客户端会定时探测是否能够发送数据
定时器数据结构选取要求:
- 需要快速找到到期任务,因此,应该具有
时间有序性
; - 其过期执行、插入(添加定时任务)和删除(取消定时任务)的频率比较高,三种操作效率必须保证
各种数据结构的时间复杂度:
-
最小堆:插入O(logn),删除O(logn),过期expire执行O(1)
-
红黑树:插入O(logn),删除O(logn),过期expire执行O(logn)
-
哈希表+链表(时间轮):插入O(1),删除O(1),过期expire平均执行O(1)(最坏为O(n))
不同开源框架定时器实现方式不一,如,libuv采用最小堆来实现,nginx采用红黑树实现,linux内核和skynet采用时间轮算法实现等等。
其中执行到期任务有两种工作方式:
- 轮询: 每隔一个时间片去查找哪些任务到期
- 睡眠/唤醒:不停查找deadline最近任务,到期执行,否则sleep;sleep期间,任务有改变,线程会被唤醒
定时器和的使用:
- 第一种,网络事件和时间事件在一个线程当中配合使用;例如nginx、redis
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(); // 时间事件处理, 到这里时延很大,怎么办, nginx到时间了会去打断epoll_wait(),红黑树+定时信号
}
- 第二种 在其他线程添加定时任务
void* thread_timer(void * thread_param) {
init_timer();
while (!quit) {
update_timer(); // 更新检测定时器,并把定时事件发送到消息队列中
sleep(t); // 这里的 t 要小于 时间精度
}
clear_timer();
return NULL;
}
pthread_create(&pid, NULL, thread_timer, &thread_param);
红黑树(nginx)
以时间作为key,时间是一样的话,红色树本身就支持key相等,就看你key相等节点放左还放右,最好放右,因为先插入的先执行,放左边
int find_nearest_expire_timer() {
ngx_rbtree_node_t *node;
// 哨兵节点,红黑树是空的(红黑树的叶子节点都指向这个哨兵节点,哨兵节点是黑色的,红黑树的所有叶子节点都是黑色的)
if (timer.root == &sentinel) {
return -1;
}
node = ngx_rbtree_min(timer.root, timer.sentinel);
int diff = (int)node->key - (int)current_time();
return diff > 0 ? diff : 0;
}
最小堆(boost.asio、go、libuv)
只关心父子节点的大小关系,不关系兄弟之间的大小关系
最小堆利用数组存储(因为是完全树):
索引方式:
效率比红黑高,增删简单,找最小节点快,就是第一个O(1),而红黑树的速度是O(h),最差情况要找h次
增删操作:二者都是log(n),但是最小堆更稳定,因为是完全树,左右子树高度差最大为1,红黑树是相差2倍
时间轮 (kafaka、netty)
kafaka时间轮
单层时间轮:可用来做时间窗口(限流、熔断),以轮的形式进行时间复用
限流和熔断的区别:
比如:5s内只能做100次操作
限流:如tcp滑动窗口
每秒移动一下,反正这个窗口内只能做500次操作
熔断:
先算0-5s内的,再算5-9s内的,这些个时间区间内只能做100次操作
如:nigix可以配置1秒内只接收10个包,否则认为对方在对我进行DDOS攻击
单层时间轮
应用:
- kv数据库热key检测;
- 心跳检测:客户端每 5 秒钟发送心跳包;服务端若 10 秒内没收到心跳数据或其他请求,则清除连接
确定时间轮的大小:
比如我的时间轮大小是8,我在5s的时候检测了,那我下次检测时间因该是(5+10)%8 =7 ,那就不对了,本来是隔10s检测的,现在变成隔2s检测了,没有检测到超时事件
但其实,索引为0的位置已经有2个超时事件了
时间轮大小确定方式:2^n>10 ,这里就应该设置为16
时间轮大小不能设置太大(时间精确设置太小也会导致时间轮太大),不然会出现空推进问题,也就是在事件数量较少时,走时间轮的时候,能多地方都是空的。在分布式定时器中需要解决这个问题。 =》 最小堆+单层级时间轮 ,最小堆告诉时间轮下一次要检测的时间,不要一格一格去找了
多层时间轮
当时间跨度很大,精度不能固定时,用多层时间轮,将精度小(最近触发)的放内层,如秒,而分、时 放外层
任务添加方法:
60s内触发的任务放第一层,60s后触发的放第二层,3600后的放第三层,如61s触发的事件就放第二层第一个位置,这里记为A
当时间走啊走,走了50s,也就是11s后A位置的事件要触发了,这时候就将这个事件移动到第一层的第11个位置。
同理,分针层一分钟移动一次,后面的也往前移动,如原本2分后触发的事件现在要往前移动一个位置;时针层一小时移动一次(原本都是一秒移动一次)。这样就解决了空推进的问题,只关注最近发生的事件
第一层0号元素有数据,第二第三都是没数据的,但有的开源框架也有,因为有最大值的限制,比如unint32最大值只能为2^32-1,超过这个数的事件,都放在第0个节点:
多线程使用时间轮的优势
加锁时,锁的粒度小 (就是线程占用锁的时间,时间越小,粒度越小,并发量越高) =》 所以大量数据需要采用时间轮
因为对定时器加锁时,需要锁整个结构,如果采用红黑树和最小堆,时间复杂度时O(log),加锁复杂,但是时间轮的增查都是O(1)操作,取余就行。不能删除,但删除的问题可以在一个事件里添加是否执行的标志,如果被其他线程执行了,就不执行了,直接return
lock(&mtx);
操作数据结构
unlock(&mtx);