Gin 框架内部原理 · 第三篇

Gin Context 对象池:sync.Pool 怎么把每次请求的 Context 复用起来的

每个 HTTP 请求都会拿到一个 *gin.Context,但 Gin 并不会为每个请求真的 new 一个——它维护一个 sync.Pool,请求进来时从池子里捞一个旧的出来擦干净重新用,请求结束再扔回去。这一篇不满足于"知道 Gin 用了对象池",而是真实观测了同一块内存被连续请求复用、被 GC 收走、以及在真正并发下也不会串数据这三件事,并且用一组对照基准测出了不复用到底多花多少内存。

延续前两篇的方法论:本机真实拉取 gin-gonic/gin v1.12.0,全部实验都是本机真实执行、真实测量的 Go 程序。

4 个真实 Go 程序
本机真实 go run(其中一个用 -race 跑),外加一组真实 go test -bench
gin-gonic/gin v1.12.0
跟前两篇同一个版本,源码引用逐行核对

真实源码:每个请求的入口就是 pool.Get() + reset() + pool.Put()

gin-gonic/[email protected](本机 go.mod 解析版本) · gin.go L227, L662 engine.pool.New = func() any { return engine.allocateContext(engine.maxParams) } func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { c := engine.pool.Get().(*Context) c.writermem.reset(w) c.Request = req c.reset() engine.handleHTTPRequest(c) engine.pool.Put(c) }

Engine 结构体里有一个 pool sync.Pool 字段,New 函数在池子空的时候负责造一个新的 *Context(顺便预分配好 Params/skippedNodes 这些内部切片的容量)。每个请求进来,ServeHTTP 做的事情非常直白:从池子里 Get() 一个,换上这次请求的 Request/Writer,调用 reset() 把上一次请求留下的字段(handlersindexKeys……上一篇提到过)全部清空,处理完请求之后再 Put() 回去。

真实实验:连续请求真的复用同一块内存

真实实测
for i := 0; i < 5; i++ {
    r.ServeHTTP(w, req) // 每次都在 handler 里打印 fmt.Sprintf("%p", c)
}
request 1: *Context = 0x4d88c732e300 request 2: *Context = 0x4d88c732e300 (same as request 1) request 3: *Context = 0x4d88c732e300 (same as request 1) request 4: *Context = 0x4d88c732e300 (same as request 1) request 5: *Context = 0x4d88c732e300 (same as request 1)

5 次连续请求,打印出来的指针地址完全一样——不是"逻辑上等价的新对象",是真的同一块内存被反复拿出来又放回去。三次独立运行(见下面交互演示)地址虽然每次不同(ASLR),但每一轮内部 5 次请求的地址都严格相等。

真实实验:强制 GC 之后,池子可能真的换了一个新对象

sync.Pool 的文档写得很清楚:池子里的对象可能在任意一次 GC 时被清空,不是永久缓存。

真实实测
runtime.GC()
r.ServeHTTP(w, req)
after runtime.GC(), next request: *Context = 0x4d88c732e000 (跟之前 5 次请求用的 0x4d88c732e300 不是同一个地址)

手动触发一次 runtime.GC() 之后,下一次请求拿到的地址真的变了。这也解释了为什么 sync.Pool 适合"减少分配频率"而不能当成"绝对不会分配"的保证——GC 一来,池子可能被清空,下一个请求还是得走一次真正的分配。

真实实验:复用同一块内存,但 reset() 真的把上一次的数据擦干净了

真实实测
r.GET("/set", func(c *gin.Context) { c.Set("secret", "request-1-value") })
r.GET("/check", func(c *gin.Context) { v, ok := c.Get("secret"); ... })

fire("/set")
fire("/check")
/set : *Context=0x613e8dd4300, 'secret' already present before Set? false /check : *Context=0x613e8dd4300, 'secret' present? false (value=<nil>)

两次请求用的是完全相同的 *Context 地址,但第二次请求完全读不到第一次设置的 secret——reset() 里的 c.Keys = nil 真的生效了。复用内存和数据隔离这两件事在这里是分开保证的:内存复用靠 sync.Pool,数据隔离靠 reset() 每次把所有会暴露给业务代码的字段清空。

