9.3 世界时钟与 Tick 预算
9.2 的时间轮解决了「什么时候触发回调」,但还有一个更根本的问题没回答:「现在」是什么时候? 如果每一帧都直接读 time.Now() 算逻辑,那么同一段逻辑在两台机器、两次运行里会得到不同结果——因为墙钟会抖动、会被 NTP 校时、会被 GC 暂停打断。对需要「服务器回滚与和解」(第 13 章)的 MMO 来说,这是致命的:回滚的前提是「第 N 帧的世界状态是确定的」,而依赖墙钟的逐帧 time.Now() 直接破坏了这个前提。
世界时钟就是把这个问题挡在逻辑之外:逻辑只认「第几帧」,不认「真实时刻」。 每一帧固定推进 step 的逻辑时间,真实墙钟走得快一点、慢一点,逻辑一概不管,只在帧与帧之间把墙钟的差值折算成「该跑几步」。这一节从零实现这个折算器,并处理它最难的部分——当一帧算不完时该怎么办。
固定步长:把墙钟抖动挡在逻辑外
世界时钟的核心是一个累加器:每帧把真实流逝的墙钟时间累加进 acc,然后用 acc / step 得出「这一帧该执行几步逻辑」,余数留到下一帧。逻辑时间因此永远等于「执行过的步数 × 步长」,与墙钟抖动彻底解耦。
墙钟: |---45ms---|--10ms--|----60ms----|--50ms--|
累加器: 45 55 115 165
步长 50ms,每帧步数:
0 1 2 1
逻辑时间:0 50 150 200 ← 只由「跑了几步」决定
这就是「固定步长」的全部:墙钟负责提供「流逝了多少」,逻辑只负责回答「跑了几步」。 只要逻辑本身是确定性的(不读墙钟、不读随机数、遍历顺序固定),同一个初始状态 + 同一串输入,在任意机器上都会得到同一串帧结果。这是服务器回滚、录像回放、压测复现的地基。
典型的主循环长这样——注意墙钟只出现在 Advance 的入参里,逻辑步 world.Step() 完全不碰时间:
// snippet: 典型逻辑帧主循环(示意,非完整文件)
ticker := time.NewTicker(framePeriod) // 例如 20ms 一帧
for {
select {
case <-ticker.C:
n := clock.Advance(framePeriod) // 墙钟只在这里出现
for i := 0; i < n; i++ {
world.Step() // 纯逻辑:不读墙钟、不读随机,遍历顺序固定
}
flushOutbound() // 帧末统一发包,保证同一帧的推送顺序一致
case <-stop:
return
}
}
只要 world.Step() 是确定性的,把同一串输入重放一遍就能得到逐帧一致的世界状态——这就是第 13 章「服务器回滚与和解」需要的那个可重放地基。
但固定步长会引入一个新问题:如果某一帧因为 GC 暂停或一次慢查询花了 300ms,acc 就会攒下 6 步的积压。下一帧是「一次补跑 6 步」还是「只跑 1 步、把其余丢掉」?这个选择就是下面要讲的超支策略。
从零实现 WorldClock
下面这段是 L1,go test 可以直接跑。它实现了固定步长折算、单帧步数上限,以及三种超支策略(CatchUp / Drop / SlowDown)和时间缩放。
// file: internal/timing/worldclock.go
// tier: L1
// verify: go test ./internal/timing/
package timing
import "time"
// Policy 决定「墙钟落后于逻辑时间」时怎么处理。
type Policy int
const (
// CatchUp 追帧:一帧内补齐所有落后步数(上限 maxSteps),逻辑可重放时首选。
CatchUp Policy = iota
// Drop 丢弃:一帧最多执行 maxSteps 步,其余积压直接丢掉,逻辑时间会跳跃。
Drop
// SlowDown 降频:不补步,但把有效步长向实际帧时间靠拢,让逻辑频率跟随真实帧率。
SlowDown
)
// WorldClock 是服务器逻辑帧时钟:把不稳定的墙钟时间折算成固定步长的逻辑时间。
// 调用方每帧调用一次 Advance,按返回的步数执行逻辑;逻辑时间只由「执行了多少步」
// 决定,因此与墙钟抖动解耦,可重放、可确定性回放。
type WorldClock struct {
policy Policy
step time.Duration
baseStep time.Duration
maxSteps int
scale float64
acc time.Duration // 尚未折算成逻辑步的墙钟余量
ticks uint64 // 已执行的逻辑步总数
dropped time.Duration // 因超预算被丢弃的逻辑时间
}
// NewWorldClock 建一个逻辑帧时钟。step 是逻辑步长,policy 决定积压处理策略。
func NewWorldClock(step time.Duration, policy Policy) *WorldClock {
return &WorldClock{policy: policy, step: step, baseStep: step, maxSteps: 8, scale: 1}
}
// Step 返回当前有效步长(SlowDown 下会动态变化)。
func (c *WorldClock) Step() time.Duration { return c.step }
// Ticks 返回已执行的逻辑步数。
func (c *WorldClock) Ticks() uint64 { return c.ticks }
// LogicTime 返回逻辑时间 = 步数 × 步长。
func (c *WorldClock) LogicTime() time.Duration { return time.Duration(c.ticks) * c.step }
// Dropped 返回累计被丢弃的逻辑时间。
func (c *WorldClock) Dropped() time.Duration { return c.dropped }
// SetMaxSteps 设置单帧逻辑步数上限。
func (c *WorldClock) SetMaxSteps(n int) {
if n > 0 {
c.maxSteps = n
}
}
// SetScale 设置时间缩放:>1 加速,<1 减速。s <= 0 时忽略。
func (c *WorldClock) SetScale(s float64) {
if s > 0 {
c.scale = s
}
}
// Advance 吸收一段真实墙钟时间,返回本帧应执行的逻辑步数。
//
// 超预算处理:当一帧折算出超过 maxSteps 步时,多余部分按策略被丢弃(Drop)
// 或截断(CatchUp),并累加到 dropped。这是防「死亡螺旋」的关键——若每步逻辑
// 都比 step 还慢,无上限追帧会让积压雪球越滚越大。
func (c *WorldClock) Advance(wall time.Duration) int {
if wall < 0 {
wall = 0
}
scaled := time.Duration(float64(wall) * c.scale)
if c.policy == SlowDown {
// 有效步长向实际帧时间靠拢,上限 4× 基准步长,避免一次卡顿把步长拉爆。
target := c.step
if scaled > target {
target = scaled
}
if limit := 4 * c.baseStep; target > limit {
target = limit
}
c.step = target
}
c.acc += scaled
steps := int(c.acc / c.step)
if steps > c.maxSteps {
over := time.Duration(steps-c.maxSteps) * c.step
c.dropped += over
c.acc -= over
steps = c.maxSteps
}
c.acc -= time.Duration(steps) * c.step
c.ticks += uint64(steps)
return steps
}
Advance 只做三件事:折算、限步、记账。注意「限步」那一段:当折算出的步数超过 maxSteps 时,它把超出的部分直接从 acc 里扣掉、并累加到 dropped。这一扣很关键——它让累加器在超支后立刻回到有界状态,而不是把积压留给下一帧继续滚。
超支处理:追帧、丢弃与死亡螺旋
三种策略的差别,用「每步逻辑实际耗时比步长还慢」这个最坏情况最能体现。假设 step = 20ms,但每一步逻辑要花 work = 25ms——这在线上并不罕见(一次慢的持久化、一次卡顿的广播)。下面这段是确定性测试,模拟一个真实帧循环:
// snippet: internal/timing/worldclock_test.go(节选)
// simulate 模拟真实帧循环:每帧吸收墙钟时间、执行 n 步逻辑,逻辑本身又要花 work
// 的墙钟时间,因此下一帧的墙钟间隔至少是上帧逻辑耗时。用来暴露「死亡螺旋」。
func simulate(c *WorldClock, frames int, step, work time.Duration) (steps int, dropped time.Duration) {
wall := step
for i := 0; i < frames; i++ {
n := c.Advance(wall)
steps += n
wall = time.Duration(n) * work
if wall < step {
wall = step
}
}
return steps, c.Dropped()
}
func TestDeathSpiralAndDropBreaksIt(t *testing.T) {
const (
step = 20 * time.Millisecond
work = 25 * time.Millisecond // 每步逻辑比步长还慢
)
catch := NewWorldClock(step, CatchUp)
catch.SetMaxSteps(1000)
catchSteps, _ := simulate(catch, 100, step, work)
if catchSteps <= 100 {
t.Fatalf("catch-up ran %d steps in 100 frames, expected the backlog to snowball", catchSteps)
}
drop := NewWorldClock(step, Drop)
drop.SetMaxSteps(1)
dropSteps, dropDropped := simulate(drop, 100, step, work)
if dropSteps != 100 {
t.Fatalf("drop ran %d steps in 100 frames, want exactly 1/frame", dropSteps)
}
if dropDropped == 0 {
t.Fatal("drop policy never discarded backlog, death spiral not broken")
}
}
测试断言了两件相反的事。第一,CatchUp 在 100 帧里跑了远超 100 步——因为每帧补跑更多步,逻辑耗时又更长,下一帧积压更多,步数像滚雪球一样涨上去,这就是死亡螺旋:服务器越忙,越要补跑,于是越忙。第二,Drop 在 100 帧里恰好跑 100 步、且丢弃了大量积压——它主动放弃「追上真实时间」,把每帧钉死在 1 步,螺旋被掐断。
所以选型的判据是「逻辑能不能承受时间跳跃」:
| 策略 | 行为 | 适用场景 | 风险 |
|---|---|---|---|
CatchUp 追帧 | 一帧补齐落后步数(上限 maxSteps) | 逻辑可重放、帧率偶发抖动 | 上限设太大 → 死亡螺旋 |
Drop 丢弃 | 一帧最多 maxSteps 步,其余丢弃 | 实时性优先、能容忍逻辑时间跳跃 | 逻辑时间与真实时间漂移 |
SlowDown 降频 | 不补步,拉长有效步长 | 逻辑频率可跟随真实帧率 | 步长变化影响物理/手感 |
工程上的默认选择是 CatchUp + 一个保守的 maxSteps(比如 8):允许偶尔的补帧,但绝不允许无限补。maxSteps 就是给死亡螺旋设的那道闸——超过 8 步的积压宁可丢掉,也不要让服务器陷入「越补越慢」的正反馈。
时间缩放
SetScale 让逻辑时间可以相对墙钟加速或减速:scale = 2 表示墙钟走 50ms、逻辑走 100ms(双倍速),scale = 0.5 是慢动作。它不需要改动逻辑本身,只是在折算那一步乘一个系数——scaled := time.Duration(float64(wall) * c.scale)。
游戏里时间缩放的典型用途是「活动期间的限时玩法加速」「剧情演出的慢动作」「测试时快进一段挂机收益」。要注意它和超支策略的交互:scale = 2 时,同样的墙钟抖动会被放大成两倍的积压,因此 maxSteps 要相应放宽;而 SlowDown 策略下 scale 与动态步长会互相影响,两者一般不叠加使用。
本节小结
- 世界时钟让逻辑只认「第几帧」、不认「真实时刻」,是服务器回滚与录像回放的地基。
- 折算公式:
acc += wall * scale,steps = acc / step,余数留到下一帧。 - 超支三策略:
CatchUp追帧(默认,配保守maxSteps)、Drop丢弃、SlowDown降频。 maxSteps是防死亡螺旋的闸门:每步逻辑比步长还慢时,无上限追帧会让积压雪球式增长;Drop或小maxSteps才能掐断它。
阅读导航:上一节:9.2 时间轮从零实现 · 下一节:10.1 消息驱动的实体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。