零分配的秘密:Context pool 與 smartParams
零分配的秘密:Context pool 與 smartParams
gortex 的路由 hot path 是 0 allocs/op,秘密是三件事:sync.Pool 重用 Context、[4]string 的 smartParams、內嵌的 responseWriter。這篇逐一拆,並教你怎麼量。
前置:A5 記憶體與併發原語。
sync.Pool 重用 Context
每條請求都要一個 Context,每次 new 一個就是每秒幾萬次 heap 配置加 GC 壓力。gortex 改成從 sync.Pool 借:
var contextPool = sync.Pool{
New: func() any { return &DefaultContext{store: make(Map)} }, // 預配 store map
}
func AcquireContext(r *http.Request, w http.ResponseWriter) Context {
c := contextPool.Get().(*DefaultContext)
c.Reset(r, w) // 綁新的 request/response
return c
}
關鍵在歸還。ReleaseContext 把欄位清空再放回,但保留已配置的記憶體:store 用 for k := range m { delete(m, k) } 逐一刪 key、不重 make(留住底層 bucket);params 呼叫 reset() 而不是設 nil(留住 backing array)。下一條請求拿到的就是一個俐落、但記憶體已就緒的 Context,這就是 A5 講的 sync.Pool 重用落到實戰。
smartParams:[4]string 的 SBO
路徑參數(:id 那些)若每次都開一個 map,又是一次配置。gortex 用 small-buffer optimization:
type smartParams struct {
count int
keys [4]string // 常見情況(≤4 個參數)全放固定陣列,不進 heap
vals [4]string
overflow []pathParam // 超過 4 個才退化到 slice
}
[4]string 是值陣列,內嵌在 Context 裡,不額外配置。選 4 是因為多數 REST 路徑參數不超過三四層。超出才用 overflow slice。
這裡特意用 insertion-ordered slice 而不是 map:B3 比對時 Static > Param > Wildcard 會回溯,失敗的 param 分支要能精準退掉它加過的參數。truncate(n) 就靠順序把 index ≥ n 的丟掉:
func (sp *smartParams) truncate(n int) {
sp.count = n
switch {
case n <= 4:
sp.overflow = sp.overflow[:0]
case n-4 < len(sp.overflow):
sp.overflow = sp.overflow[:n-4]
}
}
內嵌 responseWriter
要記錄回應狀態碼與大小(給 logger、給「是否已寫入」的判斷),得包一層 http.ResponseWriter。gortex 的包法用到 A3 的 embedding:
type responseWriter struct {
http.ResponseWriter // 內嵌:原本的方法直接繼承,不花成本
status int
size int64
written bool
}
內嵌 http.ResponseWriter 後,這個 wrapper 自動滿足介面,只要覆寫 WriteHeader/Write 去記 status 與 size。更關鍵的是它在 DefaultContext 裡是 value 欄位(rw responseWriter),跟 pooled context 同生命週期,不是每條請求 new 一個 wrapper,而是 Reset 時重新指向新的 http.ResponseWriter。狀態追蹤因此零額外配置。
怎麼量 allocs/op
零分配不能用講的,要量。go test -benchmem 會吐 allocs/op:
func BenchmarkRoute(b *testing.B) {
r := setupRouter()
req := httptest.NewRequest("GET", "/users/42", nil)
w := httptest.NewRecorder()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
r.ServeHTTP(w, req)
}
}
// go test -bench=BenchmarkRoute -benchmem
// → ... ns/op 0 B/op 0 allocs/op
依 gortex README 的 benchmark,路由 hot path 約 65 ns/op、0 allocs/op(M3 Pro)。只要不小心讓一個值逃逸(塞進 interface、寫一次 map),allocs/op 立刻不是 0,所以是量出來的,不是猜的。
重點整理
- Context 從
sync.Pool借用歸還;清理時保留 map 與 params 的 backing,下一條請求 0 alloc。 - smartParams 用
[4]stringSBO,≤4 個參數不進 heap;overflow 用 insertion-ordered slice,配合 B3 回溯精準truncate。 responseWriter內嵌http.ResponseWriter(embedding 省去重寫方法)且是 pooled context 的 value 欄位,狀態追蹤零額外配置。- 用
go test -benchmem看allocs/op;gortex 路由約 65 ns/op、0 allocs/op(README,M3 Pro)。 - 零分配就是 A5(逃逸分析 / pool / 同步原語)落到框架 hot path 的成果。
原始碼:yshengliao/gortex。
大綱 Sheng,內文 Claude 協助 · 環境 Go 1.24(gortex go.mod)· 本系列為事後回填整理 · 列入 20260613 blog 翻新計劃,新漆未乾。