时间轮调度器比红黑树更适合高频定时任务,因其O(1)插入与摊还O(1)到期扫描,避免了红黑树O(log n)导致的延迟恶化;需用steady_clock、相对时间偏移、无锁批量搬运及统一内存管理。

时间轮调度器为什么比 std::set<:pair task>> 更适合高频定时任务
因为红黑树每次插入/删除都是 O(log n),而时间轮是 O(1) 插入、O(1) 到期扫描(摊还)。当每秒新增上千个 100ms~5s 的短周期任务时,红黑树会明显卡顿——尤其在嵌入式或游戏服务器这类对延迟敏感的场景。
真实踩坑点:有人用 std::chrono::steady_clock::now() 做 key 存进 std::map,结果发现单次 tick 处理耗时从 20μs 涨到 300μs+,且随任务数增长非线性恶化。
- 时间轮把「绝对时间」映射为「槽位索引」,靠位运算(如
hash = (expire_time / tick_ms) & (N-1))实现零分支跳转 - 必须用单调时钟(
std::chrono::steady_clock),不能用system_clock——系统时间被 NTP 调整会导致大量任务提前或丢失 - 单层时间轮只适合固定范围(比如最大延时 60s),超出就需分层(毫秒轮 + 秒轮 + 分轮),但层级越多 cache miss 越严重
如何设计无锁插入 + 批量到期执行的 Tick 线程模型
核心矛盾:主线程频繁添加任务,Tick 线程每毫秒扫描一次,二者共享槽位数组。若用互斥锁,高并发下锁争用会让吞吐掉一半以上。
实操建议用「生产者-消费者队列 + 每 tick 批量搬运」:
- 主线程只往一个
std::vector本地缓存 push_back,不碰共享结构 - Tick 线程每轮开始前,用
std::atomic_exchange原子交换出这个缓存,再批量 hash 到对应槽位(避免反复加锁) - 到期扫描时,直接遍历当前槽位链表,用
std::exchange(ptr->next, nullptr)拆链,确保无锁遍历安全 - 注意:Task 对象生命周期必须由调度器统一管理(比如用
std::shared_ptr或对象池),不能让回调函数 delete 自己
std::chrono::duration_cast<:chrono::milliseconds>(now - base).count() 这行代码为什么必须做?
因为时间轮依赖「相对刻度」,不是绝对时间戳。如果直接用 now.time_since_epoch().count(),数值太大(纳秒级 19 位整数),除法取模慢,且容易溢出;而转换成毫秒后,配合 2048 槽的轮子,只需 & 0x7FF 就能完成取模,CPU 友好。
关键细节:
- base 时间必须在调度器启动时一次性获取:
const auto base = std::chrono::steady_clock::now();,之后所有任务都相对于它计算偏移 - 不能每 tick 都重新算 base——会导致同一任务在相邻两轮被重复插入不同槽位
- 如果任务延迟要求精确到微秒(比如音视频同步),那就用微秒单位,但槽位数要翻 1000 倍,内存占用和 cache 行压力直线上升
为什么多数开源实现不敢去掉「过期重插」逻辑?
因为分层时间轮里,一个 65s 后执行的任务,在毫秒轮满一圈(60s)后还没到期,得“降级”到秒轮。这个过程叫 rehash 或 cascade,本质是把任务从当前轮移出、重新计算在上层轮的槽位。
这里极易出错:
- 没做内存屏障,导致 Tick 线程看到部分构造的任务(指针非空但成员未初始化)
- 重插时没检查是否已被 cancel(
task->cancelled.load()应在插入前原子读取) - 多层轮之间用不同 tick 单位(毫秒轮 tick=1ms,秒轮 tick=1000ms),但忘记把剩余延迟按新单位折算,导致任务提前 1000 倍触发
真正轻量的方案是:单层时间轮 + 大槽位(比如 65536 槽,tick=10ms,覆盖 10 分钟),避开 cascade 的复杂性。代价是内存多占几 MB,换来逻辑清晰和调试友好。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











