《游戏服务端架构·品类、状态与拓扑》4.2 tick、帧预算与世界时钟

从《裂隙战场》一次 500ms 停顿后技能连放讲起,说明固定步长 tick 为什么不能直接跟着墙上时间跑;给出带 CatchUp 上限、丢弃计数、漂移余量和时钟回拨保护的 Go Loop 实现,覆盖正常推进、大延迟补帧、超限丢弃与回拨四类测试,并与第 1 章 33ms/16ms 的帧预算口径对齐。

一次 500ms 停顿,十五个 tick 同时到达

《裂隙战场》的房间服按 30Hz 运行,每 33ms 推进一次战斗状态。某个版本上线后,玩家反馈“偶尔所有技能一起爆”,录制回放显示:一次 500ms 的 GC 停顿之后,服务器在极短时间内连续结算了 15 个 tick,所有原本应该错开的技能和位移挤在同一帧生效。

问题不在 GC 本身,而在 tick 循环的写法。团队最早用的是最直观的版本:

for {
    simulateOneTick()
    time.Sleep(33 * time.Millisecond)
}

这个循环的真实周期是“33ms + 本帧处理时间”。如果每帧处理 5ms,实际就是 38ms 一帧,一小时累积落后墙上时间约 9 分钟。另一种改法是每次醒来计算“欠了多少帧”,然后一次性全部补上:

elapsed := time.Since(last)
for i := 0; i < int(elapsed/33ms); i++ {
    simulateOneTick()
}

这解决了漂移,却制造了新的尖峰:停顿越久,补的帧越多,补帧本身又消耗时间,形成“越补越落后”的正反馈。固定步长 tick 要同时解决三个问题:步长稳定、补帧有上限、余量不累积。

墙上时间不是世界时间

time.Now() 可以被 NTP 和运维调整,适合记录日期,不适合驱动 33ms 步进。Go 的 time.Time 在进程内带有单调读数,Sub 和 Since 一般不受回拨影响,但跨进程、持久化之后单调读数就消失了。

世界时间必须由服务端定义:tick 用 UTC 毫秒,日历和每日重置用运营配置的时区。两者混用,跨天结算会在不同地域算出不同结果。

固定步长:把时间切成可重放的格子

固定步长的核心是:模拟只按固定的 Interval 前进,墙上时间只决定“现在应该走到第几格”。

世界时间轴(固定 33ms 一格)
|----|----|----|----|----|----|
 t0   t1   t2   t3   t4   t5
           ^
     墙上时间到这里,应执行到 t2

好处是处理逻辑可复现、快照 tick 有明确含义、帧预算可以按格测量;代价是循环必须维护锚点、当前 tick 和不足一格的余量。

补帧、丢弃与漂移校正

一次 Advance 可能遇到四种情况,处理策略不同:

情况策略代价
正常延迟,欠帧数在上限内补齐所有欠帧短时 CPU 上升
单次大延迟,欠帧数超过上限只补上限,其余丢弃并计数模拟时间跳过一段
不足一格保留为漂移余量,下次继续无
时钟回拨不反向执行,重新锚定并计数需要告警

补帧的本质是“用现在的 CPU 偿还过去的时间”。停顿期间欠了 15 帧、每帧处理 5ms,补完需要 75ms,这又会让下一次醒来欠更多帧;没有上限时系统可能永远追不上。上限把“追不上”变成“明确丢弃”:模拟时间跳过一段,玩家可能看到位移突变,但它是可观测、可告警的,比全服卡死好。

漂移余量解决另一个方向的问题。步长 33ms、墙上时间过了 50ms,执行 1 帧后还剩 17ms;如果直接把锚点设成“现在”,这 17ms 就永久丢失。正确做法是把锚点推进到已执行帧的边界,让余量带到下一次,下次只需再过 16ms 就能凑满一格。

时钟回拨则必须显式识别。now - last 为负时,如果代码写 for i := 0; i < due; i++,负数不执行,看起来“没事”,但锚点没有更新,后续计算全错;更危险的是把负延迟当欠帧、反向推进世界。正确做法是不执行任何帧、重置锚点到当前时间、增加回拨计数并告警。

帧预算与 tick 的关系

