cyberrt 中 timer 实现原理以及 bug 修复

cyberrt 是百度开源的智驾中间件,主要功能是通信和调度。timer 是cyberrt 中的一个基础模块,提供了定时器功能。cyberrt 中的定时器完全是自己造轮子,没有基于 posix timer 或者其它第三方库提供的 timer。

本人在工作中移植了 cyberrt 中的 timer,在使用过程中也遇到并修复了两个 bug。本文首先记录 cyberrt 中 timer 的实现原理,然后再记录使用时遇到的问题。

1 实现原理

1.1 时间轮、驱动线程、scheduler

下边分别介绍定时器中的概念:

timer task

timer task 在这里就是定时器超时之后执行的回调函数

时间轮

辅助时间轮

① 时间轮类似于生活中的钟表,钟表有秒针,分针和时针,钟表一圈的刻度是 60。

同样在这里,也有两个时间轮,我们可以称作秒针和分针;也有刻度,不过一圈的刻度不是 60。

② 左边的是秒针时间轮,刻度是 512,每个刻度之间时间间隔是 2ms。

右边的是辅助时间轮,是分针,刻度是 64,每个刻度之间的时间间隔就是秒针走一圈的时间,即 2ms * 512 = 1024ms。

③ 时间轮的每个刻度是一个容器,里边放着在这个时刻需要执行的 timer task,也就是当秒针转到某个槽位的时候,这个槽位中的 task 就会被执行。

时间轮索引时间轮索引类似于时钟上的秒针和分针。
tick 线程

① tick 线程是整 timer 能够运行的驱动力,tick 线程每 sleep 2ms 就会工作,之后再  sleep 2ms 然后再工作,一直做这样的循环。

② 睡 2ms 确定了定时器的最小刻度就是 2ms,tick 线程每睡 2ms,秒针就前进一个刻度。

③  tick 线程的工作是将这个刻度中的 timer task 提交给 scheduler 来执行。

schedulerscheduler 是 cyberrt 内部实现的调度框架,tick 线程可以将 timer task 提交给 scheduler 执行。

本人在工作中对 timer 的使用并没有移植 scheduler,而是使用的线程池来代替。

1.2 相关代码

timer 相关源码在 https://github.com/ApolloAuto/apollo/tree/master/cyber/timer

下边几个常数,分别定义了时间轮和辅助时间轮的刻度数,分别是 512 和 64;指定了 timer 的最小分辨率,是 2ms;定时器支持的最大周期是 2ms * 512 * 64 =  65536ms。

static const uint64_t WORK_WHEEL_SIZE = 512;

static const uint64_t ASSISTANT_WHEEL_SIZE = 64;

static const uint64_t TIMER_RESOLUTION_MS = 2;

static const uint64_t TIMER_MAX_INTERVAL_MS =

    WORK_WHEEL_SIZE * ASSISTANT_WHEEL_SIZE * TIMER_RESOLUTION_MS;

timer task,表示一个定时器任务。其中包括定时器的周期,回调函数,当然也包括其它一些信息,可以用来对定时器进行误差统计。timer task 是 timer 实现的内部用来描述一个定时器的结构体。

struct TimerTask {

  explicit TimerTask(uint64_t timer_id) : timer_id_(timer_id) {}

  uint64_t timer_id_ = 0; // timer  id,进程内唯一

  std::function<void()> callback; // 回调函数

  uint64_t interval_ms = 0; // 定时器周期

  uint64_t remainder_interval_ms = 0;

  uint64_t next_fire_duration_ms = 0;

  int64_t accumulated_error_ns = 0;

  uint64_t last_execute_time_ns = 0;

  std::mutex mutex;

};

时间轮,两个时间轮,时间轮的每个刻度上都是一个 std::list<std::weak_ptr<TimerTask>>,用来保存这个时刻需要执行的 timer task。

TimerBucket work_wheel_[WORK_WHEEL_SIZE];

TimerBucket assistant_wheel_[ASSISTANT_WHEEL_SIZE];

