时间轮通过空间换时间将定时器操作降至O(1):将时间划分为固定槽位的环形数组,任务按延迟映射到对应槽链表,避免遍历与堆排序开销,显著提升高并发下调度性能与精度。

为什么 std::chrono + std::thread 手搓定时器在高并发下掉帧严重
因为每次新增/取消任务都要加锁遍历链表或红黑树,O(n) 或 O(log n) 的插入+删除开销在每秒数万次调度时直接拖垮 CPU 缓存和线程竞争。真实业务中常见现象是:任务延迟从毫秒级跳到百毫秒,std::this_thread::sleep_for 被频繁打断,std::condition_variable::wait_for 唤醒不准。
时间轮本质是空间换时间:把未来一段时间(比如 60 秒)切分成固定槽(slot),每个槽挂一个任务链表。插入/删除都是 O(1),只要槽粒度合理(如 10ms 一格),就能把大量短周期任务摊平到不同缓存行上。
- 单层时间轮适合固定范围、低频更新的场景(如连接空闲检测),但到期任务集中爆发会导致单槽链表过长
- 分层时间轮(Hierarchical Timing Wheel)更实用:底层细粒度(10ms),上层粗粒度(如 256 × 10ms = 2.56s),靠“溢出迁移”自动降级,避免全局扫描
- 必须用无锁队列(如
moodycamel::ConcurrentQueue)承接外部提交的任务,否则add_timer()本身就成了瓶颈
tick() 函数里不能做任何阻塞或虚函数调用
时间轮的滴答函数是整个调度器的心跳,它被高频调用(比如每 10ms 一次),任何意外延迟都会导致后续所有槽位偏移。典型错误包括:std::cout 、调用 virtual void on_timeout()、访问未预热的 std::unordered_map。
- 到期任务回调必须通过无锁队列投递到工作线程池,
tick()只负责解链、移动指针、触发唤醒信号 - 槽数组必须用
std::vector<:atomic>>或裸指针数组 + 内存屏障,避免编译器重排破坏链表一致性 - 如果使用 RCU 风格延迟释放节点,要确保
tick()不访问已标记为删除但尚未回收的内存
如何让 add_timer() 支持纳秒级精度却不牺牲吞吐
硬件时钟(clock_gettime(CLOCK_MONOTONIC, ...))能提供纳秒级时间戳,但时间轮本身只按槽步长推进。关键在于:不把“绝对超时时间”硬塞进槽索引,而是拆成两部分存储。
- 每个
TimerNode存两个字段:rounds(需等待多少整圈)和slot_index(当前圈内第几格),这样 10ms 槽也能表示 3.7s 后触发 - 插入时用当前 tick 计数减去目标时间,算出相对轮数和余数,全程整数运算,无浮点误差
- 避免在
add_timer()中调用std::chrono::steady_clock::now()—— 改用线程局部缓存的最近一次 tick 时间,差值超过阈值再同步一次
Linux 上 timerfd_create() 和纯用户态时间轮谁更快
实测结论:纯用户态时间轮在 QPS > 5k 时稳定压倒 timerfd。因为后者每次 epoll_wait() 返回后仍要 read() 清空 fd,且内核 timerfd 实现本身也是红黑树,高并发下红黑树旋转和 cache line bouncing 更严重。
-
timerfd唯一优势是信号安全,适合嵌入已有 epoll 主循环;但若你控制整个事件循环,用户态轮更可控 - 注意
CLOCK_MONOTONIC_RAW比CLOCK_MONOTONIC更少受 NTP 调整影响,对长时间运行的服务更稳 - 别忘了关闭 ASLR(
echo 0 > /proc/sys/kernel/randomize_va_space)做性能对比,否则指针跳变会干扰 L3 cache 命中率
真正难的是多核 NUMA 下的槽数组内存分布——把每个层级的槽数组 mmap(MAP_HUGETLB | MAP_POPULATE) 并绑定到对应 socket,不然跨 NUMA 访问延迟直接吃掉一半性能提升。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











