定时器设计

定时器设计

定时器应用:

  • 游戏的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
  • 第二种 在其他线程添加定时任务
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)

只关心父子节点的大小关系,不关系兄弟之间的大小关系

定时器设计_第1张图片

最小堆利用数组存储(因为是完全树):

20230215204410

索引方式:

定时器设计_第2张图片

效率比红黑高,增删简单,找最小节点快,就是第一个O(1),而红黑树的速度是O(h),最差情况要找h次

增删操作:二者都是log(n),但是最小堆更稳定,因为是完全树,左右子树高度差最大为1,红黑树是相差2倍

时间轮 (kafaka、netty)

kafaka时间轮

单层时间轮:可用来做时间窗口(限流、熔断),以轮的形式进行时间复用

限流和熔断的区别:

比如:5s内只能做100次操作

限流:如tcp滑动窗口

定时器设计_第3张图片 定时器设计_第4张图片

每秒移动一下,反正这个窗口内只能做500次操作

熔断:

定时器设计_第5张图片

先算0-5s内的,再算5-9s内的,这些个时间区间内只能做100次操作

如:nigix可以配置1秒内只接收10个包,否则认为对方在对我进行DDOS攻击

单层时间轮

应用:

  • kv数据库热key检测;
  • 心跳检测:客户端每 5 秒钟发送心跳包;服务端若 10 秒内没收到心跳数据或其他请求,则清除连接

确定时间轮的大小:

比如我的时间轮大小是8,我在5s的时候检测了,那我下次检测时间因该是(5+10)%8 =7 ,那就不对了,本来是隔10s检测的,现在变成隔2s检测了,没有检测到超时事件

定时器设计_第6张图片

但其实,索引为0的位置已经有2个超时事件了

时间轮大小确定方式:2^n>10 ,这里就应该设置为16

时间轮大小不能设置太大(时间精确设置太小也会导致时间轮太大),不然会出现空推进问题,也就是在事件数量较少时,走时间轮的时候,能多地方都是空的。在分布式定时器中需要解决这个问题。 =》 最小堆+单层级时间轮 ,最小堆告诉时间轮下一次要检测的时间,不要一格一格去找了

多层时间轮

当时间跨度很大,精度不能固定时,用多层时间轮,将精度小(最近触发)的放内层,如秒,而分、时 放外层

定时器设计_第7张图片

任务添加方法:

60s内触发的任务放第一层,60s后触发的放第二层,3600后的放第三层,如61s触发的事件就放第二层第一个位置,这里记为A

定时器设计_第8张图片

当时间走啊走,走了50s,也就是11s后A位置的事件要触发了,这时候就将这个事件移动到第一层的第11个位置。

同理,分针层一分钟移动一次,后面的也往前移动,如原本2分后触发的事件现在要往前移动一个位置;时针层一小时移动一次(原本都是一秒移动一次)。这样就解决了空推进的问题,只关注最近发生的事件

第一层0号元素有数据,第二第三都是没数据的,但有的开源框架也有,因为有最大值的限制,比如unint32最大值只能为2^32-1,超过这个数的事件,都放在第0个节点:

定时器设计_第9张图片

多线程使用时间轮的优势

加锁时,锁的粒度小 (就是线程占用锁的时间,时间越小,粒度越小,并发量越高) =》 所以大量数据需要采用时间轮

因为对定时器加锁时,需要锁整个结构,如果采用红黑树和最小堆,时间复杂度时O(log),加锁复杂,但是时间轮的增查都是O(1)操作,取余就行。不能删除,但删除的问题可以在一个事件里添加是否执行的标志,如果被其他线程执行了,就不执行了,直接return

lock(&mtx);
操作数据结构
unlock(&mtx);

你可能感兴趣的:(组件,c++,kafka,数据结构)