零分配的秘密: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]string SBO,≤4 個參數不進 heap;overflow 用 insertion-ordered slice,配合 B3 回溯精準 truncate
  • responseWriter 內嵌 http.ResponseWriter(embedding 省去重寫方法)且是 pooled context 的 value 欄位,狀態追蹤零額外配置。
  • go test -benchmemallocs/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 翻新計劃,新漆未乾。