在前文说道,服务端编程中主要涉及到三类事件:IO事件、定时(一次或多次)事件以及信号事件。在这里将介绍常用的三类处理方式,并分析Redis选择哪种。
常见定时方法
SIGALRM信号
原理
调用alarm或者settimer函数,他在指定超时期满时会产生SIGALRM信号。我们为其建立一个信号处理函数,执行相应的处理逻辑。
代码
- 使用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");}
|
- Redis使用settimer实现WatchDog
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);}
|
分析
- 优点
可以看出使用SIGALRM实现简单。
- 缺点
信号属于异步事件,在多线程条件下,还需考虑信号重入问题。因此我们并不推荐使用。
使用socket选项
原理
借助socket的SO_RCVTIMEO和SO_SNDTIMEO选项,分别设置socket接受超时时间和发送数据超时时间。其中常见API支持该选项的情况如下:
| 系统API | 支持选项 | 超时后的行为 |
|---|
| send | SO_SNDTIMEO | 返回-1,设置errno为EAGAIN或EWOULDBLOCK |
| sendmsg | SO_SNDTIMEO | 返回-1,设置errno为EAGAIN或EWOULDBLOCK |
| recv | SO_RCVTIMEO | 返回-1,设置errno为EAGAIN或EWOULDBLOCK |
| recvmsg | SO_RCVTIMEO | 返回-1,设置errno为EAGAIN或EWOULDBLOCK |
| accept | SO_RCVTIMEO | 返回-1,设置errno为EAGAIN或EWOULDBLOCK |
| connect | SO_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); }}
|
分析
- 优点
使用简单。只需一次设置。
- 缺点
- 使用场景有限,仅支持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);
|
1234567
| struct timeval{long tv_sec; //secondslong tv_usec; //microseconds};
|
12345
| 1、把该参数设置为空指针NULL。表示永远等待下去,当有一个描述字准备好I/O时才返回。2、把该参数设置为指定了timeval结构中的秒数和微秒数的值。表示等待指定了超时时间,当超时后还没有描述字准备好I/O时直接返回。3、把该参数设置为指定了timeval结构中的秒数和微秒数的值,而且秒数和微秒都为0。表示不检查描述字是否准备好I/O后立即返回,这称为轮询。
|
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 */}
|
分析
- 优点
将定时事件统一由IO复用管理,实用而方便。实际中也是使用这个方法。
- 缺点
暂无
常见定时任务管理方法
在服务端的网络模型中,定时任务主要包括时间(相对或者绝对均可——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;
|
操作
- 创建
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;}
|

- 触发
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;}
|
这里时间复杂度可能比较高,实际中需要结合具体场景使用。
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 */ } }
|
- 执行定时事件
一次性的执行完直接删除,周期性的执行完在重新添加到链表。
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;}
|
优缺点
- 优点
实现简单
- 缺点
如果定时任务很多,效率比较低。
升序链表
参照上述无序列表
时间轮
时间轮使用了哈希表的思想,将定时器散列在不同的链表上,这样可以保证每条链表上的定时器数少于排序链表上的定时器数量,其基本思想如下:

- 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 };
|
操作
- 添加
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;//返回含有时间信息和所在槽位置的定时器 }
|
- 触发
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;}
|
优缺点
- 优点
- 时间轮使用哈希表的思想,优化了纯链表带来的插入性能较低问题。
- 容易扩展,当需要精细精度时,我们可以采用多层时间轮,参照kafka的三层时间轮。
- 缺点
- 只能以固定频率转动,若要支持不同精度的定时器,单个时间轮可能会造成溢出或者耗费大量内存。因此需要引入多层,但却加大了实现的难度。
时间堆
根据前面描述,我们很容易联想到时间堆,而且还是小根堆。
数据结构
12345678910
| typedef void( *cb_func )( void * );struct evtime { int interval; //定时器间隔 int index; //索引 int64_t timeout; //下次超时的超时时间 void* ptr; //定时回调数据 cb_func func; //定时回调函数};
|
操作
- 添加
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;}
|
- 触发
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;}
|
分析
- 优点
支持各种精度的定时器。
- 缺点
- 定时任务过多,插入和删除时间复杂度可能比较高。此时可以使用多叉树进行优化。
扩展
除了上述几种方法,还有Nginx支持的红黑树,Redis的zset等等均可。
总结
- 在常见的定时方法中,推荐使用IO复用,因为可以进行统一处理。
- Redis中由于定时任务比较少,而且是单线程,所以直接就采用链表,且基本有序。
- 项目中推荐使用IO复用+时间堆的方式。
- 如果定时任务很多,可以参考kafka的多层时间轮。
