首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >代码拜年:SRS高精度低误差定时器

代码拜年:SRS高精度低误差定时器

作者头像
Winlin
发布2022-03-18 17:12:08
发布2022-03-18 17:12:08
8100
举报
文章被收录于专栏:SRS开源服务器SRS开源服务器

服务器的定时器一直都有不准确的问题,包括大名鼎鼎的Nginx也是一样,定时器的误差本质上是由于并发引起的,这是服务器要解决的本质问题。

趁今年过春节,仔细分析了ST的调度和定时器机制,目前大部分时候定时器能达到25ms之内的精度,要完整解决这个问题还需要继续改善。

并发

首先,考虑服务器怎么支持并发?目前Linux服务器基本就是epoll了,下面是示意代码:

代码语言:javascript
复制
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包需要处理,那么就会导致定时器过期而引入误差,示意代码如下:

代码语言:javascript
复制
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复用中,不过对于上述的误差无法解决。

考虑下面的示意代码:

代码语言:javascript
复制
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后,才处理定时器,应该在中间合适的时机处理定时器,这样可以显著减少定时器的误差问题。示意代码如下:

代码语言:javascript
复制
nfd = epoll_wait(fds, timeout);for (int i = 0; i < nfd; i++) {    int active_fd = fds[i];    // 处理这个活跃的fd。        if (timeout) {        // 触发定时器,误差比之前小。    } }

当然这个是示意代码,并不是最好的方案,它的问题包括:

  • 性能问题,每个fd都检查timeout,比之前的性能要低一些。
  • 并不能完全解决误差,比如RTC的UDP是复用的,这个FD属于超级活跃的FD,有海量的包处理处理,很有可能处理完它就需要大量时间。
  • 逻辑上也很复杂,相当于独立epoll_wait之外再计算一次timeout,对现有服务器框架造成比较大的影响。

最终差不多是这个思路,减少IO并发处理引入的定时器误差。下面看下SRS中的解决方案。

StateThreads

SRS解决这个问题,是要在StateThreads(ST)中修改,降低IO并发引入的定时器误差。先看下ST的epoll_wait的处理逻辑:

代码语言:javascript
复制
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协程“等不及”而超过了唤醒时间。改进方案:

  • 将到期的Timer放RunQ的头部,优先执行。
  • 在IO协程中,主动调用yield方法,将到期的Timer放RunQ头部并执行。

详细的改进方案,可以阅读原文,Issue中有详细描述。下面是实测效果:

代码语言:javascript
复制
// 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 的详细讨论。

祝新年快乐。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-02-12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 SRS开源服务器 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档