std::chrono+数组索引比std::priority_queue更适合时间轮,因其满足o(1)插入与检查需求;时间轮适合短期集中任务,依赖steady_clock、2的幂数组大小及槽位/轮数分层计算,多线程需原子槽位+桶级锁。

为什么 std::chrono + 数组索引比 std::priority_queue 更适合时间轮
时间轮的核心诉求是 O(1) 插入和 O(1) 检查到期任务,而不是全局最小堆排序。用 std::priority_queue 实现定时器,每次 tick 都得 pop 所有到期项,实际是 O(k log n),k 是到期数;而时间轮把时间切片后映射到固定大小数组,插入和推进都只操作一个槽位。
关键点在于:时间轮不是万能的,它适合大量短期、相对时间集中的任务(比如网络连接超时、心跳检测),不适合长周期任务(如“7 天后发邮件”)——这类必须降级到多级时间轮或单独处理。
-
std::chrono::steady_clock是唯一可选时钟,system_clock可能被系统调整导致跳变 - 单层时间轮的精度由 tick 间隔决定,比如 tick=10ms,那所有定时器误差在 [0,10ms) 区间
- 数组大小建议设为 2 的幂(如 256、1024),方便用位运算取模:
index & (size-1)
如何用 std::vector 存桶并避免内存抖动
每个槽位存一个 std::vector<timerentry></timerentry>,但频繁 push_back 会触发多次 realloc。更稳的做法是预分配 + 环形缓冲模拟,或者直接用 std::list ——虽然缓存不友好,但插入/删除稳定 O(1),且不涉及内存重分配。
示例结构体要轻量:
struct TimerEntry {
uint64_t expiration; // 绝对时间戳(单位:ms)
std::function<void> cb;
bool canceled = false;
};</void>
- 不要在
TimerEntry里存std::shared_ptr或大对象,回调函数应捕获必要数据而非整个对象 - 取消机制靠
canceled标志位,tick 时跳过已取消项,避免 erase 引发迭代器失效 - 每轮推进后清空当前槽位 vector,但不 shrink_to_fit ——下一轮大概率还要用
add_timer() 怎么算槽位和轮数
假设 tick=10ms,总槽数=256,则一圈耗时 256×10=2560ms。若任务延迟 3000ms,它不在第一圈,而在第二圈的第 (3000−2560)/10 = 44 槽(索引从 0 开始)。
计算逻辑必须拆成两步:
- 先算相对 now 的延迟毫秒数:
delay_ms = expiration_ms - now_ms - 再分层:
auto wheel_idx = delay_ms / (tick_ms * slots_per_wheel);判断是否溢出(>0 就得进高阶轮) - 槽位索引:
(now_ms / tick_ms + delay_ms / tick_ms) & (slots - 1),注意这里用的是绝对时间推导,不是 delay 直接取模
漏掉轮数判断会导致长延迟任务永远不触发 —— 这是最常踩的坑。
多线程下怎么安全读写 current_slot 和桶
主线程推进 slot,工作线程添加/取消定时器,不能裸用 int current_slot。推荐方案:用 std::atomic_int 存当前槽位,写桶时用 std::mutex 锁对应 bucket(不是全局锁),粒度越细越好。
- 避免用
std::atomic<timerentry></timerentry>做无锁链表 —— C++ 内存模型复杂,易出 ABA 问题 - 添加定时器时,先算目标槽位,再 lock 对应 bucket mutex,push_back 后 unlock
- tick 推进在单线程做(如 io_uring 完成队列、epoll wait 返回后),不加锁读
current_slot,然后 lock 对应 bucket 清理
真正难的不是实现,而是确认所有回调都在预期线程执行 —— 如果业务要求回调在 IO 线程,就得把 cb post 到对应 event loop,而不是直接调用。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