真实实验:高并发下也不会有两个请求抢到同一个正在用的 Context

真实实测
// 500 个 goroutine 完全并发地各打一个请求,每个请求:
// c.Set("id", 自己的id); time.Sleep(1ms); 检查读回来的还是不是自己的id
// go run -race
500 fully concurrent requests, each planting+checking its own unique id: 0 mismatches (-race: 未检测到任何 data race)

500 个真正并发的请求,中间故意插了 1 毫秒的 time.Sleep 放大"如果池子出错会撞车"的窗口,结果 0 次串数据,-race 也没有报警。sync.Pool.Get() 保证同一个对象不会被同时借给两个调用者,所以即使内部复用同一批 *Context,不同请求之间也不会互相看到对方的数据。

真实实验:不让池子复用,到底多花多少内存

同一个 Engine、同一张路由表,唯一的区别是:一组正常跑,另一组在每次请求前先 runtime.GC() 清空池子(上面已经验证过这招真的有效),强迫每次都走一遍真正的分配。

真实实测
func BenchmarkWithPool(b *testing.B) {
    for i := 0; i < b.N; i++ { r.ServeHTTP(w, req) }
}
func BenchmarkWithoutPoolReuse(b *testing.B) {
    for i := 0; i < b.N; i++ { runtime.GC(); r.ServeHTTP(w, req) }
}
// go test -bench=. -benchmem
BenchmarkWithPool-10 1001 ns/op 1377 B/op 11 allocs/op BenchmarkWithoutPoolReuse-10 201032 ns/op 3066 B/op 16 allocs/op (第二次独立运行: 982 ns/op 1376 B/op 11 allocs/op vs 202533 ns/op 3066 B/op 16 allocs/op)

ns/op 这一列不能直接比较——"不复用"那组里每次强制触发的 runtime.GC() 本身开销就远超一次请求处理,把耗时数字完全带偏了。真正能说明问题的是 B/opallocs/op:两次独立运行都稳定显示,拒绝复用会让每次请求多分配约 2.2 倍的内存、多出 5 次堆分配。这才是 sync.Pool 真正要解决的问题——不是让单次请求"跑得更快",是减少 GC 需要追踪和回收的对象数量。

交互演示:对象池复用、GC 清空、隔离性、并发安全的真实数据回放

把上面几组真实实验按发生顺序串成一条演示。

Gin Context 池实录未开始
点击"下一步"或"播放"开始。

全部数据来自本机真实 go run/go test -bench 的输出(pool_reuse.goisolation.goconcurrent_isolation.gopool_bench_test.go),Python 脚本逐项核对了 3 次独立运行的地址复用/GC 清空模式、并发隔离结果,以及两次独立基准测试的内存分配比例。

参考与说明

  • 本文源码引用(Engine.poolallocateContextServeHTTP 里的 Get/reset/Put 三步)均取自本机通过 go get github.com/gin-gonic/gin 真实拉取、由本机 go.mod 解析锁定的 v1.12.0 版本源码($GOMODCACHE/github.com/gin-gonic/[email protected]/gin.go),跟前两篇是同一个版本。
  • 全部实验(pool_reuse.goisolation.goconcurrent_isolation.gopool_bench_test.go)均为本机真实 go run/go test -bench -benchmem 产生的输出,未做删改;并发隔离那组额外用 -race 检测器跑过。
  • 演示数据的自检:3 次独立运行的地址复用/GC 清空模式逐项核对过(5 次请求地址相同,GC 后地址必须不同);并发隔离的 0 mismatch 结果核对过;两次独立基准测试的 B/op 和 allocs/op 比例(约 2.2x 和 1.45x)相互印证,而且都明确指出 ns/op 在这个对照实验里不是公平的比较维度。
  • 没有涉及:Context.Copy()(在 goroutine 里异步使用 Context 时必须调用的浅拷贝方法,专门用来避开池子复用带来的数据竞争)、responseWriter 自己的重置逻辑、maxParams/maxSections 是怎么随着路由注册动态增长的。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电