class TimerBucket {

 public:

  void AddTask(const std::shared_ptr<TimerTask>& task) {

    std::lock_guard<std::mutex> lock(mutex_);

    task_list_.push_back(task);

  }

  std::mutex& mutex() { return mutex_; }

  std::list<std::weak_ptr<TimerTask>>& task_list() { return task_list_; }

 private:

  std::mutex mutex_;

  std::list<std::weak_ptr<TimerTask>> task_list_;

};

时间轮索引,两个时间轮,两个索引。current_work_wheel_index_  的取值范围是 [0, 511],current_assistant_wheel_index_  的取值范围是 [0, 63]。

  uint64_t current_work_wheel_index_ = 0;

  uint64_t current_assistant_wheel_index_ = 0;

1.3 几个关键点

1.3.1 向时间轮中放 task

什么时候向时间轮中放 task ?

(1)当创建一个 timer 并启动这个 timer 的时候,会向时间轮中放一个 task

假设创建一个 timer,周期是 50ms,而创建 timer 的时候 current_work_wheel_index_  指向时间轮第 100 的位置,那么这个 task 就会放到时间轮的第 125 的位置。时间轮索引每 2ms 移动一个刻度,当过了 50ms 的时候,正好移动到第 125 的位置,然后 tick 线程将 task 从时间轮中取出,交给 scheduler,scheduler 便会执行这个 task。

(2)当 scheduler 执行完一个 task 时,需要将这个 task 放回时间轮

假设这个 task 的定时器周期是 50ms,scheduler 在执行 task 之前和 task 执行完毕之后,都会记录时间戳,通过两个时间戳计算出来 task 执行消耗的时间。如果 执行 task 消耗了 40ms,而这个时候 current_work_wheel_index_  指向 200 的位置,那么 scheduler 就会将 task 放到 205 的位置,而不是放到 225 的位置。

这个里边有个误差校正的逻辑,就是当 task 执行完毕之后并不是将 task 放到 50ms 之后的位置,而是要考虑到 task 本身消耗的时间。如果把这次 task 放到 225 的位置,那么定时器下一次执行和这次执行的时间间隔相差了 90ms,就不是 50ms 了。

1.3.2 两个时间轮如何配合

分 3 种情况来介绍,什么时候将 task 放到时间轮中,什么时候将 task 放到辅助时间轮中。这 3 种情况和代码保持一致,对应代码中的函数是 TimingWheel::AddTask。

void TimingWheel::AddTask(const std::shared_ptr<TimerTask>& task,

                          const uint64_t current_work_wheel_index)

(1)假如现在时间轮指针指向刻度 200,这个时候向时间轮中添加的 task 需要在 600ms 之后执行,也就是需要放到时间轮的第 500 个刻度的位置,那么就直接放入。

(2) 还是上边这个例子,时间轮的刻度指向 200,这时添加的 task 需要在 1000ms 之后执行,那么这个 task 需要放到刻度是 188。当秒针转过 0 点之后开始下一圈,转到 188 的位置时,再执行这个 task。

(3)如下图所示,当前秒针指向刻度 200,分针指向刻度 5,这个时候添加的 task 需要在 1200ms 之后执行,那么这个 task 放到什么位置呢 ?

放到秒针时间轮中肯定是不行的,因为秒针时间轮转一圈是 1024ms,无论放到哪里,都会在 1024ms 之内执行。

这个时候就需要将 task 放到分针时间轮中,那么具体怎么放呢,计算过程如下:

① 先计算出来 task 的执行时间和当前秒针指向的刻度相差的位置,为 600

1200ms / 2ms = 600

② 那么应该把 task 放在第  800 的刻度上,又因为时间轮是环形的,最大刻度是 511

200 + 600 = 800

800 对环形队列取余是 288,但是不能放到刻度 288 的位置,因为 288 的位置会在 176ms 之后执行。

800 & (512 - 1) =  288

③ 应该转一圈之后,下一次指向 288 的时候再执行,这个时候时间正好是 512 * 2ms + 176ms = 1200ms。

