代表性面试主题

编程面试:实现一个 Hashed Timing Wheel 定时器

编程题困难
Offer.cc 编辑团队发布 更新

题干

请实现一个支持 start、schedule、cancel 的 Hashed Timing Wheel。它需要管理数百万个超时任务,允许毫秒级 tick,但不要求每个任务精确到毫秒。你会如何设计桶、轮转、剩余圈数和并发边界?

题干与适用场景

这道题考察你能否为大量近似定时任务选择合适的数据结构。二叉堆按到期时间排序,插入和删除通常需要维护堆序;时间轮把到期时间映射到桶,适合网络超时、重试和连接保活等对精确度要求有限的场景。回答必须说明精度、复杂度、取消语义、长延迟和执行线程边界。

面试官考察什么

  • 能否把 tick、桶数量、时间跨度和任务 deadline 建立清晰关系。
  • 能否正确处理跨轮任务、当前桶任务、迟到 tick 与提前触发。
  • 能否让取消操作便宜,并避免已取消节点在桶中造成错误执行。
  • 能否说明线程安全、回调隔离、时钟选择和过载时的行为。

回答前需要澄清的问题

先确认任务数量级、允许的最早或最晚误差、最短延迟、最大延迟、取消比例和回调耗时。任务是否必须持久化?进程重启后是否恢复?schedule 和 cancel 是否会被多个线程调用?回调是否允许阻塞?如果一次 tick 中到期任务太多,是延迟执行、丢弃低优先级任务,还是施加背压?这些答案决定单轮时间轮是否足够,以及是否需要分层时间轮或外部持久化。

30 秒回答框架

我会用单调时间计算相对 deadline,以固定 tick 推进游标。每个任务根据剩余 tick 数计算桶索引和轮数,放入双向链表;tick 到达时只扫描当前桶,轮数大于零的任务减一并留在桶内,轮数为零且 deadline 已到的任务移出并提交执行。cancel 使用句柄把节点标记为取消并从链表摘除。时间轮只负责调度,不在 tick 线程执行用户回调;所有误差、并发和过载策略都用测试与指标验证。

分步骤深入解答

1. 定义时间轮参数与误差

设 tick 为 tickDuration,桶数为 wheelSize,一轮覆盖 tickDuration × wheelSize。deadline 先转换为相对 tick 数;索引可由 (currentTick + remainingTicks) mod wheelSize 得到。tickDuration 决定最小分辨率,轮覆盖范围决定单轮能直接容纳的延迟。需要更大范围时,使用层级时间轮或把任务留在当前桶并记录剩余圈数。

2. 选择任务节点与桶结构

每个节点保存 deadline、remainingRounds、callback、cancelled 和前后指针。桶使用双向链表,插入和已知节点删除为常数时间;句柄直接指向节点,避免 cancel 时重新搜索。不要用数组保存所有任务再逐 tick 扫描,否则任务数量会直接放大每次推进的成本。

3. 推进 tick 并处理跨轮任务

每次 tick 先推进单调时钟和游标,再摘下当前桶的节点。remainingRounds 大于零时减一并重新挂回;为零时比较实际 deadline,尚未到期就按新的剩余 tick 重新入桶,到期才提交执行。若线程长时间暂停导致跳过多个 tick,要限制一次补偿扫描量并记录延迟,避免恢复时阻塞过久。

4. 处理 schedule、cancel 与竞态

可以用一个生产者队列把 schedule 和 cancel 请求交给唯一 tick 线程,减少桶锁竞争。cancel 先设置取消标记,再尝试摘链;tick 线程取出节点后再次检查标记,保证竞态下不会执行已取消回调。schedule 的 deadline 早于当前时间时,应定义为当前 tick 尽快执行,不能把负数取模后放入任意未来桶。

5. 隔离回调执行与过载

tick 线程只负责移动节点和提交任务,把用户回调交给受控执行器。执行器满载时要有明确策略,例如限制队列、按优先级丢弃可丢任务、延迟非关键任务或返回过载错误。记录到期延迟、桶扫描耗时、取消数、执行队列长度和回调失败,才能判断时间轮本身或下游执行器成为瓶颈。

6. 用伪代码固定核心不变量

核心循环可以写成以下形式,具体锁和线程模型依实现语言决定:

text
schedule(task, deadline):
    ticks = ceil((deadline - now) / tickDuration)
    ticks = max(ticks, 0)
    node.rounds = ticks / wheelSize
    node.bucket = (currentTick + ticks) % wheelSize
    buckets[node.bucket].append(node)
    return node.handle

advance(now):
    while currentTick <= floor(now / tickDuration):
        bucket = buckets[currentTick % wheelSize]
        for node in bucket.detachAll():
            if node.cancelled: continue
            if node.rounds > 0:
                node.rounds -= 1
                bucket.append(node)
            elif node.deadline <= now:
                executor.submit(node.callback)
            else:
                schedule(node, node.deadline)
        currentTick += 1

高质量示范回答

我会先确认误差、延迟范围、取消比例、持久化和回调阻塞约束。实现上用单调时钟和固定 tick 推进游标,桶是双向链表,节点保存 deadline、remainingRounds、取消标记和句柄。schedule 根据剩余 tick 计算桶索引与轮数,cancel 通过句柄摘链并设置标记;tick 线程只处理当前桶,跨轮节点递减轮数,deadline 到期后提交到受控执行器。对跳过 tick 的恢复限制补偿量并记录延迟;对执行器过载设置队列上限、优先级和失败策略。测试覆盖边界 deadline、长延迟、重复 cancel、并发 schedule/cancel、时钟跳跃、回调异常和数百万节点的扫描成本。若需要更低误差或更大时间范围,再考虑层级时间轮或堆作为补充。

常见错误

  • 把 wall clock 直接用于相对计时,忽略系统时间调整。
  • 忘记保存 remainingRounds,导致跨轮任务提前执行。
  • 当前桶任务尚未到期时直接执行或静默丢失。
  • cancel 只设置布尔值,却没有在取出节点后再次检查。
  • 在 tick 线程同步运行用户回调,造成整个时间轮停顿。
  • 用固定数组全量扫描任务,失去时间轮的稀疏调度优势。
  • 没有定义跳 tick、过载、重启恢复和回调异常的行为。

追问及应对

为什么不用最小堆?

最小堆适合任务量较小、需要更精确排序的场景;每次插入或删除要维护堆序。时间轮牺牲精确度,换取桶内常数时间的挂载与摘除,适合大量、近似到期的任务。应根据误差预算和操作分布选择,而不是声称时间轮总是更快。

tick 线程暂停了几秒怎么办?

用单调时间计算当前应到的 tick,限制一次补偿处理的最大桶数或任务数,并把剩余任务留给后续循环。记录调度延迟;如果业务不能接受突发补偿,应配合分批执行和背压,不能在恢复瞬间同步执行全部回调。

如何保证 cancel 不会误执行?

句柄指向节点,cancel 先原子设置取消标记,再尝试摘链;tick 线程从桶取出后必须再次检查标记。若回调已经提交到执行器,只能定义取消的线性化点,并让回调在开始前检查任务状态。

什么时候需要层级时间轮?

当单轮覆盖范围不足以容纳最大 deadline,或任务跨度跨越多个数量级时,可以增加更粗粒度的上层时间轮,把任务逐层下放。每层仍需明确误差、降级和迁移成本;若需要持久化和故障恢复,还要把时间轮与可靠存储或消息系统分离。

公开来源

同类题目

相关面试工具

用 Screenshot 处理算法题

截图题目后,按顺序看约束、解法、代码、边界条件和复杂度。

查看工具