定时器
网络程序经常需要处理的一类事件是定时器事件,服务器程序通常管理着众多定时事件,因此有效低组织这些定时事件,使之能在预期的时间点被触发而不影响服务器的主要逻辑,对于服务器的性能有着至关重要的影响。为此,将每个定时事件分别封装成定时器,并使用某种容器类数据结构,比如链表、排序链表和时间轮,将所有定时器串联起来,以实现对定时事件的统一管理。
在讨论如何组织定时器之前,先要介绍定时的方法。定时是指在一段时间之后触发某段代码的机制,我们可以在这段代码中依此处理所有到期的定时器。换言之,定时机制是定时器得以被处理的原动力。
Linux提供的三种定时方法:
socket选项SO_RCVTIMEO 和SO_SNDTIMEO
SIGALRM信号
I/O复用系统调用的超时参数
Socket选项之SO_RCVTIMEO 和SO_SNDTIMEO
SO_RCVTIMEO设置接收数据超时时间,SO_SNDTIMEO设置发送数据超时时间。这两个选项仅对数据接收和发送相关的socket专用系统调用有效,这些系统调用如下所示:
可见,在程序中可以根据系统调用的返回值以及error来判断超时时间是否已到.进而决定是否开始处理定时任务。
代码(设置connect超时时间)
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int timeout_connect( const char* ip, int port, int time )
{
int ret = 0;
struct sockaddr_in address;
memset( &address, 0,sizeof( address ) );
address.sin_family = AF_INET;
inet_pton( AF_INET, ip, &address.sin_addr );
address.sin_port = htons( port );
int sockfd = socket( PF_INET, SOCK_STREAM, 0 );
assert( sockfd >= 0 );
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 )
{
if( errno == EINPROGRESS )
{
printf( "connecting timeout\n" );
return -1;
}
printf( "error occur when connecting to server\n" );
return -1;
}
return sockfd;
}
int main( int argc, char* argv[] )
{
if( argc < 3 )
{
printf( "Not enough parameters" );
return 1;
}
const char* ip = argv[1];
int port = atoi( argv[2] );
int sockfd = timeout_connect( ip, port, 10 );
if ( sockfd < 0 )
{
return 1;
}
return 0;
}
SIGALRM信号
由alarm和setitimer函数设置的实时闹钟一旦超时,将触发SIGALARM信号。因此可以利用该信号的信号处理函数来处理定时任务。
基于升序链表的定时器
定时器通常至少要包含两个成员:一个超时时间和一个任务回调函数。有时候还可能包含回调函数被执行时需要传入的参数,以及是否重启定时器等信息。如果使用链表作为容器来串联所有的定时器,则每个定时器还要包含指向下一个定时器的指针成员。进一步,如果链表是双向的,则每个定时器还需要包含指向前一个定时器的指针成员。
I/O复用系统调用的超时函数
Linux下的3组I/O复用系统调用都带有超时参数,因此它们不仅能统一处理信号和I/O事件,也能统一处理定时事件。但是由于I/O复用系统调用可能在超时时间到期之前就返回(有I/O事件发生),所以我们如果要利用它们来定时,就需要不断更新定时参数以反映剩余的事件。
/*
*Linux下的3组I/O服用系统调用都带有超时参数,因此它们不仅能统一处理信号和I/O事件,
也能统一处理定时事件。但是由于I/O复用系统调用可能在超时时间到期之前就返回,所以如
果我们要利用它们来定时,就需要不断更新定时参数以反映剩余的时间。
*/
#define TIMEOUT 5000
int timeout = TIMEOUT;
time_t start = time( NULL );
time_t end = time( NULL );
while( 1 )
{
printf( "the timeout is now %d mill-seconds\n", timeout );
start = time( NULL );
int number = epoll_wait( epollfd, events, MAX_EVENT_NUMBER, timeout );
if( ( number < 0 ) && ( errno != EINTR ) )
{
printf( "epoll failure\n" );
break;
}
/*如果epoll_wait成功返回0,则说明超时时间到,此时便可处理定时任务,并重置定时
* 时间*/
if( number == 0 )
{
// timeout
timeout = TIMEOUT;
continue;
}
end = time( NULL );
/*如果epoll_wait返回值大于0,则本次epoll_wait调用持续的时间时(end - start)
*1000ms,我们需要将定时时间timeout减去这段时间,以获得下次epoll_wait调用的超时
参数
*/
timeout -= ( end - start ) * 1000;
/*
* 重新计算之后的timeout值有可能等于0,说明本次epoll_wait调用返回时,不仅有文件描述
* 符就绪,并且其超时时间也刚好到达,此时我们也要处理定时任务,并重置定时时间。
* */
if( timeout <= 0 )
{
// timeout
timeout = TIMEOUT;
}
// handle connections
}
高性能定时器
时间轮
基于排序链表的定时器存在一个问题:添加定时器的效率偏低。下面我们讨论的时间轮解决了这个问题。
上面所示的时间轮,(实线)指针指向轮子上的一个槽slot,它以恒定的速度顺时针转动,每转动一步就指向下一个槽(虚线指针指向的槽),每次转动称为一个滴答(tick),一个滴答时间称为时间轮槽间隔si,它实际上就是心搏时间。该时间轮有N个槽,因此它没转动一周的时间就是N*si。每个槽指向一条定时器链表,每条链表上的定时器具有相同的特征:他们的定时时间相差N*si的整数倍。时间轮正是利用这个关系将定时器散列到不同的链表中。假如现在指针指向槽cs,我们要添加一个定时时间为ti的定时器,则该定时器被插入槽ts对应的链表中:
ts=(cs+(ti/si))%N
基于排序链表的定时器使用唯一的一条链表来管理所有定时器,所以插入操作的效率随着定时器目的增多而降低。而时间轮使用哈希表的思想,将定时器散列到不同的链表中。这样每条链表上的定时器数目都将明显少于原来的排序链表上的定时器数据,插入操作的效率基本不受定时器的数目影响。
很显然,对时间轮而言,要提高定时精度,就要使si值足够小,要提高执行效率,则要求N值足够大(这样的每个槽内的链表中的定时器数目就会减少)。
对时间轮而言,添加一个定时器的时间复杂度是O(1),删除一个定时器的时间复杂度也是O(1),执行一个定时器的时间复杂度是O(n),但实际上执行一个定时器任务的效率比O(n)好的多,因为时间轮将所有的定时器散列到不同的链表上了。时间轮的槽越多,等价散列表的入口越多,从而每条链表上的定时器数量越少。效率提升。
时间堆
时间轮中的定时方案是以固定的频率调用心搏函数tick,并在其中依此检测到期的定时器,然后执行到期定时器上的回调函数。设计定时器的另一种思路是:将所有定时器中超时时间最小的一个定时器的超时值作为心搏间隔。这样,一旦心搏函数tick被调用,超时时间最小的定时器必然到期,我们就可以在tick函数中处理该定时器。然后,再次从剩余的定时器中找出超时时间最小的一个,并将这段最小时间设置为笑一次心搏间隔。如此反复,就实现了较为精确的定时。最小堆很适合处理这种定时方案,而且由于最小堆是一种完全二叉树,可以用数组来组织其中的元素。
对时间堆而言,添加一个定时器的复杂度是O(lgn),删除一个定时器的时间复杂度是O(1),执行一个定时器的时间复杂度是O(1)。因此,时间堆的效率是很高的。