需要将 task 放到分针时间轮中,分针当前指向 5,那么需要将 task 放到分针时间轮的刻度 6 的位置。

秒针每次指向 0 的时候,相当于秒针转了一圈了。这个时候就会将分针时间轮中的 task 移到秒针时间轮中。如上边这个例子,task 放到了刻度 6 的位置,当秒针指向 0 的时候,便会将刻度 6 中的 task 取出来放到秒针时间轮中。那么具体放到秒针时间轮的什么位置呢 ?就是上边计算过程中的余数,288。

1.3.3 tick 线程

1.1 节中说了,tick 线程每睡 2ms 就会将对应时间轮中的 task 取出,交给 scheduler 来执行。

实际实现上 tick 线程并不是直接睡 2ms,而是使用了 std::this_thread::sleep_until() 这个函数,意思是睡到什么时候,相对于直接睡 2ms 更精确。

假如前一次睡到 1000ms,那么下一次就会睡到 1002ms。这样也把 tick 线程工作消耗的时间考虑了进来,假如这次工作消耗了 0.5ms,然后还是直接睡 2ms,这样就会引入 0.5ms 的误差。

1.3.4 定时器调度误差补偿算法

(1)当 task 执行时间超过了定时器周期,是什么现象

假如一个定时器的周期是 50ms,而这次执行 task 就花费了 60ms 的时间。如下图所示,前两次 task 执行均消耗了 10ms,不会引入误差;第 3 次执行消耗了 60ms 的时间。

那么在第 3 次执行期间,50ms 的定时器时间到期之后会不会再执行一个 task,造成两个 task 并发执行 ?

task 不会并发执行。从上边的分析也能知道,tick 线程会将 current_work_wheel_index_  指向的刻度中的 timer task 从时间轮中取出来,然后交给 scheduler 执行,取出来之后,时间轮中已经没有这个 task 了,只有下一次再加入了时间轮,后边才会被执行。

这次 task 执行消耗了 60ms 的时间,那么下次执行是什么时候, 是立即执行一次,还是间隔 40ms 执行一次,还是间隔 50ms 执行一次呢 ?

立即执行,40ms 之后执行,50ms 之后执行,分别对应下图中的 ①,②,③。

cyberrt 中,这次执行超时之后,下一次执行是立即执行,在 2ms 之后执行。

并且这一次的误差不仅仅影响下一次的执行,还影响后边多次的执行。

还记的上边有一个 TimerTask 数据结构,其中有一个成员是 accumulated_error_ns,这个成员记录着定时器第一次运行到目前为止的误差。

假如定时器的周期是 50ms,但是前 10 次相邻两次开始执行的时间均是相差了 60ms,那么每一次都会积累 10ms 的误差(我们不去探讨产生这种误差的原因,只做这种假设),那么前 10 次就积累了 100ms 的误差。

timer 的的调度是一直想要补偿这个误差,假如第 11 次执行消耗了 10ms 的时间,那么正常来说离下一次执行还有 40ms 的时间,但是 cyberrt 中为了补偿历史误差,会立即再执行一次这样的 task。这次就补偿了 40ms 的误差,那么误差还剩 60ms,后边会继续补偿。

实际使用是对补偿算法的修改:

定时器误差补偿算法并没有一个统一的标准,不同的定时器实现可能是不一样的。

本人在工作中也对 timer 的误差补偿算法进行了修改,也可以说进行了简化,具体做法是:去掉了历史积累误差统计,当这次执行超时的时候,立即执行一次,后边的任务恢复到定时器正常的周期,不受历史误差影响。

2 bug 修复

2.1 current_work_wheel_index_ 在 AddTask 中没有加锁

current_work_wheel_index_ 是时间轮的索引,会被多线程访问。

tick 线程中修改,线程每隔 2ms 便会将 current_work_wheel_index_mutex_ 加 1。

