第十一章 定时器
定时器容器是容器类数据结构,比如时间轮.
定时器是容器内容纳的一个对象,是对容器事件的封装.
本章主要讨论两种高效的管理定时器的容器:时间轮和时间堆.
在此之前,先介绍定时的方法,Linux提供了三种定时方法:
-
socket选项SO_RCVTIMEO和SO_SNDTIMEO.
-
SIGALRM信号.
-
I/O复用系统调用的超时参数.
本章的标题叫定时器,这是行业内的常用叫法.
11.1 socket选项的SO_RCVTIMEO和SO_SNDTIMEO
前者设置接收数据超时时间,后者设置发送数据超时时间.
这两个选项仅对数据接收和发送相关API有效.影响如下表所示:
系统调用 | 有效选型 | 系统调用超时后的行为 |
---|---|---|
send | SO_SNDTIMEO | 返回-1,设置error为EAGAIN或EWOULDBLOCK |
sendmsg | SO_SNDTIMEO | 返回-1,设置error为EAGAIN或EWOULDBLOCK |
recv | SO_RCVTIMEO | 返回-1,设置error为EAGAIN或EWOULDBLOCK |
recvmsg | SO_RCVTIMEO | 返回-1,设置error为EAGAIN或EWOULDBLOCK |
accept | SO_RCVTIMEO | 返回-1,设置error为EAGAIN或EWOULDBLOCK |
connect | SO_SNDTIMEO | 返回-1,设置error为EINPROGRESS |
可见,在程序中可以根据系统调用的返回值以及error来判断超时时间是否已到.进而决定是否开始处理定时任务.
/*通过选项SO_RCVTIMEO和SO_SNDTIMEO所设置的超时时间的类型timeval,这和select系统调用
* 的超时参数类型相同
*/
struct timeval timeout;
timeout.tv_sec = time;
timeout.tv_usec = 0;
socklen_t len = sizeof(timeout);
ret = setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, len);
assert(ret != -1);
ret = connect(sockfd, (struct sockaddr *)&address, sizeof(address));
if (ret == -1)
{
/*超时对应的错误是EINPROGRESS,下面这个条件如果成立,
* 我们就可以处理定时任务了
*/
if (errno == EINPROGRESS)
{
fprintf(stderr, "connecting timeout process timeout logic
");
return -1;
}
fprintf(stderr, "error occur when connecting to server
");
return -1;
}
-
超时时间类型是timeval,这和select系统调用的超时参数类型相同.
-
注意到此时connect是阻塞的.
11.2 SIGALRM 信号
由alarm和setitimer函数设置的定时闹钟一旦超时将触发SIGALRM信号.
一般而言,SIGALRM信号按照固定的频率生成,即由alarm或setitimer函数设置的定时周期T保持不变.
服务器程序通常定期处理非活动连接,处理结果包括给客户端发送一个重连请求,关闭该连接或者其他.虽然Linux在内核中提供了对连接是否处于活动状态的定期检查机制,不过会使得应用程序对连接的管理变得复杂.因此可以考虑在应用层实现类似于KEEPALIVE的机制,以管理所有处于非活动状态的连接.
下面这个例子利用ALARM函数周期性的出发SIGALRM信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务,即关闭非活动的连接.
-
创建管道监听信号以及设置信号处理函数
int epollfd = epoll_create(5);
addfd(epollfd, listenfd);
//创建管道
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
setnonblocking(pipefd[1]);
//监听信号
addfd(epollfd, pipefd[0]);//设置信号处理函数
addsig(SIGALRM);
addsig(SIGTERM); -
设置定时
alarm(TIMESLOT); // #define TIMESLOT 5
-
处理新用户连接
if (sockfd == listenfd)
{
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_addrlength);
int connfd = accept(listenfd, (struct sockaddr *)&client_address,
&client_addrlength);
addfd(epollfd, connfd);
users[connfd].address = client_address;
users[connfd].sockfd = connfd;/*创建定时器, 设置其回调函数与超时时间, * 然后绑定定时器与用户数据, * 最后将定时器添加到链表timer_lst中*/ util_timer *timer = new util_timer; timer->user_data = &users[connfd]; timer->cb_func = cb_func; time_t cur = time(NULL); timer->expire = cur + 3*TIMESLOT; users[connfd].timer = timer; timer_lst.add_timer(timer);
}
-
若有信号则处理信号,只会处理之前设置的信号(SIGALRM和SIGTERM)
else if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN))
{
char signals[1024];
ret = recv(pipefd[0], signals, sizeof(signals), 0);for(int i = 0; i < ret; i++) { switch(signals[i]) { case SIGALRM: { /*用timeout变量标记有定时任务需要处理, * 但不立即处理定时任务, * 这是因为定时任务的优先级不是很高, * 我们优先处理其他更重要的任务*/ timeout = true; break; } case SIGTERM: { stop_server = true; } } }
}
-
若接收到了客户端数据则刷新连接状态(更新时间)或者关闭连接等
else if ( events[i].events & EPOLLIN )
{
memset( users[sockfd].buf, ' ', BUFFER_SIZE - 1 );
ret = recv( sockfd, users[sockfd].buf, BUFFER_SIZE - 1, 0 );
fprintf( stdout, "get %d bytes of client data %s from %d ", ret,
users[sockfd].buf, sockfd );util_timer *timer = users[sockfd].timer; if ( ret < 0 ) { /*如果发生错误,则关闭连接,并移除其对应的定时器*/ if ( errno != EAGAIN ) { cb_func( &users[sockfd] ); if ( timer ) { timer_lst.del_timer( timer ); } } }else if ( ret == 0 ) { /*如果对方已经关闭连接,则我们也关闭连接,并移除对应的定时器*/ cb_func( &users[sockfd] ); if ( timer ) { timer_lst.del_timer( timer ); } }else { /*如果某个客户连接上有数据可读, * 则我们要调整该连接对应的定时器, * 以延迟该连接被关闭的时间 */ if ( timer ) { time_t cur = time( NULL ); timer->expire = cur + 3 * TIMESLOT; fprintf( stdout, "adjust timer once " ); timer_lst.adjust_timer( timer ); } }
}
-
处理定时结果
/*最后处理定时事件,
- 因为I/O事件有更高的优先级。当然,这样做将导致定时任务不能精确
- 地按照预期的时间执行
*/
if (timeout)
{
timer_handler();
timeout = false;
}
-
上面的代码我去掉了相关的异常判断,只关注了逻辑部分.
-
关于timer_lst,它是一个双向循环链表,按照超时时间(time_t expire)进行的升序排序.
以下是util_timer的数据结构
class util_timer
{
public:
util_timer():prev(NULL), next(NULL){}
public:
time_t expire; /*任务的超时时间,这里使用绝对时间*/
void (*cb_func)(client_data *); /*任务回调函数*/
/*回调函数处理的客户数据,由定时器的执行者传递给回调函数*/
client_data *user_data;
util_timer *prev;
util_timer *next;
};
我总结了一下我对这个的理解(从信号的接收角度):
-
使用alarm来触发超时信号(alarm(TIMESLOT)).
-
检测是否接收到了此信号. (epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1))
-
若接收到了此信号,启动定时器容器内部的tick函数对超时连接进行清除.(timer_lst.tick())
-
若没有接收到此信号,则刷新当前连接的超时时间.(timer->expire = cur + 3*TIMESLOT)
-
(回到2)
11.3 I/O复用系统调用的超时参数
由于I/O复用系统调用可能在超时时间到期之前就返回(发生了I/O事件),所以我们若要利用他们来定时,就要不断更新定时参数以反映剩余时间.
printf( "the timeout is now %d mil-seconds
", timeout );
start = time( NULL );
int number = epoll_wait( epollfd, events, MAX_EVENT_NUMBER, timeout );
if ( (number < 0) && (errno != EINTER) )
{
printf( "epoll failure
" );
break;
}
/* 如果epoll_wait成功返回0, 则说明超时时间到,此时便可处理定时任务,
* 并重置定时时间
*/
if ( number == 0 )
{
timeout = TIMEOUT;
continue;
}
end = time( NULL );
/*如果epoll_wait的返回值大于0, 则本次epoll_wait调用持续的时间是(end - start)*1000 ms,
* 我们需要将定时时间timeout减去这段时间,以获得下次epoll_wait调用的超时参数
*/
timeout -= (end - start) * 1000;
/*重新计算之后的timeout值可能等于0, 说明本次epoll_wait调用返回时,
* 不仅有文件描述就绪,而且其超时时间也刚好到达,此时我们也要定时任务,并重置定时时间
*/
if ( timeout <= 0 )
{
timeout = TIMEOUT;
}
11.4 高性能定时器
前面主要讲解了如何使用Linux提供的三种定时方法,这三种方法分别是socket中的属性,SIGALRM信号和I/O复用与的超时参数.定时器允许在将来的某一时刻执行我们预先设定的操作。在稍微复杂点的程序中,定时器通常会被广泛使用。
实现一个简单的定时器并不难。每个定时器都包含一个字段,表示定时器需要多长时间到期,当程序检查定时器时,根据时间判断定时器是否到期。如果已到期,则执行相应的操作并删除定时器,否则不做任何处理。
定时器的常见操作有3个:添加定时器, 检查定时器是否到期, 删除定时器。
最基本的就是上面看到的基于升序链表的实现,来看看它的时间复杂度表现:
插入 | 删除 | 执行定时任务 |
---|---|---|
O(N) | O(1) | O(1) |
可见随着定时器对象的逐渐增加,插入的性能呈线性下降,这引出了这一章的话题,高性能的定时器.他们分别是
-
时间轮
-
时间堆
来看看他们的表现:
时间轮 | 插入 | 删除 | 执行定时任务 |
---|---|---|---|
时间复杂度 | O(1) | O(1) | O(N) |
时间堆 | 插入 | 删除 | 执行定时任务 |
---|---|---|---|
时间复杂度 | O(lgN) | O(1) | O(1) |
相比基于升序链表的表现要好得多.
11.5 时间轮
下图描述的是一种简单的时间轮,因为它只有一个轮子,而复杂的时间轮可能有多个轮子.轮子其实可视为一个环形链表.
轮子的每次转动称为时间轮的一个tick,tick的目的是检查是否有要被触发的事件,一个tick的时间间隔为si(slot interval),该时间轮有N个槽,因此转一周的时间是N*si.并且每个槽指向一条定时器链表,每条链表上的定时器具有相同的特征:它们的定时时间相差N*si的整数倍.而时间轮正是利用了这个关系将定时器散列到不同的链表中.假设现在指针指向cs,我们要添加一个定时时间为ti的定时器,则该定时器将被插入槽ts(timer slot)对应的链表中:
ts = (cs + (ti%si))&N
显然,对时间轮而言,要提高定时精度,就要使si值足够小:要提高执行效率,则要使得N值足够大.
-
添加一个时间轮 重点在于根据时间找到槽的位置 和 在第几轮会被触发.
tw_timer* add_timer(int timeout)
{
if (timeout < 0)
{
return NULL;
}
int ticks = 0;/*下面根据待插入定时器的超时值计算它将在时间轮转动多少个滴答后被触发, * 并将该滴答数存储于变量ticks中。如果待插入定时器的超时值小于时间轮的槽间隔SI, * 则将ticks向上折合为1,否则就将ticks向下折合为timeout/SI */ if (timeout < SI) { ticks = 1; } else { ticks = timeout / SI; } //计算待插入的定时器在时间轮转动多少圈后被触发 int rotation = ticks / N; //计算待插入的定时器应该被插入哪个槽中 int ts = (cur_slot + (ticks % N)) % N; //创建新的定时器,它在时间轮转动rotation圈之后被触发,且位于第ts个槽上 tw_timer *timer = new tw_timer(rotation, ts); //如果第ts个槽中尚无任何定时器,则把新建的定时器插入其中, //并将该定时器设置为该槽的头结点 if (!slots[ts]) { fprintf(stdout, "add timer, rotation is %d, ts is %d, cur_slot is %d ", rotation, ts, cur_slot); slots[ts] = timer; } //否则,将定时器插入第ts个槽中 else { timer->next = slots[ts]; slots[ts]->prev = timer; slots[ts] = timer; } return timer;
}
-
删除定时器
void del_timer(tw_timer *timer)
{
if (!timer)
{
return;
}
int ts = timer->time_slot;
// slots[ts]是目标定时器所在槽的头结点。如果目标定时器就是该头结点,
// 则需要重置第ts个槽的头结点
if (timer == slots[ts])
{
slots[ts] = slots[ts]->next;
if (slots[ts])
{
slots[ts]->prev = NULL;
}
delete timer;
}
else
{
timer->prev->next = timer->next;
if (timer->next)
{
timer->next->prev = timer->prev;
}
delete timer;
}
} -
tick函数检查是否有触发事件,SI 时间到后,调用该函数,时间轮向前滚动一个槽的间隔
void tick()
{
tw_timer *tmp = slots[cur_slot]; //取得时间轮上当前槽的头结点
fprintf(stdout, "current slot is %d ", cur_slot);
while (tmp)
{
fprintf(stdout, "tick the timer once ");
//如果定时器的rotation值大于0, 则它在这一轮不起作用
if (tmp->rotation > 0)
{
tmp->rotation --;
tmp = tmp->next;
}
//否则, 说明定时器已到期,于是执行定时任务,然后删除该定时器
else
{
tmp->cb_func(tmp->user_data);
if (tmp == slots[cur_slot])
{
fprintf(stdout, "delete header in cur_slot ");
slots[cur_slot] = tmp->next;
delete tmp;
if (slots[cur_slot])
{
slots[cur_slot]->prev = NULL;
}
tmp = slots[cur_slot];
}
else
{
tmp->prev->next = tmp->next;
if (tmp->next)
{
tmp->next->prev = tmp->prev;
}
tw_timer *tmp2 = tmp->next;
delete tmp;
tmp = tmp2;
}
}
}
可见,时间轮使用了哈希表的思想,将定时器散列到不同的链表上.这样每条链表上的定时器数目都将明显少于原来的排序链表上的定时器数目,插入的效果基本不受定时器数目的影响(几乎).时间轮上的槽越多,等价于散列表的入口越多,从而每条链表上的定时器数量越少,如此一来,执行一个定时器任务的时间复杂度将接近于O(1).
时间轮 | 插入 | 删除 | 执行定时任务 |
---|---|---|---|
时间复杂度 | O(1) | O(1) | O(N) |
对了,时间轮其实就是哈希链表中的链地址法.
11.6 时间堆
时间堆的思路和其他的有点不太一样,它是将所有定时器中时间最小的一个定时器的超时值作为si,这样一旦tick被调用,超时时间最小的定时器必然到期,我们就可以在tick函数中处理该定时器.然后,再次从剩余的定时器中找出超时时间最小的一个,并将这段最小时间设置为下一次的si,如此反复.
按照这样的思路,最小堆肯定是个不错的选择.
这里就把tick函数贴出来吧,因为其他的都涉及到了数据结构中堆的相关操作.
void tick()
{
heap_timer *tmp = array[0];
time_t cur = time(NULL); //循环处理堆中到期的定时器
while(!empty())
{
if (!tmp)
{
break;
}
//如果堆顶定时器没有到期,则退出循环
if (tmp->expire > cur)
{
break;
}
//否则就执行堆顶定时器中的任务
if (array[0]->cb_func)
{
array[0]->cb_func(array[0]->user_data);
}
//将堆顶元素删除,同时生成新的堆顶定时器(array[0])
pop_timer();
tmp = array[0];
}
}
再看看时间堆的时间复杂度表现:
时间堆 | 插入 | 删除 | 执行定时任务 |
---|---|---|---|
时间复杂度 | O(lgN) | O(1) | O(1) |
关于第十一章的总结
-
了解到了定时器的基本操作,添加定时器, 检查定时器是否到期以触发其事件, 删除定时器.
-
关于定时器的实现有时间轮和时间堆两种.
-
在Linux中起到定时方法的有socket中的属性,SIGALRM信号,还有I/O复用的时间参数.
-
数据结构的作用就体现出来了,时间轮其实就是散列表的链地址法,时间堆就是最小堆.
From
Aaron-z/linux-server-high-performance
2017/2/10 19:01:48