定时器概述
对于服务端来说,驱动服务端逻辑的事件主要有两个,⼀个是⽹络事件,另⼀个是时间事件;
在不同框架中,这两种事件有不同的实现⽅式;
第⼀种,⽹络事件和时间事件在⼀个线程当中配合使⽤, 将定时器中最近的时间,设置为epoll_wait的timeout参数;例如nginx、redis;
第⼆种,⽹络事件和时间事件在不同线程当中处理;例如linux crontab, skynet;
// 第⼀种
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(); // 时间事件处理
}
// 第⼆种 在其他线程添加定时任务
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);
定时器误差大怎么解决
对于第一种方式,如果网络事件的处理时间很长,影响了定时器事件的处理,造成定时器误差很大,怎么解决?
定时信号解决。定时发送信号,会打断epoll_wait
https://www.cnblogs.com/li-hao/archive/2013/04/23/3038542.html
定时器设计
定时器本质是越近要触发的定时任务,它的优先级越高。
定时器需要包含以下方法:
-
初始化定时器
-
添加定时任务
-
删除定时任务
-
检测定时任务
-
销毁定时器
定时器数据结构选择
定时器的数据结构,必须满足以下两点:
- 能够快速进行增加删除操作;
- 能够快速找到最小的节点。
以下数据结构可以实现定时器:
- 最小堆
对于增查删,时间复杂度为o(logn); 对于最小节点(即根节点)的查找复杂度是o(1)。
- 红黑树
增查删时间复杂度都是o(logn); 对于最小节点的查找时间复杂度是o(h), h是红黑树高度。
- 跳表
先不讨论skiplist,后面redis中会具体介绍skiplist。
- 时间轮
时间轮分为单层级和多层级时间轮。
红黑树
关于红黑树的实现,可以参考rbtree实现。
怎么解决相同时间的key?
红黑树并没有要求key不能相同。对于key相同的情况,新节点直接加入到原节点的右子树。nginx红黑树实现就是这样的。
void
ngx_rbtree_insert_value(ngx_rbtree_node_t *temp, ngx_rbtree_node_t *node,
ngx_rbtree_node_t *sentinel)
{
ngx_rbtree_node_t **p;
for ( ;; ) {
p = (node->key < temp->key) ? &temp->left : &temp->right;
if (*p == sentinel) {
break;
}
temp = *p;
}
*p = node;
node->parent = temp;
node->left = sentinel;
node->right = sentinel;
ngx_rbt_red(node);
}
void
ngx_rbtree_insert_timer_value(ngx_rbtree_node_t *temp, ngx_rbtree_node_t *node,
ngx_rbtree_node_t *sentinel)
{
ngx_rbtree_node_t **p;
for ( ;; ) {
/*
* Timer values
* 1) are spread in small range, usually several minutes,
* 2) and overflow each 49 days, if milliseconds are stored in 32 bits.
* The comparison takes into account that overflow.
*/
/* node->key < temp->key */
p = ((ngx_rbtree_key_int_t) (node->key - temp->key) < 0)
? &temp->left : &temp->right;
if (*p == sentinel) {
break;
}
temp = *p;
}
*p = node;
node->parent = temp;
node->left = sentinel;
node->right = sentinel;
ngx_rbt_red(node);
}
stl的map则不允许有重复的key,如果key相同,则更新value值。实现类似下面这样。
void
ngx_rbtree_insert_value(ngx_rbtree_node_t *temp, ngx_rbtree_node_t *node,
ngx_rbtree_node_t *sentinel)
{
ngx_rbtree_node_t **p;
for ( ;; ) {
if (node->key == temp->key) {
temp->data = node->data;
return;
}
p = (node->key < temp->key) ? &temp->left : &temp->right;
if (*p == sentinel) {
break;
}
temp = *p;
}
*p = node;
node->parent = temp;
node->left = sentinel;
node->right = sentinel;
ngx_rbt_red(node);
}
红黑树增删查复杂度是o(logn),查找最左侧节点复杂度是o(h)
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;
}
static ngx_rbtree_node_t *
ngx_rbtree_min(ngx_rbtree_node_t *node, ngx_rbtree_node_t *sentinel)
{
while (node->left != sentinel) {
node = node->left;
}
return node;
}
最小堆
定义
满二叉树:所有层的节点数都是该层所能容纳节点的最大数量(2^n; n >= 0);
完全二叉树:若二叉树的深度为h,除了h层外,其他层都是该层所能容纳节点的最大数量(2^n; n >= 0), 且h层的节点都集中在最左侧;
最小堆:
-
是一棵完全二叉树;
-
每一个节点的值都小于等于它的子节点;
-
每一棵子树都是最小堆。
这个是最小二叉堆。大部分定时器都使用最小堆。libevent使用最小二叉堆,go、libev使用最小四叉堆。在大量数据情况下,最小四叉堆比最小二叉堆性能大概提高5%。
最小堆通常使用数组来实现,对于下标为x的节点,它的左子树是2x+1,右子树是2x+2,父节点是floor((x-1)/2)
增加操作
为了满足完全二叉树的定义,往二叉树最深层沿着最左侧添加一个节点;然后考虑是否需要进行上升操作。
例如,添加值为4的节点,将4节点插入到5节点的左子树位置,4小于5,4节点和5节点需要交换位置。
删除操作
删除操作需要先查找是否包含这个节点,最小堆的查找复杂度是o(logn),最差情况是o(logn);找到之后,将该节点与最后一个节点交换,先考虑下降操作,如果失败则进行上升操作;最后删除最后一个节点。
例如,删除1号节点,则需要下沉;如果删除9号节点,则需要上升。
最小堆只关心父子的大小关系,不关心兄弟的大小关系,实现要比红黑树简单。
最小堆增删的复杂度都是o(logn), 查找最小节点的复杂度是o(1),最小堆是完全二叉树,相对于红黑树更加平衡,时间复杂度比红黑树稳定。更适合用于定时器。
时间轮
java netty网络库、kafka、skynet、linux crontab都是用时间轮。
红黑树、最小堆在单线程中使用;时间轮在多线程环境中使用。
单层级时间轮
单层级时间轮通常用来实现时间窗口
限流,是动态的,有一个滑动窗口,就是时间窗口;
熔断,是与限流对应的,窗口时固定的。前一个窗口的结束位置,是下一个窗口的开始位置。
怎么防止DDOS攻击?
- 网络底层 DPDK,复杂算法;这种方法是最优的。
- 应用层,nginx配置1秒内只能发10次包。限制流量。用的是熔断操作。
时间轮需要考虑两个因素:
- 时间轮的大小;
- 时间精度。
例如,5秒一个心跳包,10秒没有收到心跳包就断开连接。
时间轮的大小要选择2^n, 刚好大于10,所以选择16.
x % 16 = x & (16 - 1)
时间精度是1秒。
数据结构是数组 + 链表,链表可以优化为红黑树或者最小堆。
一般不会用单层级时间轮,实际使用为每个连接分配一个定时任务就可以了。
单层级时间轮如果使用不当,会有问题。
如果时间轮设置太大,就会出现踏空的现象(空推进);空推进是分布式定时器必须要解决的问题。分布式定时器大部分是使用的单层级时间轮 + 最小堆。最小堆能告诉时间轮下次检测的是哪个格子,解决了空推进的问题。kafka使用最小堆解决空推进问题。后面会讲分布式定时器。
如果时间精度设置太小,也会出现空推进。
多层级时间轮
多层级时间轮参考了表盘的原理,实现一个3层级的的时间轮。第1层级是秒针,第2层级是分针,第2层级是时针。即将要执行的任务都放在第一层。
对于这个时间轮,大小就是60 * 60 * 12秒,精度就是1秒。
多层级时间轮解决了空推进的问题。
添加任务的时候,怎么决定放在哪一层?
触发时间与当前时间间隔小于60,放在第1层级;间隔大于等于60,放在第2层级;间隔大于60*60,则放在第3层级。
在第1层级每1秒移动一格,格子为0-59;每移动一格,执行该格子中的所有定时任务;当第1层指针从59格开始移动,此时层级2移动一格;层级2移动一格的行为定义为,将该格子当中的定时任务重新映射到层级1当中;同理,当第2层指针从59格开始移动,层级3中的定时任务将重新映射到层级2中。
如何重新映射?
(定时任务的过期时间 - 当前时间)% (上一层的长度)
得到映射到上一层中的具体位置。
第一层0号索引有数据,后面层级的0号索引是没有数据的
为什么很多开源时间轮, 最后一层0号位置有数据?
如果定时任务超过时间轮的大小了,则会放到最后一层的0号位置。
时间轮增加查找复杂度都是o(1),锁的粒度小,用空间换时间。
所以多线程中,使用时间轮。
3层级。
在第1层级每1秒移动一格,格子为0-59;每移动一格,执行该格子中的所有定时任务;当第1层指针从59格开始移动,此时层级2移动一格;层级2移动一格的行为定义为,将该格子当中的定时任务重新映射到层级1当中;同理,当第2层指针从59格开始移动,层级3中的定时任务将重新映射到层级2中。
如何重新映射?
(定时任务的过期时间 - 当前时间)% (上一层的长度)
得到映射到上一层中的具体位置。
第一层0号索引有数据,后面层级的0号索引是没有数据的
为什么很多开源时间轮, 最后一层0号位置有数据?
如果定时任务超过时间轮的大小了,则会放到最后一层的0号位置。
时间轮增加查找复杂度都是o(1),锁的粒度小,用空间换时间。
所以多线程中,使用时间轮。