一次 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 的补算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。