Redis源码分析09——定时事件

1,248 阅读15分钟

在前文说道,服务端编程中主要涉及到三类事件:IO事件、定时(一次或多次)事件以及信号事件。在这里将介绍常用的三类处理方式,并分析Redis选择哪种。

常见定时方法

SIGALRM信号
原理

调用alarm或者settimer函数,他在指定超时期满时会产生SIGALRM信号。我们为其建立一个信号处理函数,执行相应的处理逻辑。

代码
  1. 使用alarm实现带超时的connect
12345678910111213141516171819202122232425262728293031323334353637
static void	connect_alarm(int);intconnect_timeo(int sockfd, const SA *saptr, socklen_t salen, int nsec){	Sigfunc	*sigfunc; //信号处理函数Signal的第二个参数,和返回值	int		n;	sigfunc = Signal(SIGALRM, connect_alarm); //注册定时器alarm的信号处理函数为connect_alarm	if (alarm(nsec) != 0) //调用alarm函数发送alarm信号		err_msg("connect_timeo: alarm was already set"); //已经推翻了以前设置的alarm信号设置	if ( (n = connect(sockfd, saptr, salen)) < 0) { //调用connect连接服务器		close(sockfd); //失败了就关闭套接字(防止三路握手继续进行),并设置error值		if (errno == EINTR)			errno = ETIMEDOUT;	}	alarm(0);					/* turn off the alarm 取消alarm信号 */	Signal(SIGALRM, sigfunc);	/* restore previous signal handler 把alarm信号改为原来的处理函数 */	return(n);}static voidconnect_alarm(int signo) //打断connect调用后直接返回{	return;		/* just interrupt the connect() */}/* end connect_timeo */voidConnect_timeo(int fd, const SA *sa, socklen_t salen, int sec){	if (connect_timeo(fd, sa, salen, sec) < 0)		err_sys("connect_timeo error");}
  • alarm(0)表示取消alarm信号
  1. Redis使用settimer实现WatchDog
  • 函数原型
1234567891011121314
#include <sys/time.h>   int setitimer(int which, const struct itimerval *new_value,struct itimerval *old_value);struct itimerval {  struct timeval it_interval; /* next value */  struct timeval it_value;     /* current value */  };   struct timeval {         long tv_sec;                /* seconds */         long tv_usec;               /* microseconds */  };
  • 特点

setitimer 能够在 Timer 到期之后,自动再次启动自己,因此,用它来解决 Single-Shot Timer 和 Repeating Timer 的问题显得很简单。

  • 具体代码
1234567891011121314151617181920212223242526272829303132
void enableWatchdog(int period) {    int min_period;    if (server.watchdog_period == 0) {        struct sigaction act;        /* 注册SIGALRM信号处理函数 */        sigemptyset(&act.sa_mask);        act.sa_flags = SA_ONSTACK | SA_SIGINFO;        act.sa_sigaction = watchdogSignalHandler;        sigaction(SIGALRM, &act, NULL);    }    /* If the configured period is smaller than twice the timer period, it is     * too short for the software watchdog to work reliably. Fix it now     * if needed. */    min_period = (1000/server.hz)*2;    if (period < min_period) period = min_period;    watchdogScheduleSignal(period); /* Adjust the current timer. */    server.watchdog_period = period;}void watchdogScheduleSignal(int period) {    struct itimerval it;    /* Will stop the timer if period is 0. */    it.it_value.tv_sec = period/1000;    it.it_value.tv_usec = (period%1000)*1000;    /* Don't automatically restart. */    it.it_interval.tv_sec = 0;    it.it_interval.tv_usec = 0;    //这里调用settimer函数    setitimer(ITIMER_REAL, &it, NULL);}
分析
  1. 优点

可以看出使用SIGALRM实现简单。

  1. 缺点

信号属于异步事件,在多线程条件下,还需考虑信号重入问题。因此我们并不推荐使用。

使用socket选项
原理

借助socket的SO_RCVTIMEO和SO_SNDTIMEO选项,分别设置socket接受超时时间和发送数据超时时间。其中常见API支持该选项的情况如下:

系统API支持选项超时后的行为
sendSO_SNDTIMEO返回-1,设置errno为EAGAIN或EWOULDBLOCK
sendmsgSO_SNDTIMEO返回-1,设置errno为EAGAIN或EWOULDBLOCK
recvSO_RCVTIMEO返回-1,设置errno为EAGAIN或EWOULDBLOCK
recvmsgSO_RCVTIMEO返回-1,设置errno为EAGAIN或EWOULDBLOCK
acceptSO_RCVTIMEO返回-1,设置errno为EAGAIN或EWOULDBLOCK
connectSO_SNDTIMEO返回-1,设置errno为EINPROGRESS
代码
123456789101112131415161718192021222324252627282930
voiddg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen){	int				n;	char			sendline[MAXLINE], recvline[MAXLINE + 1];	struct timeval	tv;	//设置时间结构体为5秒	tv.tv_sec = 5;	tv.tv_usec = 0;	Setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); //设置套接字选项	while (Fgets(sendline, MAXLINE, fp) != NULL) {		Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);		n = recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);		if (n < 0) {			if (errno == EWOULDBLOCK) {				fprintf(stderr, "socket timeout\n");				continue;			} else				err_sys("recvfrom error");		}		recvline[n] = 0;	/* null terminate */		Fputs(recvline, stdout);	}}
分析
  1. 优点

使用简单。只需一次设置。

  1. 缺点
  • 使用场景有限,仅支持socket超时
  • 并非所有实现都支持
使用IO复用
原理

Linux下的3组IO复用函数都带有超时参数,因此他不仅能统一处理信号和IO事件,也可以处理定时事件。但是因为IO复用事件触发可能在超时时间到期之前就返回(如有可读可写事件),因此我们需要不断更新定时参数以反映剩余时间。

123456789101112
/*select系列*/int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);/*poll系列*/int poll(struct pollfd *fds, nfds_t nfds, int timeout);/*epoll系列*/int epoll_create(int size);int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  • select中timeval数据结构如下
1234567
struct timeval{long tv_sec;   //secondslong tv_usec;  //microseconds};
  • 对于select而言
12345
1、把该参数设置为空指针NULL。表示永远等待下去,当有一个描述字准备好I/O时才返回。2、把该参数设置为指定了timeval结构中的秒数和微秒数的值。表示等待指定了超时时间,当超时后还没有描述字准备好I/O时直接返回。3、把该参数设置为指定了timeval结构中的秒数和微秒数的值,而且秒数和微秒都为0。表示不检查描述字是否准备好I/O后立即返回,这称为轮询。
  • 对于poll和epoll而言
12345
如果timeout设置为等待的毫秒数,无论I/O是否准备好,e/poll()都会返回。如果timeout设置为 0时,e/poll() 函数立即返回。如果timeout设置为 -1时,e/poll()一直阻塞到一个指定事件发生
代码
1234567891011121314151617
intreadable_timeo(int fd, int sec){	fd_set			rset;	struct timeval	tv;	FD_ZERO(&rset); //置空描述符集rset	FD_SET(fd, &rset); //加入传参进来的描述符集	//设置时间结构体为传参进来的sec秒	tv.tv_sec = sec;	tv.tv_usec = 0;	return(select(fd+1, &rset, NULL, NULL, &tv)); //调用select等待传参进来的fd变得可读		/* 4> 0 if descriptor is readable */}
分析
  1. 优点

将定时事件统一由IO复用管理,实用而方便。实际中也是使用这个方法。

  1. 缺点

暂无

常见定时任务管理方法

在服务端的网络模型中,定时任务主要包括时间(相对或者绝对均可——redis采用的是绝对时间)以及回调函数,尽管不同放大,但是思想基本相同。

无序链表-Redis为例
数据结构
123456789101112131415
typedef struct aeTimeEvent {    //标识符    long long id; /* time event identifier. */    //定时用的,秒    long when_sec; /* seconds */    //定时用的,毫秒    long when_ms; /* milliseconds */    //定时回调函数    aeTimeProc *timeProc;    //注销定时器时候的回调函数    aeEventFinalizerProc *finalizerProc;    void *clientData;    struct aeTimeEvent *prev;    struct aeTimeEvent *next;} aeTimeEvent;
操作
  1. 创建

redis中最重要的定时函数且是周期执行的函数,就是大名鼎鼎的serverCron函数。在redis中由于定时任务比较少,因此并没有严格的按照过期时间来排序的,而是按照id自增+头插法来保证基本有序。

1234567891011121314151617181920212223242526272829
 if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {        serverPanic("Can't create event loop timers.");        exit(1);    }//创建定时器对象long long aeCreateTimeEvent(aeEventLoop *eventLoop, long long milliseconds,        aeTimeProc *proc, void *clientData,        aeEventFinalizerProc *finalizerProc){    long long id = eventLoop->timeEventNextId++;    aeTimeEvent *te;    te = zmalloc(sizeof(*te));    if (te == NULL) return AE_ERR;    te->id = id;    aeAddMillisecondsToNow(milliseconds,&te->when_sec,&te->when_ms);    te->timeProc = proc;    te->finalizerProc = finalizerProc;    te->clientData = clientData;    te->prev = NULL;    //看到头插法了没    te->next = eventLoop->timeEventHead;    if (te->next)        te->next->prev = te;    eventLoop->timeEventHead = te;    return id;}

1591259514148

  1. 触发

redis中是采用IO复用来进行定时任务的。

  • 查找距离现在最近的定时事件,见aeSearchNearestTimer
1234567891011121314
static aeTimeEvent *aeSearchNearestTimer(aeEventLoop *eventLoop){    aeTimeEvent *te = eventLoop->timeEventHead;    aeTimeEvent *nearest = NULL;    while(te) {        if (!nearest || te->when_sec < nearest->when_sec ||                (te->when_sec == nearest->when_sec &&                 te->when_ms < nearest->when_ms))            nearest = te;        te = te->next;    }    return nearest;}

这里时间复杂度可能比较高,实际中需要结合具体场景使用。

  • 更新剩余过期时间,想想为啥呢?
    因为我们前面提到过,io复用有可能因为IO时间返回,所以需要更新
1234567891011121314151617181920212223242526272829303132333435
if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))            shortest = aeSearchNearestTimer(eventLoop);        if (shortest) {            long now_sec, now_ms;            aeGetTime(&now_sec, &now_ms);            tvp = &tv;            /* How many milliseconds we need to wait for the next             * time event to fire? */            long long ms =                (shortest->when_sec - now_sec)*1000 +                shortest->when_ms - now_ms;            if (ms > 0) {                tvp->tv_sec = ms/1000;                tvp->tv_usec = (ms % 1000)*1000;            } else {                //表示不等待                tvp->tv_sec = 0;                tvp->tv_usec = 0;            }        } else {            /* If we have to check for events but need to return             * ASAP because of AE_DONT_WAIT we need to set the timeout             * to zero */            //参照select最后一个参数的解释,表示不等待            if (flags & AE_DONT_WAIT) {                tv.tv_sec = tv.tv_usec = 0;                tvp = &tv;            } else {                /* Otherwise we can block */                tvp = NULL; /* wait forever */            }        }
  1. 执行定时事件

一次性的执行完直接删除,周期性的执行完在重新添加到链表。

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
/* Process time events */static int processTimeEvents(aeEventLoop *eventLoop) {    int processed = 0;    aeTimeEvent *te;    long long maxId;    time_t now = time(NULL);    /* If the system clock is moved to the future, and then set back to the     * right value, time events may be delayed in a random way. Often this     * means that scheduled operations will not be performed soon enough.     *     * Here we try to detect system clock skews, and force all the time     * events to be processed ASAP when this happens: the idea is that     * processing events earlier is less dangerous than delaying them     * indefinitely, and practice suggests it is. */    //如果系统时间被修改至未来,然后又返回正确的值,此时定时事件可能会被随机推迟。    //在这里的策略就是提前执行定时器要比延迟执行更安全    if (now < eventLoop->lastTime) {        te = eventLoop->timeEventHead;        while(te) {            te->when_sec = 0;            te = te->next;        }    }    //更新为当前的时间    eventLoop->lastTime = now;    te = eventLoop->timeEventHead;    maxId = eventLoop->timeEventNextId-1;    //在这里就是删除定时器    while(te) {        long now_sec, now_ms;        long long id;        //在下一轮中对事件进行删除        /* Remove events scheduled for deletion. */        if (te->id == AE_DELETED_EVENT_ID) {            aeTimeEvent *next = te->next;            if (te->prev)                te->prev->next = te->next;            else                eventLoop->timeEventHead = te->next;            if (te->next)                te->next->prev = te->prev;            if (te->finalizerProc)                te->finalizerProc(eventLoop, te->clientData);            zfree(te);            te = next;            continue;        }        /* Make sure we don't process time events created by time events in         * this iteration. Note that this check is currently useless: we always         * add new timers on the head, however if we change the implementation         * detail, this check may be useful again: we keep it here for future         * defense. */        if (te->id > maxId) {            te = te->next;            continue;        }        aeGetTime(&now_sec, &now_ms);        if (now_sec > te->when_sec ||            (now_sec == te->when_sec && now_ms >= te->when_ms))        {            int retval;            id = te->id;           // timeProc 函数的返回值 retval 为时间事件执行的时间间隔            retval = te->timeProc(eventLoop, id, te->clientData);            processed++;            if (retval != AE_NOMORE) {                aeAddMillisecondsToNow(retval,&te->when_sec,&te->when_ms);            } else {                //如果超时了,那么则标记位删除                te->id = AE_DELETED_EVENT_ID;            }        }        //进行下一个        te = te->next;    }    return processed;}
优缺点
  1. 优点

实现简单

  1. 缺点

如果定时任务很多,效率比较低。

升序链表

参照上述无序列表

时间轮

时间轮使用了哈希表的思想,将定时器散列在不同的链表上,这样可以保证每条链表上的定时器数少于排序链表上的定时器数量,其基本思想如下:

1591260909062

  • si:表示一个时间周期,即心博时间
  • N:表示槽总数。转动一周时间为N*si
  • cs:表示当前指向的槽。则添加一个定时器ts=(cs+(ti%si))%N
数据结构
12345678910111213141516171819202122
struct client_data//绑定socket和定时器 {     sockaddr_in address;     int sockfd;     char buf[ BUFFER_SIZE ];     tw_timer* timer; };  class tw_timer//定时器类 { public:     tw_timer( int rot, int ts )      : next( NULL ), prev( NULL ), rotation( rot ), time_slot( ts ){}  public:     int rotation;//记录该定时器在时间轮转多少圈后生效     int time_slot;//记录定时器属于那个槽     void (*cb_func)( client_data* );//定时器回调函数     client_data* user_data;//用户数据     tw_timer* next;//指向上一个定时器     tw_timer* prev;//指向下一个定时器 31 };
操作
  1. 添加
12345678910111213141516171819202122232425262728293031
tw_timer* add_timer( int timeout )//添加新的定时器,插入到合适的槽中     {         if( timeout < 0 )//时间错误         {             return NULL;         }         int ticks = 0;         if( timeout < TI )//小于每个槽的interval,则为1         {             ticks = 1;         }         else         {             ticks = timeout / TI;//相对当前位置的槽数         }         int rotation = ticks / N;//记录多少圈后生效         int ts = ( cur_slot + ( ticks % N ) ) % N;//确定插入槽的位置         tw_timer* timer = new tw_timer( rotation, ts );//根据位置和圈数,插入对应的槽中          if( !slots[ts] )//所在槽头节点为空,直接插入          {              printf( "add timer, rotation is %d, ts is %d, cur_slot is %d\n", rotation, ts, cur_slot );             slots[ts] = timer;         }         else //头插法         {             timer->next = slots[ts];             slots[ts]->prev = timer;             slots[ts] = timer;         }         return timer;//返回含有时间信息和所在槽位置的定时器     }
  1. 触发
1234567891011121314151617181920212223242526272829303132333435363738394041
void tick(){   tw_timer* tmp = slots[cur_slot];//取出当前槽的头节点   printf( "current slot is %d\n", cur_slot );   while( tmp )//遍历   {       printf( "tick the timer once\n" );       if( tmp->rotation > 0 )       {           tmp->rotation--;           tmp = tmp->next;       }       else       {           tmp->cb_func( tmp->user_data );//符合条件,调用回调函数           if( tmp == slots[cur_slot] )           {               printf( "delete header in cur_slot\n" );               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;           }       }   }    cur_slot = ++cur_slot % N;}
优缺点
  1. 优点
  • 时间轮使用哈希表的思想,优化了纯链表带来的插入性能较低问题。
  • 容易扩展,当需要精细精度时,我们可以采用多层时间轮,参照kafka的三层时间轮。
  1. 缺点
  • 只能以固定频率转动,若要支持不同精度的定时器,单个时间轮可能会造成溢出或者耗费大量内存。因此需要引入多层,但却加大了实现的难度。
时间堆

根据前面描述,我们很容易联想到时间堆,而且还是小根堆

数据结构
12345678910
typedef void( *cb_func )( void * );struct evtime {	int interval;   	//定时器间隔	int index;      	//索引	int64_t timeout;  	//下次超时的超时时间	void* ptr;      	//定时回调数据	cb_func func;		//定时回调函数};
操作
  1. 添加
12345678910111213141516171819202122232425262728
int Timer::add( int interval , int64_t cur , cb_func func , void* context ) {	if( interval < 0 ) {		log_error( "interval = %d < 0\n" , interval );		return -1;	}	if( m_max_heap_size == m_cur_heap_size ) {		m_max_heap_size = m_max_heap_size ? m_max_heap_size * 2 : 8;		m_ppevtime = ( evtime** )realloc( m_ppevtime , sizeof( evtime* ) * m_max_heap_size );	}	if( !m_ppevtime ) {		log_error( "realloc memory for evtime\n" );		return -1;	}	evtime * e = new evtime;	e->interval = interval;	e->ptr = context;	e->func = func;	e->timeout = cur + interval;		//调整堆的大小	min_heap_adjust_up( m_cur_heap_size++ , e );	return 0;}
  1. 触发
123456789101112131415161718192021222324252627282930
if(m_ptimer) {       //获取最近的时间		int timeout = m_ptimer->latency_time(get_usec()/1000);		tv.tv_sec = timeout / 1000;		tv.tv_usec = (timeout % 1000) * 1000;		ret = select(max_socket + 1, &fdsr, NULL, NULL, &tv);	}	 	/*if timer is not init*/	CHK_EXP_RUN(!m_ptimer, continue);	evtime ev ;	int64_t cur = get_usec()/1000;       //判断是由于IO事件还是定时事件	while(m_ptimer->pop_timeout(ev, cur) != -1){			if(ev.id == PULL_CONF_TIMER){				if(IS_WRONG(ret = pull_conf_update( ))) {					log_error("pull_conf_update fail, %s\n", error_code_desc[ret - error_ok]);				m_pstat->add(STAT_CONFIG_UPDATE_ERROR_COUNT, 1);			}			else {				kill_all_child( );			}			m_ptimer->add(ev.id, g_db_conf.vol_conf.pull_timegap, cur, ev.ptr);					}		else if(ev.id == DIFF_UDP_LOSS_NUM) {			diff_udp_loss( );			m_ptimer->add(ev.id, ev.interval, cur, ev.ptr);		}	}

pop_timeout函数

12345678910111213141516171819
int rc_timer::pop_timeout(evtime & ev, int64_t cur){	if(!m_cur_heap_size){			return -1;	}		evtime * e = m_ppevtime[0];	int64_t timeout = e->timeout - cur;		if(timeout <= 0){		min_heap_adjust_down(0, m_ppevtime[--m_cur_heap_size]);		memcpy(&ev, e, sizeof(ev));		delete e;			return 0;	}			return -1;}
分析
  1. 优点

支持各种精度的定时器。

  1. 缺点
  • 定时任务过多,插入和删除时间复杂度可能比较高。此时可以使用多叉树进行优化。
扩展

除了上述几种方法,还有Nginx支持的红黑树,Redis的zset等等均可。

总结

  1. 在常见的定时方法中,推荐使用IO复用,因为可以进行统一处理。
  2. Redis中由于定时任务比较少,而且是单线程,所以直接就采用链表,且基本有序。
  3. 项目中推荐使用IO复用+时间堆的方式。
  4. 如果定时任务很多,可以参考kafka的多层时间轮。

gzh