2.1 帧预算与性能剖析
第 1 章算清了「一台机器能扛多少人」,但那个数字是黑盒里称出来的。真正的服务端不是「每秒能处理多少请求」,而是「每帧里能不能把该做的事做完」。aegis 的场景节点跑 30Hz,意味着每 33.3ms 必须完整跑完一次 tick:把这一帧所有玩家的移动算完、把位置变化广播给视野内的邻居、结算这一帧的战斗、推进所有定时器。少做一样,玩家看到的就是卡顿、穿墙、技能不生效。
而「卡顿」几乎从不来自「总 CPU 不够」,而是来自某一帧里某一段太慢。所以调优的第一步不是加机器,是把这一帧 33ms 拆开,量出每一段到底花了多少。这一节先把帧预算写成一张能断言的表,再用 Go 的 runtime/pprof 和 net/http/pprof 真跑一次剖析,最后给出本机实测的相位占比和火焰图读法。
与站内既有文章的分工
本节与站内既有专题的分工如下,避免重复:
| 既有文章 | 主题 | 与本节的分工 |
|---|---|---|
/game-server-combat-telemetry-sampling-architecture/ | 战斗遥测采样 | 讲采样的架构与取舍,本节讲采样出的耗时如何落进帧预算并被剖析 |
/game-server-layered-architecture-for-live-games/ | 线上游戏分层架构 | 讲层与层的边界,本节只关心单帧内的相位耗时 |
/game-server-resource-quota-backpressure-architecture/ | 资源配额与背压 | 讲配额与背压的架构,本节提供其前置输入:每相位还剩多少预算 |
一帧 33ms 花在哪里
帧预算的核心是把「一帧」当成一个有上限的容器:所有相位分这个容器,分不完就得砍,分得太松就浪费。aegis 给《晨曦大陆》战斗地图定的预算表是这样的:AOI 广播占最大头(12ms),因为它不是 O(实体数) 而是 O(实体数 × 邻居数);移动 6ms(每个实体一帧一次);战斗 8ms(结算集中、突发性强);定时器 3ms(量大但每个极轻)。剩下的 4ms 是刻意留白的余量,用来吸收抖动和短时尖峰。
下面这段是 L1,go test 可以直接跑。internal/profile/budget.go 把这张表编码成一个结构体,并配一个把每相位耗时累加起来、算出「用掉了多少预算」的记账器:
// file: internal/profile/budget.go
// tier: L1
// verify: go test ./internal/profile/
// Package profile is chapter two's toolbox: a frame-budget accountant, a GC
// sampler, and a bounded-queue/hotspot-isolation layer. Everything here is
// standard library only.
package profile
import (
"sort"
"time"
)
// TargetTickHz is《晨曦大陆》's simulation rate. A 30 Hz server gets a fresh
// frame every 33.3ms; that frame is the budget everything else is measured
// against.
const TargetTickHz = 30
// FrameBudget is one tick's wall-clock allowance, split by phase. The numbers
// are a target for a scene node running《晨曦大陆》's combat map: broadcast
// (AOI) dominates because every entity's visibility delta fans out to
// neighbours, movement is cheap, combat is bursty, timers are steady.
type FrameBudget struct {
Tick time.Duration
Movement time.Duration
AOI time.Duration
Combat time.Duration
Timers time.Duration
}
// DefaultFrameBudget is the section 2.1 budget table.
func DefaultFrameBudget() FrameBudget {
return FrameBudget{
Tick: time.Second / TargetTickHz, // 33.33ms
Movement: 6 * time.Millisecond,
AOI: 12 * time.Millisecond,
Combat: 8 * time.Millisecond,
Timers: 3 * time.Millisecond,
}
}
// Assigned is the sum of the phase budgets; Headroom is what is left of the
// tick after the phases. Headroom absorbs jitter and short spikes — a budget
// with zero headroom is a budget that is already overrun.
func (b FrameBudget) Assigned() time.Duration {
return b.Movement + b.AOI + b.Combat + b.Timers
}
// Headroom returns the unassigned slice of the tick.
func (b FrameBudget) Headroom() time.Duration { return b.Tick - b.Assigned() }
// Phase names a stage of one tick.
type Phase string
const (
PhaseMovement Phase = "movement"
PhaseAOI Phase = "aoi-broadcast"
PhaseCombat Phase = "combat"
PhaseTimers Phase = "timers"
)
// BudgetOf returns the wall-clock allowance for a phase.
func (b FrameBudget) BudgetOf(p Phase) time.Duration {
switch p {
case PhaseMovement:
return b.Movement
case PhaseAOI:
return b.AOI
case PhaseCombat:
return b.Combat
case PhaseTimers:
return b.Timers
default:
return 0
}
}
// FrameRecorder accumulates per-phase durations across ticks and reports how
// much of each phase's budget was actually used.
type FrameRecorder struct {
budget FrameBudget
total map[Phase]time.Duration
ticks int
}
// NewFrameRecorder returns a recorder bound to a budget.
func NewFrameRecorder(b FrameBudget) *FrameRecorder {
return &FrameRecorder{budget: b, total: make(map[Phase]time.Duration)}
}
// Observe records one phase's duration for one tick.
func (r *FrameRecorder) Observe(p Phase, d time.Duration) { r.total[p] += d }
// EndTick closes the tick counter.
func (r *FrameRecorder) EndTick() { r.ticks++ }
// TickCount returns how many ticks were recorded.
func (r *FrameRecorder) TickCount() int { return r.ticks }
// PhaseReport is one row of the frame-budget breakdown.
type PhaseReport struct {
Phase Phase
Avg time.Duration // average per tick
Budget time.Duration // allowance per tick
PctOfTick float64 // Avg / Tick
Used float64 // Avg / Budget
}
// Report computes the per-phase average and its share of the tick and of its
// own budget, sorted by descending average.
func (r *FrameRecorder) Report() []PhaseReport {
if r.ticks == 0 {
return nil
}
out := make([]PhaseReport, 0, len(r.total))
for p, d := range r.total {
avg := d / time.Duration(r.ticks)
bud := r.budget.BudgetOf(p)
used := 0.0
if bud > 0 {
used = float64(avg) / float64(bud)
}
out = append(out, PhaseReport{
Phase: p,
Avg: avg,
Budget: bud,
PctOfTick: float64(avg) / float64(r.budget.Tick),
Used: used,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Avg > out[j].Avg })
return out
}
// Overruns returns the phases whose average exceeded their budget, worst first.
func (r *FrameRecorder) Overruns() []Phase {
var out []Phase
for _, row := range r.Report() {
if row.Used > 1.0 {
out = append(out, row.Phase)
}
}
return out
}
配一个测试把「预算表自洽」「超预算能被抓出来」变成断言。internal/profile/frame_budget_test.go(同为 L1,go test ./internal/profile/ -run TestFrame -v)先断言相位预算之和不超过一帧、余量为正、AOI 是最大相位,再喂进一个故意超预算的 AOI(15ms > 12ms),断言 Overruns() 恰好抓到它。预算表一旦变成断言,将来谁把 AOI 预算改大都会先撞上测试。
真跑剖析:pprof 与火焰图
光有记账器只能看到「哪一段慢」,看不到「慢在哪一行」。这就要请出 runtime/pprof。生产环境里最常用的是 net/http/pprof:场景节点只要把它的 handler 挂到内部端口,运维就能在不停服的情况下抓 goroutine、heap、CPU 剖析。下面这段是 L2,真起一个 HTTP server 并真拉取剖析:
// file: internal/profile/cpuprofile_test.go
// tier: L2
// verify: go test ./internal/profile/ -run TestHTTPPprof -v
package profile
import (
"io"
"net/http"
"net/http/pprof"
"net/http/httptest"
"strings"
"testing"
)
// TestHTTPPprof starts a real HTTP server with the net/http/pprof handlers
// mounted, keeps a worker busy, then pulls the index, the goroutine profile and
// a one-second CPU profile over HTTP — the same endpoints an ops engineer hits
// on a live scene node.
func TestHTTPPprof(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
mux.Handle("/debug/pprof/heap", pprof.Handler("heap"))
srv := httptest.NewServer(mux)
defer srv.Close()
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
return
default:
aoiBroadcast(2048)
}
}
}()
// 1) The index must list the built-in profiles.
body := get(t, srv.URL+"/debug/pprof/")
for _, want := range []string{"goroutine", "heap", "profile", "threadcreate"} {
if !strings.Contains(body, want) {
t.Errorf("/debug/pprof/ index missing %q", want)
}
}
// 2) The goroutine profile must capture our running worker.
g := get(t, srv.URL+"/debug/pprof/goroutine?debug=1")
if !strings.Contains(g, "aoiBroadcast") {
t.Errorf("goroutine profile did not capture aoiBroadcast")
}
// 3) A one-second CPU profile must be a non-empty gzip pprof blob.
prof := get(t, srv.URL+"/debug/pprof/profile?seconds=1")
close(stop)
if len(prof) < 100 {
t.Fatalf("cpu profile too small: %d bytes", len(prof))
}
if !(prof[0] == 0x1f && prof[1] == 0x8b) {
t.Fatalf("cpu profile is not a gzip blob: % x", prof[:2])
}
t.Logf("pprof ok: goroutine profile %d bytes, cpu profile %d bytes (gzip)", len(g), len(prof))
}
func get(t *testing.T, url string) string {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read %s: %v", url, err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s: status %d", url, resp.StatusCode)
}
return string(b)
}
本机实跑(go test ./internal/profile/ -run TestHTTPPprof -v):
=== RUN TestHTTPPprof
cpuprofile_test.go:63: pprof ok: goroutine profile 5282 bytes, cpu profile 2715 bytes (gzip)
--- PASS: TestHTTPPprof (1.12s)
PASS
ok aegis/internal/profile 1.465s
三个断言各自守住一件事:索引里有 goroutine/heap/profile 说明 handler 挂对了;goroutine 剖析里出现 aoiBroadcast 说明我们的业务函数真的被抓到了,不是只抓到一堆 runtime 栈;CPU 剖析是 gzip 魔数开头的非空 blob,说明它是一份能被 go tool pprof 打开的合法剖析。这就是不停服剖析的全部秘密:把 handler 挂上去,运维随时拉一份。
记账器 + pprof 合起来,就能回答「一帧里各段占多少」。用 go test -bench=BenchmarkTick -benchmem -cpuprofile=cpu.prof ./internal/profile/ 在 4000 实体、1000 战斗、8000 定时器的战斗地图上跑基准:
goos: darwin
goarch: arm64
pkg: aegis/internal/profile
cpu: Apple M1 Pro
BenchmarkTick-10 300 1083475 ns/op 2977816 B/op 44679 allocs/op
把相位耗时单独打出来(go test ./internal/profile/ -run TestFrameBudgetReport -v):
frame_budget_test.go:69: phase=aoi-broadcast avg=960.491µs budget=12ms used= 8%
frame_budget_test.go:69: phase=combat avg=101.008µs budget=8ms used= 1%
frame_budget_test.go:69: phase=movement avg=81.749µs budget=6ms used= 1%
frame_budget_test.go:69: phase=timers avg=10.351µs budget=3ms used= 0%
数字很清楚:AOI 广播是绝对大头(约 960µs,占该相位的 8%),战斗和移动各约 100µs 和 80µs,定时器几乎可以忽略。整帧约 1.15ms,只吃掉 33ms 预算的 3.5%。这本身就是一个有用的结论:在这台机器、这个规模下,帧预算远远没有被 CPU 打满——瓶颈不在这里。
真正要看的在 CPU 剖析里。go tool pprof -top cpu.prof:
flat flat% sum% cum cum%
1.48s 27.87% 27.87% 1.48s 27.87% runtime.madvise
0.89s 16.76% 44.63% 0.89s 16.76% runtime.kevent
0.79s 14.88% 59.51% 0.79s 14.88% runtime.pthread_cond_wait
0.52s 9.79% 69.30% 0.52s 9.79% runtime.pthread_cond_signal
0.34s 6.40% 75.71% 0.34s 6.40% runtime.usleep
0.31s 5.84% 81.54% 0.31s 5.84% runtime.pthread_kill
0.11s 2.07% 83.62% 0.32s 6.03% runtime.mallocgcSmallNoscan
0.10s 1.88% 85.50% 0.48s 9.04% runtime.mallocgc
0.07s 1.32% 90.02% 0.54s 10.17% aegis/internal/profile.aoiBroadcast
火焰图怎么读:flat 是函数自身占用的时间,cum 是它及其被调用者的累计。第一行 runtime.madvise(27.9%)不是我们的代码,是运行时在把内存还给操作系统;紧随其后的 kevent、pthread_cond_wait、pthread_cond_signal 是 GC 的并发标记与唤醒。真正属于业务逻辑的 aoiBroadcast 只占 10.2%(cum)。换句话说,这帧的时间大头不是「算」出来的,而是「分配与回收」出来的——每个实体每帧都新造一批报文,制造了海量短命对象,把 GC 拖成了主角。
这条结论直接决定了后面两节的方向:2.2 要把 Go 的 GC 和 JVM 的 GC 摆在一起量,看清「分配速率」这个隐藏成本;2.3 则用热区隔离把这种分配压力关进独立的队列里。剖析的价值不是告诉你哪个函数慢,而是告诉你「慢」这件事根本不在你以为的地方。
本节小结 / 实测结论
- 帧预算是一张有上限的容器表:30Hz 下每帧 33.3ms,AOI 广播 12ms、战斗 8ms、移动 6ms、定时器 3ms,余下 4ms 刻意留白。相位预算之和一旦超过一帧,就该在评审里被拦下——本节把它做成了断言。
- 记账器回答「哪一段慢」,pprof 回答「慢在哪一行」:
net/http/pprof挂上内部端口后,goroutine / heap / CPU 剖析可以不停服拉取;L2 测试断言了三个关键事实(handler 挂对、业务函数被抓到、剖析是合法 gzip blob)。 - 本机实测:帧预算没被打满,但 GC 成了主角。4000 实体的战斗地图整帧约 1.15ms(预算的 3.5%),AOI 广播占相位 8%;而 CPU 剖析显示近三成时间花在
runtime.madvise、GC 唤醒与mallocgc上,业务函数aoiBroadcast仅占 10.2%。优化的靶子不是算法,是分配。
以上数字均为本机(Apple M1 Pro,go1.26.0)单次实跑,属同机相对量级,绝对数值不可移植。
阅读导航:上一节:目录 · 下一节:2.2 双栈对照:Go GC vs JVM GC 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。