第 1 章把延迟拆成帧预算、操作预算与结算预算 ,其中帧预算回答“一次模拟 tick 最多能花多久”。30Hz 对应 33ms,60Hz 对应 16ms,硬上限约为步长的 1.5 倍:33ms 对应 50ms,16ms 对应 25ms。只规定步长不规定处理时间,会出现“每 33ms 醒一次、每次处理 80ms”的循环,实际帧率已经掉了一半;只规定预算不规定步长,则补帧数量无法计算。

所以本节实现的 Loop 直接复用 internal/budget 的 Frame 类型和 Check 函数,把超限判断交给第 1 章的同一套口径,而不是在 tick 包里再定义一套阈值。

代码:Loop

下面是 internal/tick 的完整实现。它只做四件事:把墙上时间换算成固定步数、限制单次补帧、保留漂移余量、记录超预算的帧。

// file: internal/tick/loop.go
// tier: L1
// verify: go test ./internal/tick/
package tick

import (
	"time"

	"gsarchlab/internal/budget"
)

const (
	// Frame30Hz and Frame60Hz match the frame budgets in chapter 1: a 30Hz
	// simulation gets 33ms per step, a 60Hz simulation gets 16ms.
	Frame30Hz = 33 * time.Millisecond
	Frame60Hz = 16 * time.Millisecond

	// MaxFrame33 and MaxFrame16 are the hard ceilings from the same budget
	// table, roughly 1.5x the step. A step above the ceiling is a violation,
	// not just a slow frame.
	MaxFrame33 = 50 * time.Millisecond
	MaxFrame16 = 25 * time.Millisecond
)

// Loop converts wall-clock time into a fixed number of simulation steps.
// Interval is the fixed step, MaxCatchUp bounds how many steps one Advance
// may execute, and MaxFrame is the per-step budget used for violation checks.
type Loop struct {
	Interval   time.Duration
	MaxCatchUp int
	MaxFrame   time.Duration

	last      time.Time
	tick      uint64
	dropped   uint64
	rollbacks uint64
	drift     time.Duration
	samples   []budget.Sample
}

func New(interval time.Duration, maxCatchUp int, maxFrame time.Duration) *Loop {
	if interval <= 0 {
		interval = Frame30Hz
	}
	if maxCatchUp < 1 {
		maxCatchUp = 1
	}
	if maxFrame <= 0 {
		maxFrame = 2 * interval
	}
	return &Loop{Interval: interval, MaxCatchUp: maxCatchUp, MaxFrame: maxFrame}
}

// Start anchors the loop at a wall-clock instant and resets counters.
func (l *Loop) Start(at time.Time) {
	l.last = at
	l.tick = 0
	l.dropped = 0
	l.rollbacks = 0
	l.drift = 0
	l.samples = nil
}

// Step reports the logical world tick, which never moves backwards.
func (l *Loop) Step() uint64 { return l.tick }

func (l *Loop) Dropped() uint64   { return l.dropped }
func (l *Loop) Rollbacks() uint64 { return l.rollbacks }

// CatchUp answers how many fixed steps an elapsed duration is worth, without
// mutating the loop and without exceeding MaxCatchUp.
func (l *Loop) CatchUp(elapsed time.Duration) int {
	if elapsed <= 0 {
		return 0
	}
	n := int(elapsed / l.Interval)
	if n > l.MaxCatchUp {
		n = l.MaxCatchUp
	}
	return n
}

// Advance moves the loop up to the given wall-clock instant. It returns the
// number of steps the caller should simulate and the number of steps that were
// discarded because the process fell too far behind.
//
// After a normal advance, last is the start of the next unexecuted step and
// Drift holds the sub-step remainder. A large gap still consumes every due
// boundary, so only the sub-step remainder survives as drift.
func (l *Loop) Advance(now time.Time) (executed, dropped int) {
	if l.last.IsZero() {
		l.last = now
		return 0, 0
	}
	elapsed := now.Sub(l.last)
	if elapsed < 0 {
		// A backwards clock must not run the world in reverse. Re-anchor and
		// count it, so the caller can alert instead of silently losing time.
		l.rollbacks++
		l.last = now
		l.drift = 0
		return 0, 0
	}

	due := int(elapsed / l.Interval)
	executed = due
	if due > l.MaxCatchUp {
		executed = l.MaxCatchUp
		dropped = due - l.MaxCatchUp
		l.dropped += uint64(dropped)
	}

	// Advance the wall-clock anchor by every due step, including discarded
	// ones. That keeps the sub-step remainder stable instead of letting a
	// backlog permanently shift the loop's phase.
	l.last = l.last.Add(time.Duration(due) * l.Interval)
	l.tick += uint64(executed)
	l.drift = now.Sub(l.last)
	return executed, dropped
}

