bigywater.tech
Gin · 20

Context 复用:goroutine 里不 c.Copy() 会怎样

在 handler 里起 goroutine 之后直接用 c,看起来能跑,其实读到的可能是别的请求的数据。这篇用 200 个串行请求量出出错的比例,用 -race 看竞态检测器的报告,再把 c.Copy() 能做和不能做的事逐项试一遍。

196 / 200
GOMAXPROCS=10 时,不 Copy 的 goroutine 读到别的请求 id 的个数(Copy 为 0)
10 处
-race 版本只发 20 个请求就报出的 DATA RACE
context canceled
handler 返回后,副本里 Request.Context().Err() 的值

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 往已经结束的响应里写。

不 Copy 的后果

实测 · 本机执行输出
  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()),本文没有验证)。

分步演示

Context 复用与 Copy
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。第二、三节用真实的 httptest.NewServer 与 http.Client,串行发请求;-race 版本用 go build -race;GOMAXPROCS 通过环境变量设置;第四、五节同样用真实的本机 HTTP 服务;每种情形各一次(第二节每个设置 200 个请求)
    • 没有验证:并发请求(而不是串行)下的出错比例;context.WithoutCancel 在后台任务里的用法(只是推测的建议);Copy 的开销(分配与耗时);Gin 的 ContextWithFallback 打开时 Copy 的行为;其他版本的 Copy 实现
    京ICP备2026067502号-1京公网安备11010502063487号