scheduler 会读 current_work_wheel_index_,scheduler 中执行完本次 task 之后,会将 task 再次放回到时间轮。scheduler 将 task 放入时间轮时需要确定将 task 放到时间轮的哪个位置,假如当前 current_work_wheel_index_ 指向 200,task 需要在 20ms 之后执行,那么需要将 task 放到 210 的位置。

我们考虑一个临界的情况,假如定时器周期是 50ms,scheduler 这次执行 task 消耗了 48ms,那么这个时候需要将 task 放到下一个刻度,也就是 2ms 之后,这个 task 需要再次执行。假如在函数 void TimingWheel::AddTask(const std::shared_ptr<TimerTask>& task) 中读取的 current_work_wheel_index_ 是 200,那么就需要将 task 放到 201 的位置,如果在 AddTask() 函数中线程被调度出去,4ms 之后又调度回来,这个时候 tick 线程已经将 current_work_wheel_index_ 移动到了 202 的位置,也就是 201 这个位置已经过去了,下次执行只能等 current_work_wheel_index_ 转一圈之后再执行,这样就给定时器带了很大的误差。

代码中也定义了锁 current_work_wheel_index_mutex_,并且在 tick 线程中对索引进行修改时加了锁。

但是在 AddTask() 函数中读取索引的时候没有加锁,这就是问题所在。需在在 AddTask(task, current_work_wheel_index_); 上边也加锁。

2.2 timer Stop 导致程序崩溃

本人在开发中使用 timer 时,有这样一个使用场景:timer 创建之后当条件满足时需要将 timer 停止(调用 Stop()),当另一个条件满足时又要将 timer 启动(调用Start()),也就是需要将 timer 进行重复的停止与启动。

当将 timer 进行 Stop() 时,会导致程序崩溃。

因为 timer task 是提交给线程池来执行,异步执行。当调用 timer->Stop() 的时候,可能这个 task 已经提交给了线程池,但是还没有执行。当 timer Stop() 之后,再执行 task,因为当 timer Stop() 的时候,已经将对应的 TimerTask 给释放,其中的回调函数 std::function<void()> callback 当然也释放了,那么当 task 到来之后再次执行 callback 的时候,访问已经释放的资源,就会导致程序崩溃。

可以看到 callback 的入口还对 task 进行了判断,判断 task 是不是已经释放,如果已经释放,那么就直接返回。有这个判断,为什么还会导致崩溃呢 ?因为这个判断是在 callback 内部做的判断,而这个时候 callback 都已经释放了,所以在调用 callback 的时候就会导致崩溃。这里的设计有点问题,callback 本身属于 task 的一个成员,在 callback 内部又判断了 task 是不是已经释放。

修改方法是将 callback 放在了 timer 类中,timer task 中的 callback 引用 timer 中的 callback。这样只要 timer 类不销毁,callback 就是一直可以调用的。这种方式满足上边说的使用场景。

  • 21
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Apollo Cyber RT是Apollo自动驾驶系统的一个关键组件。它是在Apollo v3.0之后引入的,用于替代之前版本使用的ROS框架。\[3\]Apollo Cyber RT提供了一套开发工具,包括rosbag_to_record工具,支持多个channel,如/perception/obstacles、/planning、/prediction等等。\[1\]通过使用Apollo Cyber RT,开发人员可以更好地满足商业化自动驾驶解决方案对稳健性和性能的需求。\[3\]如果你想了解更多关于Apollo Cyber RT的信息,可以参考Apollo Cyber RT Developer Tools、CyberRT介绍、百度Apollo Cyber RT简介、基本概念以及与ROS对照、Cyber RT Documents documentation和Apollo6.0学习002:Cyber RT框架等文档。\[2\] #### 引用[.reference_title] - *1* *2* [【Apollo 6.0学习笔记】Apollo Cyber RT介绍](https://blog.csdn.net/Travis_X/article/details/120965138)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [自动驾驶Apollo源码分析系统,CyberRT篇(一):简述CyberRT框架基础概念](https://blog.csdn.net/briblue/article/details/123432580)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值