// Drift is the distance from the last completed step boundary to now. It is
// always in [0, Interval) after Advance, and it is what the next Advance will
// carry instead of sleeping a full interval and drifting further.
func (l *Loop) Drift(now time.Time) time.Duration {
	d := now.Sub(l.last)
	if d < 0 {
		return 0
	}
	return d
}

// RecordFrame feeds one measured step duration into the chapter 1 budget
// checker. Timing lives here; thresholds live in internal/budget.
func (l *Loop) RecordFrame(d time.Duration) {
	l.samples = append(l.samples, budget.Sample{
		Kind: budget.Frame,
		MS:   d.Milliseconds(),
	})
}

// FrameViolations reports steps that exceeded MaxFrame using the same
// budget.Check contract as the rest of the book.
func (l *Loop) FrameViolations() []budget.Violation {
	limits := map[budget.Kind]budget.Budget{
		budget.Frame: {
			Kind: budget.Frame,
			P99:  l.Interval.Milliseconds(),
			Max:  l.MaxFrame.Milliseconds(),
		},
	}
	return budget.Check(limits, l.samples)
}

几个关键点:

  • Advance 返回 executed 和 dropped,调用方必须同时处理两者。只执行不记录丢弃,等于把一次故障藏起来。
  • 锚点按“所有到期边界”推进,包括被丢弃的帧。这样余量始终小于一个步长,不会因为一次大停顿永久错相。
  • CatchUp 是纯查询,不修改状态,方便在告警和容量规划里复用。
  • Drift 在回拨时返回 0,避免负值污染上层计算。

四个必须测的场景

tick 循环的测试不能只测“正常走一帧”。至少覆盖正常推进、单次大延迟补帧、超过上限丢弃、时钟回拨。

// file: internal/tick/loop_test.go
// tier: L1
// verify: go test ./internal/tick/
package tick

import (
	"testing"
	"time"
)

func TestAdvanceRunsFixedSteps(t *testing.T) {
	base := time.Unix(1_700_000_000, 0)
	l := New(Frame30Hz, 8, MaxFrame33)
	l.Start(base)

	executed, dropped := l.Advance(base.Add(100 * time.Millisecond))
	if executed != 3 || dropped != 0 {
		t.Fatalf("executed=%d dropped=%d, want 3/0", executed, dropped)
	}
	if l.Step() != 3 {
		t.Fatalf("tick=%d, want 3", l.Step())
	}
	if got := l.Drift(base.Add(100 * time.Millisecond)); got != 1*time.Millisecond {
		t.Fatalf("drift=%v, want 1ms", got)
	}
}

func TestSingleLargeDelayCatchesUp(t *testing.T) {
	base := time.Unix(1_700_000_000, 0)
	l := New(Frame30Hz, 10, MaxFrame33)
	l.Start(base)

	// A 300ms stall is nine steps: below the cap, so all of them run.
	executed, dropped := l.Advance(base.Add(300 * time.Millisecond))
	if executed != 9 || dropped != 0 {
		t.Fatalf("executed=%d dropped=%d, want 9/0", executed, dropped)
	}
	if l.Step() != 9 || l.Dropped() != 0 {
		t.Fatalf("tick=%d dropped=%d", l.Step(), l.Dropped())
	}
}

func TestAdvanceOverCapDropsSteps(t *testing.T) {
	base := time.Unix(1_700_000_000, 0)
	l := New(Frame30Hz, 5, MaxFrame33)
	l.Start(base)

	// 500ms is 15 steps at 33ms. The cap allows 5 and discards 10.
	executed, dropped := l.Advance(base.Add(500 * time.Millisecond))
	if executed != 5 || dropped != 10 {
		t.Fatalf("executed=%d dropped=%d, want 5/10", executed, dropped)
	}
	if l.Step() != 5 || l.Dropped() != 10 {
		t.Fatalf("tick=%d dropped=%d", l.Step(), l.Dropped())
	}
	// All 15 due boundaries are consumed, leaving the 5ms remainder.
	if got := l.Drift(base.Add(500 * time.Millisecond)); got != 5*time.Millisecond {
		t.Fatalf("drift=%v, want 5ms", got)
	}
}

