在自动驾驶系统(如 Apollo/Cyber RT)、机器人操作系统(如 ROS)以及高帧率游戏引擎中,“时间”往往是最核心的基石之一。无论是传感器数据的对齐、控制算法的周期性执行,还是网络心跳包的发送,都对时间的精确度、单调性以及定时执行有着极高的要求。

很多初学者可能会问:C++11 已经提供了强大的 <chrono> 库,为什么像 Cyber RT 这样的工业级框架还要自己重新实现一套 TimeDuration 以及 Rate 这样的时间/定时组件?

这篇博客将结合我们剥离出的精简代码(time 目录下的组件),深入浅出地讲解定时器机制的实现原理,弄清“为什么要自己造轮子”,以及如何一步步搭建起高可用的定频控制器(Rate)。


一、为什么我们需要自己重写时间架构?

既然有 std::this_thread::sleep_for()std::chrono,为什么不直接在项目里铺满它们?这就涉及到了工业级软件工程中的几个痛点:

1.1 std::chrono 过于繁琐复杂

C++ 标准库的设计哲学是极致的泛型与类型安全。这导致了表示一个时间点或时间段的代码极其冗长:

// 标准库获取当前时间纳秒并计算的写法:
auto now = std::chrono::high_resolution_clock::now();
auto epoch = std::chrono::time_point_cast<std::chrono::nanoseconds>(now).time_since_epoch();
uint64_t now_nano = std::chrono::duration_cast<std::chrono::nanoseconds>(epoch).count();

如果在几万行的业务代码里充斥着这样的强转与模板实例,不仅难看,还容易把开发者绕晕。我们更希望用一个纯粹、简洁的对象来管理。

1.2 物理单位统一的需求

在分布式系统与消息日志中,时间戳常常需要被序列化传输。与其在不同模块间传递各种奇奇怪怪的类型,不如统一在底层用 uint64_t nanoseconds (纳秒) 作为唯一刻度。它既不会有浮点数(double)产生的累积精度丢失,又能无缝覆盖数百年跨度的高精度计时。

1.3 “时间跳变”带来的灾难(NTP 与 OS 休眠)

标准库的 system_clock 对应着挂钟时间(Wall Time),它受制于操作系统的 NTP(网络时间协议)同步。如果系统发现当前时间快了,NTP 会将时间“往回调”。
如果你的定时任务原本打算休眠到 10:00:05,而现在是 10:00:04。突然系统时间因为 NTP 被回滚到了 09:50:00。在原生API下,你的线程可能会傻傻地多休眠十分钟,导致由于节点心跳丢失引发整个自动驾驶车队紧急刹停(幽灵刹车)!

因此,需要对其进行包装,增加对 时间跳变(Time Jumps) 的校验和容错补偿。


二、基础组件:Duration 与 Time

在了解更为复杂的定时器之前,我们先看看两个大基建:Duration(时间段)和 Time(时间点)。它们分别解决“过了多久”和“现在是几点”的问题。

2.1 Duration:表示流逝的一段时间

Duration 内部的唯一成员变量是 int64_t nanoseconds_,正数代表正向的一段时间,负数则表示倒退。

它的核心方法封装了原生线程休眠:

void Duration::Sleep() const {
    auto sleep_time = std::chrono::nanoseconds(nanoseconds_);
    std::this_thread::sleep_for(sleep_time);  // 以相对时间休眠
}

通过重载大量的 +-* 等操作符,我们可以十分自然地表达 Duration(2.0) + Duration(1.5) 这样的算术。

2.2 Time:表示客观的绝对时间点

Time 内部是 uint64_t nanoseconds_,记录着自 Epoch(通常为 1970年1月1日)以来的纳秒数。我们提供了两种关键的获取方法:

  • Time::Now():利用 high_resolution_clock 或者 system_clock 获取真实世界的挂钟时间。多用于日志打印、时间戳记录。
  • Time::MonoTime():利用 steady_clock 获取单调递增时间。此时间绝对不会发生回滚,无论系统网络如何对齐。这才是用来计算“这段代码跑了多少毫秒”最精准的依据。

Time 与 Duration 的算术完美映射了物理法则:时间点 + 时间段 = 新的时间点。
Time + Duration = Time
Time - Time = Duration


3. 核心探讨:定频控制器(Rate)是如何炼成的?

现在我们进入这套时间系统中最精彩的部分。

3.1 错误示范:随性的 Sleep 定频法

很多朋友在写循环控制机制(比如 ROS 中的 node 循环,或者游戏主循环控制帧率)时,会写出这样的代码:想让频率保持在 10Hz(即每个周期 100ms)。

while (running) {
    DoHeavyAlgorithm(); // 执行业务逻辑:感知推断、规划控制...
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 睡100ms
}

为什么错得离谱?
DoHeavyAlgorithm() 不是瞬间完成的。假如它花费了 35ms,加上你休眠的 100ms,整个周期就变成了 135ms(实际运行频率跌到了约 7.4Hz!)。随着时间的推移,你的系统运作节奏将产生不可挽回的相位飘移。甚至每次因为操作系统的调度延迟,这个误差还在不断累积。

