★ 请求级 context 生命周期:为何不能跨 goroutine 使用
结论先行
每个请求在 net/http 层有一个请求级 context(c.Request.Context()),请求处理结束或客户端断开时即被取消。而 *gin.Context 由 sync.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 的执行期。