9.2 时间轮从零实现
9.1 把「能懒判定的」都降级成了状态查询,但总有一部分任务是必须「到点主动做点什么」的:体力攒满推消息、每日五点重置、限时活动开启、NPC 巡逻下一步。这类任务数量不小,而且往往集中在少数几个时间点上触发,如果还是「一个任务一个 time.Timer」,就会同时出现两个问题:一是挂载与触发的调度开销,二是回调在哪一拍的上下文里执行不可控——它可能在任意一个运行时线程上跑,打乱你对逻辑顺序的假设。
时间轮解决的正是这两件事。它把「所有定时器」收敛成「一个数组 + 一个游标」,挂载是 O(1) 的取模,触发集中在你自己推进的那一拍里,回调执行时机完全可控。代价是精度退化到一格(tick),但正如 9.1 所说,游戏逻辑本来就不需要毫秒级精度。
时间轮的结构
一个哈希时间轮就是一个长度为 slots 的数组,加一个永远向前走的游标 cursor。每个槽位挂一条任务链表;游标每推进一格(一个 tick),就检查当前槽位里的任务是否到期,到期的触发、没到期的搬到后面的槽位。结构如下:
interval = 10ms, slots = 8, span = 80ms
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ← buckets[slots]
└────┴────┴────┴────┴────┴────┴────┴────┘
▲cursor 每 Tick 右移一格
落格公式:slot = (cursor + ticks) % slots,ticks = 剩余时间 / interval
关键在落格公式:把「还有多久到期」换算成「还有几格」,再对格子数取模。这带来两个性质。第一,挂载是 O(1) 的——一次整除、一次取模、一次 append,和轮子里已有多少任务无关。第二,单圈跨度有限:span = interval * slots,超过一圈的定时器没法只靠取模定位,得靠「级联」处理。
设计参数时,interval 决定精度下限,slots 决定单圈能覆盖多长。心跳超时这类任务周期是秒级,用 interval = 100ms, slots = 64 就能覆盖 6.4 秒一圈,绝大多数任务一圈内解决;更长的周期靠级联兜住。
为什么不用「最小堆」而用「哈希取模」?最小堆(很多定时器库的默认实现)的插入是 O(log n)、取最近到期是 O(1)。看起来也不差,但它有两个问题。其一,插入的 log n 随着堆里任务变多而变大,而游戏服务端恰恰是「任务数很大」的场景。其二,也是更关键的:最小堆每次只暴露「最近的一个」到期任务,触发时要反复 pop 再 push;而时间轮的游标每推进一格,就把这一格里所有任务一次性处理完,天然按时间分桶,一拍的触发是一批而不是一个。当同一个 tick 里有几千个 Buff 同时到期时,时间轮的批处理特性比堆的逐个弹出更贴合游戏逻辑的「一拍一世界」模型。
从零实现
下面这段是 L1,go test 可以直接跑。它实现了一个支持任意时长的哈希时间轮,核心是三个方法:place 负责 O(1) 挂载,Tick 负责推进与触发,Run 负责按真实时钟驱动。
// file: internal/timing/wheel.go
// tier: L1
// verify: go test ./internal/timing/
// Package timing 提供游戏服务端的三种时间设施:技能冷却与心跳看门狗(懒判定)、
// 哈希时间轮(海量短周期定时器),以及逻辑帧世界时钟(固定步长 Tick)。
//
// 核心取舍:游戏服务端的定时器数量级是「每玩家几十个」。time.AfterFunc 每个任务
// 一个堆节点 + 一个 runtime timer,挂载是 O(log n);时间轮把挂载降成 O(1) 取模,
// 代价是精度退化到一格(tick)。短周期、精度不敏感的定时器走时间轮最划算。
package timing
import (
"sync"
"sync/atomic"
"time"
)
// Task 是时间轮上的一个定时任务。
type Task struct {
ID uint64
expires time.Time
fn func()
}
// Wheel 是按固定 tick 推进的哈希时间轮。
//
// 同一格里的任务如果到期时间超过了一圈,会在触发时被重新挂回后面的格子
// (cascade 级联),因此支持任意时长的定时,而不只是 wheel 一圈以内。
type Wheel struct {
interval time.Duration
slots int
buckets [][]*Task
cursor int
now time.Time
seq atomic.Uint64
mu sync.Mutex
}
// New 建一个时间轮。interval 是 tick 粒度,slots 是格子数,
// 单圈跨度 Span() = interval * slots。
func New(interval time.Duration, slots int) *Wheel {
if interval <= 0 {
panic("timing: interval must be positive")
}
if slots <= 0 {
panic("timing: slots must be positive")
}
return &Wheel{
interval: interval,
slots: slots,
buckets: make([][]*Task, slots),
now: time.Now(),
}
}
// Span 返回单圈跨度。
func (w *Wheel) Span() time.Duration {
return w.interval * time.Duration(w.slots)
}
// After 在 d 之后触发 fn,返回任务 ID。d <= 0 时下一个 tick 触发。
func (w *Wheel) After(d time.Duration, fn func()) uint64 {
w.mu.Lock()
defer w.mu.Unlock()
t := &Task{ID: w.seq.Add(1), expires: w.now.Add(d), fn: fn}
w.place(t)
return t.ID
}
// place 把任务放到它应该落的格子里。调用方必须持有锁。
//
// 这是时间轮的 O(1) 挂载:把「到期时刻」折算成「还有几格」,再对格子数取模。
// 不需要比较、不需要下沉,一个加法一个取模就定位到槽位。
func (w *Wheel) place(t *Task) {
ticks := int(t.expires.Sub(w.now) / w.interval)
if ticks < 1 {
ticks = 1 // 已过期或刚好到点:落到下一格,保证至少推进一次
}
slot := (w.cursor + ticks) % w.slots
w.buckets[slot] = append(w.buckets[slot], t)
}
// Tick 推进一格并触发到期任务,返回本次触发的任务数。
// 测试里手动调用以获得确定性;生产里由 Run 驱动。
func (w *Wheel) Tick() int {
w.mu.Lock()
w.now = w.now.Add(w.interval)
w.cursor = (w.cursor + 1) % w.slots
bucket := w.buckets[w.cursor]
w.buckets[w.cursor] = nil
var due, later []*Task
for _, t := range bucket {
if !t.expires.After(w.now) {
due = append(due, t)
} else {
later = append(later, t)
}
}
// 还没到期的任务重新挂回后面的格子(级联)。因为 place 用的是「剩余格数」,
// 一次级联就把任务推进到它真正该到的那一圈。
for _, t := range later {
w.place(t)
}
w.mu.Unlock()
// 回调必须在锁外执行:回调里极可能又调用 After 注册新定时器,
// 在锁内跑回调会自死锁(sync.Mutex 不可重入)。
for _, t := range due {
t.fn()
}
return len(due)
}
// Run 按真实时钟驱动时间轮,直到 stop 关闭。
func (w *Wheel) Run(stop <-chan struct{}) {
tk := time.NewTicker(w.interval)
defer tk.Stop()
for {
select {
case <-stop:
return
case <-tk.C:
w.Tick()
}
}
}
有一个细节必须强调:回调在锁外执行。Tick 先把到期任务摘到 due 切片、把未到期的搬走、Unlock,然后才在锁外逐个 fn()。原因是回调里极可能又调用 After 注册新定时器(「技能冷却结束后自动进入下一段 Buff」),而 sync.Mutex 不可重入——如果在持锁时执行回调,第二次 After 会直接自死锁。同样的理由也适用于 9.1 的 Sweep 与看门狗回调。
级联:超出单圈怎么搬
时间轮最容易写错的地方是级联。如果一个任务要到 270ms 后才触发,而单圈跨度只有 80ms,取模会把它放到一个「还没转到、但一到就该触发」的槽位——如果那一拍直接触发它,定时就提前了。正确的做法是:任务被游标扫到时,再判断一次是否真的到期;没到期就重新落格。 下面的测试用确定性手动 Tick 验证了这一点(10ms × 8 槽,跨度 80ms,任务挂在 270ms):
// snippet: internal/timing/wheel_test.go(节选)
func TestWheelCascadeBeyondOneSpan(t *testing.T) {
w := New(10*time.Millisecond, 8) // span = 80ms
fired := 0
// 270ms 远超一圈,必须靠级联才能准时触发。
w.After(270*time.Millisecond, func() { fired++ })
for i := 0; i < 26; i++ {
if n := w.Tick(); n != 0 {
t.Fatalf("tick %d fired %d, want 0", i+1, n)
}
}
if n := w.Tick(); n != 1 { // 第 27 拍 now=270ms
t.Fatalf("tick 27 fired %d, want 1", n)
}
if fired != 1 {
t.Fatalf("long timer ran %d times, want 1", fired)
}
}
这个任务在 270ms 处先落到槽 (0+27)%8 = 3,第 3 拍被扫到时发现 expires 还没到,重新落格到槽 3;如此反复,直到第 27 拍 now = 270ms 才真正到期。注意它每一圈都落回同一个槽 3——因为「剩余格数」每圈都恰好是 8 的整数倍,这正是哈希时间轮的巧妙之处:级联不需要遍历,一次取模就完成了搬迁。
任务 expires = 270ms,interval = 10ms,slots = 8
落格:slot = (cursor + 剩余ms/10) % 8
┌─ tick 3 (now=30ms) cursor=3 剩余 24 格 → slot (3+24)%8 = 3 未到期,重挂
├─ tick 11 (now=110ms) cursor=3 剩余 16 格 → slot (3+16)%8 = 3 未到期,重挂
├─ tick 19 (now=190ms) cursor=3 剩余 8 格 → slot (3+ 8)%8 = 3 未到期,重挂
└─ tick 27 (now=270ms) cursor=3 expires ≤ now ✓ 触发
这条 ASCII 就是级联的全部:每一圈落回同一槽,扫到时再判一次,直到真正到期。 反过来说,如果 place 里忘了「剩余格数」这个折算、直接用绝对格数取模,长定时器就会被提前触发——这是时间轮实现里最经典的一个坑。
实测:O(1) 挂载
本机(Go 1.26,Apple M1 Pro,macOS arm64)基准,同机相对量级,绝对数值不可移植:
| 基准 | ns/op | 每次挂载 | 内存/次 | 分配/次 |
|---|---|---|---|---|
BenchmarkWheelMount(时间轮 After) | 77.29 | 约 77 ns | 79 B | 1 |
BenchmarkAfterFuncMount(time.AfterFunc,见 9.1) | 1,198,960 / 10000 | 约 120 ns | 约 120 B | 1 |
时间轮的挂载只快约 1.5 倍,不是数量级差距——因为 Go 运行时内部本来就是按 P 分片的 64 槽时间轮,time.AfterFunc 已经很快了。自写时间轮的真正收益在别处:挂载复杂度与轮内任务数无关(place 永远是一次整除加取模),而运行时定时器堆在任务多时仍有对数级的管理成本;更重要的是控制权——回调在你推进的那一拍里、按你确定的顺序执行,可以整表快照、可以按实体批量取消(玩家下线时把他的定时器一次性摘掉)。这些能力是 time.AfterFunc 给不了的。
顺带回答一个常见疑问:既然自写只快 1.5 倍,那还有必要自己写吗?答案是看你要不要「确定性」。网络与并发卷后面会反复用到「逻辑一拍一世界」的模型:第 10 章的 Actor 邮箱、第 12 章的帧同步、第 13 章的服务器回滚,都要求「同一个 tick 里发生了什么」是确定的、可重放的。time.AfterFunc 的回调在任意运行时线程上、任意时刻触发,天然破坏这个模型;而时间轮的 Tick 由你主动调用,回调严格发生在你推进的这一拍里,顺序由你决定。对这类场景,自写的收益不是性能而是语义。
本节小结
- 时间轮 = 定长数组 + 游标;挂载是
slot = (cursor + ticks) % slots的 O(1) 取模。 - 单圈跨度
span = interval * slots;超出单圈的靠级联兜住——扫到时再判一次是否到期,没到就重新落格。 - 回调必须在锁外执行,否则回调里再注册定时器会因
sync.Mutex不可重入而自死锁。 - 实测挂载只比
time.AfterFunc快约 1.5 倍;自写的价值是确定性与批量控制,不是速度。 - 级联的实现要点:
place必须用「剩余格数」而非绝对格数;扫到时必须再判一次expires是否真的到了。
阅读导航:上一节:9.1 心跳超时与技能冷却 · 下一节:9.3 世界时钟与 Tick 预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。