在 handler 里起 goroutine 之后直接用 c,看起来能跑,其实读到的可能是别的请求的数据。这篇用 200 个串行请求量出出错的比例,用 -race 看竞态检测器的报告,再把 c.Copy() 能做和不能做的事逐项试一遍。
context.go 的 Copy(Gin v1.12.0):
122 | func (c *Context) Copy() *Context {
123 | cp := Context{
124 | writermem: c.writermem,
125 | Request: c.Request,
126 | engine: c.engine,
127 | }
128 |
129 | cp.writermem.ResponseWriter = nil
130 | cp.Writer = &cp.writermem
131 | cp.index = abortIndex
132 | cp.handlers = nil
133 | cp.fullPath = c.fullPath
134 |
135 | cKeys := c.Keys
136 | c.mu.RLock()
137 | cp.Keys = maps.Clone(cKeys)
138 | c.mu.RUnlock()
139 |
140 | cParams := c.Params
141 | cp.Params = make([]Param, len(cParams))
142 | copy(cp.Params, cParams)
143 |
144 | return &cp
145 | }副本是一个全新的 Context:Keys 在读锁下克隆,Params 复制到新切片,Request、engine、fullPath 照搬;handlers 为 nil、index 为 abortIndex,所以 Next 什么都不会跑;ResponseWriter 被置为 nil,这是为了不让 goroutine 往已经结束的响应里写。
GOMAXPROCS=10:不 Copy:200 个 goroutine 里读到别的请求 id 的有 196 个;c.Copy():200 个里有 0 个 GOMAXPROCS=4:不 Copy:200 个 goroutine 里读到别的请求 id 的有 196 个;c.Copy():200 个里有 0 个 GOMAXPROCS=1:不 Copy:200 个 goroutine 里读到别的请求 id 的有 199 个;c.Copy():200 个里有 0 个
Context 在请求结束后被放回 sync.Pool,下一个请求 reset 之后复用。串行发 200 个请求,每个 goroutine 等 50 ms 再读 c.Param("id"),几乎全部(196~199 个)读到了别的请求的 id,不是偶然现象;改用 c.Copy() 则一个都没错。注意这不依赖多核:GOMAXPROCS=1 时错得更多。
检测到 "WARNING: DATA RACE" 共 10 处;退出码 66
WARNING: DATA RACE
Read at 0x00c000142138 by goroutine 26:
github.com/gin-gonic/gin.(*Context).Param()
<gtmp>/_gopath/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:504 +0xa0
main.main.func1.1()
<gtmp>/n16b/main.go:12 +0xb0
Previous write at 0x00c000142138 by goroutine 12:
github.com/gin-gonic/gin.(*Context).reset()
报告里出现的 Gin 函数: ['github.com/gin-gonic/gin.(*Context).Next', 'github.com/gin-gonic/gin.(*Context).Param', 'github.com/gin-gonic/gin.(*Context).reset', 'github.com/gin-gonic/gin.(*Engine).ServeHTTP', 'github.com/gin-gonic/gin.(*Engine).handleHTTPRequest']用 -race 编译同一份代码,20 个请求就能报出 10 处数据竞争:一边是你的 goroutine 在 (*Context).Param() 里读,另一边是后面的请求在 (*Context).reset() 里写。这类 bug 在压测前往往不显现,-race 的集成测试能提早发现。
Copy 之后原 Context 新增的 key 在副本里吗: false;副本里 user=alice;副本 Param="7" FullPath="/u/:id"
副本 IsAborted=true(index=abortIndex,Next 什么也不会执行)
在副本上写响应 cp.String(200, ...):panic=runtime error: invalid memory address or nil pointer dereference
原请求的响应:200 "orig ok"副本是快照:复制之后原 Context 新增的 key 不会出现在副本里;可以读 Keys、Params、FullPath、Request;不能调用 c.String / c.JSON 等写响应的方法(nil 的 ResponseWriter 会 panic);Next 和 Abort 都没有意义。
handler 刚返回时 cp.Request.Context().Err()=context canceled
150ms 后 cp.Request.Context().Err()=context canceled副本里的 Request 还是原来的那个,它的 Context() 在 handler 返回后立刻被取消。要在后台任务里继续调下游,需要另建一个脱离请求生命周期的 context(例如 context.WithoutCancel(c.Request.Context()),本文没有验证)。