每个 HTTP 请求都会拿到一个 *gin.Context,但 Gin 并不会为每个请求真的 new 一个——它维护一个 sync.Pool,请求进来时从池子里捞一个旧的出来擦干净重新用,请求结束再扔回去。这一篇不满足于"知道 Gin 用了对象池",而是真实观测了同一块内存被连续请求复用、被 GC 收走、以及在真正并发下也不会串数据这三件事,并且用一组对照基准测出了不复用到底多花多少内存。
延续前两篇的方法论:本机真实拉取 gin-gonic/gin v1.12.0,全部实验都是本机真实执行、真实测量的 Go 程序。
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() 把上一次请求留下的字段(handlers、index、Keys……上一篇提到过)全部清空,处理完请求之后再 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 次请求的地址都严格相等。
sync.Pool 的文档写得很清楚:池子里的对象可能在任意一次 GC 时被清空,不是永久缓存。
runtime.GC() r.ServeHTTP(w, req)after runtime.GC(), next request: *Context = 0x4d88c732e000 (跟之前 5 次请求用的 0x4d88c732e300 不是同一个地址)
手动触发一次 runtime.GC() 之后,下一次请求拿到的地址真的变了。这也解释了为什么 sync.Pool 适合"减少分配频率"而不能当成"绝对不会分配"的保证——GC 一来,池子可能被清空,下一个请求还是得走一次真正的分配。
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() 每次把所有会暴露给业务代码的字段清空。
// 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/op 和 allocs/op:两次独立运行都稳定显示,拒绝复用会让每次请求多分配约 2.2 倍的内存、多出 5 次堆分配。这才是 sync.Pool 真正要解决的问题——不是让单次请求"跑得更快",是减少 GC 需要追踪和回收的对象数量。
把上面几组真实实验按发生顺序串成一条演示。
全部数据来自本机真实 go run/go test -bench 的输出(pool_reuse.go、isolation.go、concurrent_isolation.go、pool_bench_test.go),Python 脚本逐项核对了 3 次独立运行的地址复用/GC 清空模式、并发隔离结果,以及两次独立基准测试的内存分配比例。