3.2 王道做法:根据“补差价”原则精确预期

为了解决漂移并维持绝对稳定的频率,我们需要在每个周期计算期望的结束时间。这就引出了 Rate 类。
Rate 类内部维护了三个核心变量:

  • start_:上一次循环预期或物理发生的基准时间点。
  • expected_cycle_time_:每个周期的固定流逝时长(例如 100ms)。
  • actual_cycle_time_:上一帧实际上花了多久。

真正的定标代码:

void Rate::Sleep() {
    // 1. 根据当前周期的基准起始点,计算它【应该结束】的时间
    Time expected_end = start_ + expected_cycle_time_;

    // 2. 获取此刻真实的时间
    Time actual_end = Time::Now();

    // ========= 【处理意外与极端情况】 =========
    
    // 情况A:检测到系统时间倒退(Backward Jump)
    if (actual_end < start_) {
        std::cout << "Detect backward jumps in time\n";
        // 既然时间倒退了,之前的期待时间作废。
        // 我们以被改变后的此刻时间为新起点,重新计算结束时间。
        expected_end = actual_end + expected_cycle_time_;
    }

    // 计算实际需要休眠拉平的时间差
    Duration sleep_time = expected_end - actual_end;

    // 记录这一圈的宏观消耗
    actual_cycle_time_ = actual_end - start_;
    
    // 更新起始点:下一圈以此刻期望的结束时间作为新起点。
    // 这点极其关键:它阻断了每次调度误差向后累加的可能。
    start_ = expected_end;

    // 情况B:检测到严重的超时阻塞或时间猛进(Forward Jump)
    // sleep_time < 0 说明此时此刻早已经过了我们预期的结束时间!
    if (sleep_time < Duration(0.0)) {
        std::cout << "Detect forward jumps in time\n";
        // 错过的时间大多是因为业务代码执行耗时过长,或者被挂起了。
        // 如果连一个完整周期都错过了,我们必须放下历史包袱(比如已经滞后了5帧,没必要疯狂补跑)
        if (actual_end > expected_end + expected_cycle_time_) {
            start_ = actual_end; // 重置新纪元
        }
        return; // 不休眠了,立即回去干活
    }

    // ========= 【正常休眠】 =========
    // 调用基于绝对时间点的精准休眠
    Time::SleepUntil(expected_end);
}

3.3 Rate 是如何让节拍变得“水滴石穿”般的稳定的?

假设预期 100ms 一次:

  1. 第1循环:在 0ms 时开始,业务花费 20msRate::Sleep 计算出期望在 100ms 结束。插值是 80ms,于是休眠 80ms。醒来时大约是 100.2ms(由于OS唤醒不准)。
  2. 第2循环start_ 被更新为精确的 100ms。业务花费 30ms。期望结束时间 expected_end 永远是精确的 100ms + 100ms = 200ms!此时的真实时间大概是 130.2ms,所以会精确休眠 200ms - 130.2ms = 69.8ms
    哪怕系统唤醒有微小的漂移,Rate 每次都会用算数抹平之前累计的调度误差,使得几十万次循环以后,时间戳依然能和理论推演的时间丝毫不差!

四、定时器体系的展望:从 Rate 到全异步 Timer

通过上面的代码讲解,其实我们手头有了一个特别好用的同步定频阻塞器(Rate)。但在成熟的中间件如 Cyber RT 中,这只是万里长征第一步。

Rate::Sleep() 有一个明显的局限:它会阻塞当前线程
一旦一个节点里需要 5 个不同频率的定时器任务,单纯用 Rate 就得开 5 个独立的系统线程,那将极度浪费资源并导致激烈的线程上下文切换。

为了进阶,在此我们通常会基于 TimeDuration 构建异步事件定时器 (Timer Manager)。它的宏观架构往往包含:

  1. 优先队列(优先级调度器):使用一个小顶堆(Min-heap),按预期唤醒时间的先后对所有被注册的回调任务进行排序。
  2. 单例守着时间钟:通过 epoll_wait 配合 Linux 的 timerfd(或者使用 C++ condition_variable + 只用一个睡眠线程),阻塞直到堆顶离我们最近的任务时间到期。
  3. 分发到线程池执行:时间到期时,把堆顶任务弹出,扔给异步的计算线程池执行。然后再计算并重新将其塞回优先级队列中。

这也是对我们这套高精度 Time 系统更高级的复用。唯有将时间对象化,它才能被安全地放进红黑树或是优先队列里做统一的比对和调度。

五、总结

在深入阅读和精进 C++ 底层项目后,我们会深切体会到底层基建的神奇。
从看似简单的 TimeDuration 抽象,到精准无误、会自我补偿休眠误差与容灾异常时钟跳变的 Rate 控制器,我们见证了软件工程对“确定性”的极致追求。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