《MMO 服务器开发》9.2 时间轮从零实现

时间轮用「一个数组 + 一个游标」把所有定时器塞进固定数量的槽位,把挂载从 O(log n) 压到 O(1),代价是精度退化到一格。本节从零写一个支持任意时长的哈希时间轮:槽位、cursor、级联搬迁、Tick 推进,以及为什么回调必须在锁外执行;再用确定性测试验证跨圈的定时器仍能准时触发。

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 ns79 B1
BenchmarkAfterFuncMount(time.AfterFunc,见 9.1)1,198,960 / 10000约 120 ns约 120 B1

时间轮的挂载只快约 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 预算 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标