★ 请求级 context 生命周期:为何不能跨 goroutine 使用

结论先行

每个请求在 net/http 层有一个请求级 context(c.Request.Context()),请求处理结束或客户端断开时即被取消。而 *gin.Contextsync.Pool 复用:handler 返回后它就被归还池中,可能被下一个请求拿走。因此在 goroutine 里继续用 c(c.JSON、c.Set、c.Param…)属于典型的 use-after-recycle:数据串扰、写错响应、偶发 panic,且难复现。规范:goroutine 只接收 context.Context 和必要的数据副本。

为什么“偶尔能用”更危险

  • gin 官方文档明确提示:在中间件/handler 内起 goroutine 时,不要使用原始 context,应使用只读副本并只传需要的值。
  • 请求结束后 context 被 cancel:下游调用(DB/HTTP)立即失败——这其实是保护,不是 bug。
  • 池化复用导致偶发“读到别的请求的数据”,比稳定 panic 更难排查,是面试官最爱追问的隐蔽坑。

安全模式

  • ctx := c.Request.Context()(或加超时派生 ctx)给 goroutine 做下游调用。
  • 需要把 goroutine 结果写回响应:在 handler 存活期内同步等待(channel / errgroup / WaitGroup),收到结果后再写 c。
  • 真正“handler 返回后还要写”是不允许的——响应已结束,只能改走异步任务队列 + 回调/推送。

代码示例

func (h *Handler) Get(c *gin.Context) {
    ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
    defer cancel()

    type res struct {
        data any
        err  error
    }
    ch := make(chan res, 1)
    go func() {
        // 只带 ctx 和参数副本,绝不碰 *gin.Context
        d, err := h.svc.Get(ctx, c.Param("id"))
        ch <- res{d, err}
    }()

    select {
    case r := <-ch:
        if r.err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "failed"})
            return
        }
        c.JSON(http.StatusOK, r.data)
    case <-ctx.Done(): // 超时/客户端断开
        c.JSON(http.StatusGatewayTimeout, gin.H{"error": "timeout"})
    }
}

常见追问 / 记忆点

  • 追问:为什么不能把 c 直接塞进 goroutine?答:两条原因:① handler 返回后 Context 归还 sync.Pool,继续使用是复用的对象(数据竞争/串扰);② 请求 context 已取消,写响应本身没有意义。
  • 追问:goroutine 里真需要写响应怎么办?答:等它算完再在 handler 内写(上例);或彻底异步化:任务落队列,完成后通过 WebSocket/轮询通知。
  • 记忆点:goroutine 只带 ctx 与副本;gin.Context 的生命周期 = 当前 handler 的执行期。
笔记加载中…