func TestClockRollbackDoesNotRewind(t *testing.T) {
	base := time.Unix(1_700_000_000, 0)
	l := New(Frame30Hz, 8, MaxFrame33)
	l.Start(base.Add(200 * time.Millisecond))
	l.Advance(base.Add(266 * time.Millisecond)) // 2 steps

	before := l.Step()
	executed, dropped := l.Advance(base.Add(100 * time.Millisecond))
	if executed != 0 || dropped != 0 {
		t.Fatalf("executed=%d dropped=%d, want 0/0", executed, dropped)
	}
	if l.Step() != before {
		t.Fatalf("tick moved backwards: %d -> %d", before, l.Step())
	}
	if l.Rollbacks() != 1 {
		t.Fatalf("rollbacks=%d, want 1", l.Rollbacks())
	}
	// The loop re-anchors at the new time and can advance normally again.
	if executed, _ := l.Advance(base.Add(133 * time.Millisecond)); executed != 1 {
		t.Fatalf("after rollback executed=%d, want 1", executed)
	}
}

完整测试文件里还有两个用例:TestDriftIsCarriedAcrossAdvance 验证 17ms 余量会在下一次凑成一帧,TestFrameViolationsUseBudgetPackage 验证 64ms 的帧会被 internal/budget 判为超过 50ms 上限。它们不改变循环逻辑,但覆盖了最容易回归的两条缝。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/tick
$ go vet ./internal/tick
$ go test ./internal/tick/ -v -count=1
=== RUN   TestAdvanceRunsFixedSteps
--- PASS: TestAdvanceRunsFixedSteps (0.00s)
=== RUN   TestSingleLargeDelayCatchesUp
--- PASS: TestSingleLargeDelayCatchesUp (0.00s)
=== RUN   TestAdvanceOverCapDropsSteps
--- PASS: TestAdvanceOverCapDropsSteps (0.00s)
=== RUN   TestClockRollbackDoesNotRewind
--- PASS: TestClockRollbackDoesNotRewind (0.00s)
=== RUN   TestDriftIsCarriedAcrossAdvance
--- PASS: TestDriftIsCarriedAcrossAdvance (0.00s)
=== RUN   TestFrameViolationsUseBudgetPackage
--- PASS: TestFrameViolationsUseBudgetPackage (0.00s)
PASS
ok  	gsarchlab/internal/tick	0.267s

gofmt -l 与 go vet 均无输出,6 个测试全部通过。

世界时钟的边界

固定步长解决了“多久推进一次”,但世界时间还有四个边界要处理。

第一,NTP 校正。墙上时间可能一次跳几百毫秒。单调时钟能保护间隔测量,但跨进程的世界时间必须持久化“锚点 + tick”,不能重启后重新取 time.Now() 当世界起点。

第二,夏令时与闰秒。tick 不应感知夏令时,它只影响每日重置和活动开始这类日历计算,应使用时区数据库而不是固定偏移;Unix 时间也不表示闰秒,需要对齐外部日历时按 UTC 日期处理。

第三,客户端时间不可信。客户端可以改系统时间,不能让它决定离线收益、建筑完成或活动剩余时间。所有时间判断以服务端世界时钟为准。

常见错误与边界

第一,用 sleep(interval) 做 tick。处理时间会叠加到周期上,长期必然漂移。

第二,无上限补帧。停顿越久补得越多,补帧本身又造成新的停顿。

第三,补帧不记录丢弃,或时钟回拨时直接算负数。前者让玩家看到跳变却没有指标,后者轻则锚点错乱、重则世界反向推进。

第四,把墙上时间当世界时间,只测平均帧耗时,或把 tick 频率当成端到端延迟。NTP 跳变、重启、跨机器都会让“世界”不一致;64ms 的帧会被平均掉,但它已经让 30Hz 掉到 15Hz;tick 也只是玩家感知链路中的一段。

小结

固定步长 tick 把时间切成可重放的格子,补帧上限防止停顿被放大,漂移余量防止长期错相,回拨保护防止世界倒流。internal/tick 把这四件事写成了一个可测试的 Loop,并复用第 1 章的 internal/budget 判断帧超限。

下一节换一个时间尺度:SLG 玩家离线几小时甚至几天后回来,服务端如何补算收益、为什么不能无限补算,以及补算结果怎样和在线结算对账。

阅读导航:上一节:4.1 内存态、持久态与快照 · 下一节:4.3 离线推进与时间旅行:SLG 的补算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查