在前文说道,服务端编程中主要涉及到三类事件:IO事件、定时(一次或多次)事件以及信号事件。在这里将介绍常用的三类处理方式,并分析Redis选择哪种。
常见定时方法
SIGALRM信号
原理
调用alarm或者settimer函数,他在指定超时期满时会产生SIGALRM信号。我们为其建立一个信号处理函数,执行相应的处理逻辑。
代码
- 使用alarm实现带超时的connect
static
- alarm(0)表示取消alarm信号
- Redis使用settimer实现WatchDog
- 函数原型
#include
- 特点
setitimer 能够在 Timer 到期之后,自动再次启动自己,因此,用它来解决 Single-Shot Timer 和 Repeating Timer 的问题显得很简单。
- 具体代码
void
分析
- 优点
可以看出使用SIGALRM实现简单。
- 缺点
信号属于异步事件,在多线程条件下,还需考虑信号重入问题。因此我们并不推荐使用。
使用socket选项
原理
借助socket的SO_RCVTIMEO和SO_SNDTIMEO选项,分别设置socket接受超时时间和发送数据超时时间。其中常见API支持该选项的情况如下:
代码
void
分析
- 优点
使用简单。只需一次设置。
- 缺点
- 使用场景有限,仅支持socket超时
- 并非所有实现都支持
使用IO复用
原理
Linux下的3组IO复用函数都带有超时参数,因此他不仅能统一处理信号和IO事件,也可以处理定时事件。但是因为IO复用事件触发可能在超时时间到期之前就返回(如有可读可写事件),因此我们需要不断更新定时参数以反映剩余时间。
/*select系列*/
- select中timeval数据结构如下
struct
- 对于select而言
1
- 对于poll和epoll而言
如果
代码
int
分析
- 优点
将定时事件统一由IO复用管理,实用而方便。实际中也是使用这个方法。
- 缺点
暂无
常见定时任务管理方法
在服务端的网络模型中,定时任务主要包括时间(相对或者绝对均可——redis采用的是绝对时间)以及回调函数,尽管不同放大,但是思想基本相同。
无序链表-Redis为例
数据结构
typedef
操作
- 创建
redis中最重要的定时函数且是周期执行的函数,就是大名鼎鼎的serverCron函数。在redis中由于定时任务比较少,因此并没有严格的按照过期时间来排序的,而是按照id自增+头插法来保证基本有序。
if
- 触发
redis中是采用IO复用来进行定时任务的。
- 查找距离现在最近的定时事件,见aeSearchNearestTimer
static
这里时间复杂度可能比较高,实际中需要结合具体场景使用。
- 更新剩余过期时间,想想为啥呢?因为我们前面提到过,io复用有可能因为IO时间返回,所以需要更新。
if
- 执行定时事件
一次性的执行完直接删除,周期性的执行完在重新添加到链表。
/* Process time events */
优缺点
- 优点
实现简单
- 缺点
如果定时任务很多,效率比较低。
升序链表
参照上述无序列表
时间轮
时间轮使用了哈希表的思想,将定时器散列在不同的链表上,这样可以保证每条链表上的定时器数少于排序链表上的定时器数量,其基本思想如下:
- si:表示一个时间周期,即心博时间
- N:表示槽总数。转动一周时间为N*si
- cs:表示当前指向的槽。则添加一个定时器ts=(cs+(ti%si))%N
数据结构
struct
操作
- 添加
tw_timer
- 触发
void
优缺点
- 优点
- 时间轮使用哈希表的思想,优化了纯链表带来的插入性能较低问题。
- 容易扩展,当需要精细精度时,我们可以采用多层时间轮,参照kafka的三层时间轮。
- 缺点
- 只能以固定频率转动,若要支持不同精度的定时器,单个时间轮可能会造成溢出或者耗费大量内存。因此需要引入多层,但却加大了实现的难度。
时间堆
根据前面描述,我们很容易联想到时间堆,而且还是小根堆。
数据结构
typedef
操作
- 添加
int
- 触发
if
pop_timeout函数
int
分析
- 优点
支持各种精度的定时器。
- 缺点
- 定时任务过多,插入和删除时间复杂度可能比较高。此时可以使用多叉树进行优化。
扩展
除了上述几种方法,还有Nginx支持的红黑树,Redis的zset等等均可。
总结
- 在常见的定时方法中,推荐使用IO复用,因为可以进行统一处理。
- Redis中由于定时任务比较少,而且是单线程,所以直接就采用链表,且基本有序。
- 项目中推荐使用IO复用+时间堆的方式。
- 如果定时任务很多,可以参考kafka的多层时间轮。