
服务器的定时器一直都有不准确的问题,包括大名鼎鼎的Nginx也是一样,定时器的误差本质上是由于并发引起的,这是服务器要解决的本质问题。
趁今年过春节,仔细分析了ST的调度和定时器机制,目前大部分时候定时器能达到25ms之内的精度,要完整解决这个问题还需要继续改善。

并发
首先,考虑服务器怎么支持并发?目前Linux服务器基本就是epoll了,下面是示意代码:
nfd = epoll_wait(fds, timeout);for (int i = 0; i < nfd; i++) { int active_fd = fds[i]; // Serve the active fd. }Remark:关于并发,详细可以搜这篇文章:高性能、高并发、高扩展性和可读性的网络服务器架构:StateThreads。
无论是Nginx,还是SRS,本质上都是这个epoll大循环在驱动服务器。而定时器的误差,就是从每个active fd的处理中引入的。
如果我们需要一个20ms的定时器,那么简单来说,我们就可以给timeout赋值为20ms,这样每隔20ms时epoll_wait就会返回,但这只是系统空载没有active fd才能这么做。
实际上,fds几乎肯定是有活动的,所以当epoll wait返回时,并不一定就是20ms定时器到时间了,所以我们需要有绝对时间来计算定时器是否到达时间,每次计算timeout应该是多少。
因此,当活动的fd很繁忙时,比如有大量的TCP或UDP包需要处理,那么就会导致定时器过期而引入误差,示意代码如下:
nfd = epoll_wait(fds, 3ms);for (int i = 0; i < nfd; i++) { int active_fd = fds[i]; // 如果有上千的fd需要读写,那么处理完可能不止3ms,比如20ms。 } // 处理完fd,检查定时器一定超时,而且比预期的多17ms了,那么看起来这个 // 定时器就是37ms才唤醒,而不是20ms唤醒。在非常繁忙的视频服务器中,一定会优先处理IO也就是active fd,而导致定时器会出现一定的误差。

timerfd
感谢志宏大神提供了另外一个思路,就是Linux的timerfd。当然无法解决误差问题,因为timerfd是替代gettimeofday的时间和定时机制,可以用在io复用中,不过对于上述的误差无法解决。
考虑下面的示意代码:
nfd = epoll_wait(fds, -1);for (int i = 0; i < nfd; i++) { int active_fd = fds[i]; if (active_fd is timerfd) { // 定时器触发了。但前后的其他fd的耗时而引入的误差,是无法解决d的。 // 比如前面有大量fd和包处理,导致消耗了27ms,那么到这里就已经有了误差。 } }本质上,并发导致的定时器误差,并不是系统时钟导致的误差。前面我们计算timeout时,使用的是绝对时间,也就是gettimeofday,这个函数本质上是没有误差的,至少没有毫秒级误差。
下面看下解决方案。

解决方案
解决方案也容易,既然是并发导致的定时器误差,那么就不能处理完所有的IO后,才处理定时器,应该在中间合适的时机处理定时器,这样可以显著减少定时器的误差问题。示意代码如下:
nfd = epoll_wait(fds, timeout);for (int i = 0; i < nfd; i++) { int active_fd = fds[i]; // 处理这个活跃的fd。 if (timeout) { // 触发定时器,误差比之前小。 } }当然这个是示意代码,并不是最好的方案,它的问题包括:
最终差不多是这个思路,减少IO并发处理引入的定时器误差。下面看下SRS中的解决方案。

StateThreads
SRS解决这个问题,是要在StateThreads(ST)中修改,降低IO并发引入的定时器误差。先看下ST的epoll_wait的处理逻辑:
void _st_epoll_dispatch(void) { nfd = epoll_wait(fds, timeout); for (int i = 0; i < nfd; i++) { int active_fd = fds[i]; _ST_ADD_RUNQ(threads[active_fd]); // 把活跃的协程放入RunQ } } void _st_vp_schedule(void) { for (thread in RunQ) { // 切换到活跃thread并执行 } if (RunQ is empty) { _st_vp_check_clock(); // 检查定时器,到期后放入RunQ }}其实ST也是一样的并发处理,不过是把fd对应的协程放入RunQ,到期的定时器也放入RunQ,最后统一执行所有的RunQ。
简化来看,就是先执行所有的IO协程,然后执行Timer协程。同样也是一样的问题,IO协程执行会导致Timer协程“等不及”而超过了唤醒时间。改进方案:
详细的改进方案,可以阅读原文,Issue中有详细描述。下面是实测效果:
// RTC播放改进前,总共只有38个事件,每秒应该有50个事件// 30ms之内的事件只有33个,还有5个超过30ms。clock=0,18,7,8,2,2,1,0,0// RTC播放改进后,总共有49个事件。// 都在25ms之内唤醒。clock=0,46,3,0,0,0,0,0,0
// 下面是RTC播放,也就是发送UDP包时,主动调用yield让给timer时间片srs_error_t SrsUdpMuxSocket::sendto(...) { int nb_write = srs_sendto(...); // Yield to another coroutines. // @see https://github.com/ossrs/srs/issues/2194#issuecomment-777542162 if (++nn_msgs_for_yield_ > 20) { nn_msgs_for_yield_ = 0; srs_thread_yield(); }}yield是新增的一个ST函数,主要将当前协程切走稍后执行,没有用sleep,因为sleep效率很低,而yield直接切换引入的开销非常小。

遗留问题
目前只解决了大部分的定时器误差,但是有时候还是会有误差,因为除了IO协程繁忙,还有系统调用的耗时,磁盘IO的耗时。
比如,IO协程在处理时,可能会调用日志函数写日志,目前是调用open和write函数,这两个函数都是毫秒级别的误差。这个最终的解决方案,就需要改进SRS的线程模型,可以参考Issue #2188 的详细讨论。
祝新年快乐。