扫码关注公众号,手机阅读更方便
前言
如果你看过 Go 语言中 Gin 框架的官方文档,你可能会注意到一条重要的提醒:当在中间件或 handler 中启动新的 Goroutine 时,不能使用原始的上下文,必须使用只读副本。文档中还提供了以下示例代码:
func main() {
r := gin.Default()
r.GET("/long_async", func(c *gin.Context) {
// 创建在 goroutine 中使用的副本
cCp := c.Copy()
go func() {
// 用 time.Sleep() 模拟一个长任务。
time.Sleep(5 * time.Second)
// 请注意您使用的是复制的上下文 "cCp",这一点很重要
log.Println("Done! in path " + cCp.Request.URL.Path)
}()
})
r.GET("/long_sync", func(c *gin.Context) {
// 用 time.Sleep() 模拟一个长任务。
time.Sleep(5 * time.Second)
// 因为没有使用 goroutine,不需要拷贝上下文
log.Println("Done! in path " + c.Request.URL.Path)
})
// 监听并在 0.0.0.0:8080 上启动服务
r.Run(":8080")
}
然而,文档中并未详细说明为什么需要使用只读副本。如果你对 Gin Context 的设计特点及其生命周期不太了解,可能无法猜到其背后的具体原因。
本文将深入探讨在 Go Gin 框架中,为什么在处理 HTTP 请求时,如果需要启动一个 Goroutine 来执行异步任务,必须使用只读副本而不是直接使用原始上下文对象,以及直接使用原始上下文对象可能导致的问题。
准备好了吗?准备一杯你最喜欢的咖啡或茶,随着本文一探究竟吧。

存在隐患的代码
我们先来看看这段代码:
package main
import (
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/test", func(ctx *gin.Context) {
// 往上下文中写入数据
unixMilli := time.Now().UnixMilli()
ctx.Set("timestamp", unixMilli)
// 在主线程中启动一个 goroutine
go func() {
// 模拟耗时任务
time.Sleep(10 * time.Second)
// 从上下文中读取数据并比较
value, exists := ctx.Get("timestamp")
if exists {
// 比较时间戳
if value.(int64) == unixMilli {
println("时间戳相同")
} else {
println("时间戳不同")
}
} else {
println("数据不存在")
}
}()
ctx.JSON(200, gin.H{
"message": "程序员陈明勇",
})
})
r.GET("/healthcheck", func(ctx *gin.Context) {
ctx.JSON(200, gin.H{
"message": "ok",
})
})
r.Run(":8080")
}
在上述代码示例中,通过使用 Gin 框架,定义了两个接口:
-
/test接口:- 处理请求时,获取当前时间戳并将其存储在上下文对象(
*gin.Context)中。 - 启动一个
Goroutine,模拟耗时任务(延迟 10 秒),从上下文中读取存储的时间戳并进行比较。 - 返回
{"message": "程序员陈明勇"}的JSON响应。
- 处理请求时,获取当前时间戳并将其存储在上下文对象(
-
/healthcheck接口:- 提供一个健康检查功能,直接返回
{"message": "ok"}的JSON响应,表示服务正在正常运行。
- 提供一个健康检查功能,直接返回
模拟测试
在生产环境中,不同接口会被频繁且交替调用,例如 /test 和 /healthcheck,现在我们来模拟这种场景进行测试:
-
启动服务:
go run main.go -
并发测试:
- 使用
go-wrk或其他工具持续一段时间同时请求/test和/healthcheck接口,观察控制台打印结果。
- 使用
控制台打印结果分析
预期控制台打印信息应始终为:时间戳相同,但实际情况却还出现:
- 时间戳不同
- 数据不存在
这表明上下文对象中的 timestamp 对应的值已被修改或该 key 被删除。然而在 /test 接口里并未对 timestamp 执行修改或删除操作。
原因分析
既然能确定 key 为 timestamp 的数据被删除,或者其值被修改,并且这种操作并非由我们的代码主动触发。因此,需要确认是否是 Gin 框架内部触发了这些操作。我们可以先看看 gin.Context 结构体的源码(位于 context.go 文件中)。
通过分析源码,可以定位到 gin.Context 结构体的 reset 方法,这个方法负责执行一些清空操作,包括清空上下文中的键值对,源码如下:
func (c *Context) reset() {
c.Writer = &c.writermem
c.Params = c.Params[:0]
c.handlers = nil
c.index = -1
c.fullPath = ""
c.Keys = nil
c.Errors = c.Errors[:0]
c.Accepted = nil
c.queryCache = nil
c.formCache = nil
c.sameSite = 0
*c.params = (*c.params)[:0]
*c.skippedNodes = (*c.skippedNodes)[:0]
}
由此可见 key 为 timestamp 的数据被删除的操作是在这里面进行的。那么修改 value 的操作呢?我们不妨猜猜,既然有 reset 方法,那么 gin.Context 对象有可能会被复用,我们可以进一步查看 reset 方法的调用位置,可以在 gin.go 文件中找到 ServeHTTP 方法:
// ServeHTTP 是 HTTP 请求的入口方法
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := engine.pool.Get().(*Context) // 从对象池中获取 Context 对象
c.writermem.reset(w)
c.Request = req
c.reset() // 重置 Context 对象
engine.handleHTTPRequest(c)
engine.pool.Put(c) // 将 Context 放回对象池
}
通过阅读源码可以得出以下结论:
Gin使用对象池复用Context对象,并非每次请求都新建Context。- 获取到
Context对象后,会通过reset方法清空状态和数据。 - 请求结束后,
Context对象会被放回对象池。
这就说得通了,在 /test 接口中,返回 JSON 响应后,Context 对象会被放回对象池。而 Goroutine 延迟 10 秒后才从 Context 对象中读取 timestamp 的数据。在这 10 秒里,Context 对象可能已经被复用到其他请求,例如:
- 复用到
/test接口请求里,导致timestamp的值被覆盖。 - 复用到
/healthcheck接口请求里,导致timestamp被删除(因为reset清空了数据)。
修复代码
为了解决 Context 对象被复用导致数据不一致的问题,使用只读副本代替原本的上下文对象:
package main
import (
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/test", func(ctx *gin.Context) {
// 往上下文中写入数据
unixMilli := time.Now().UnixMilli()
ctx.Set("timestamp", unixMilli)
// 创建在 goroutine 中使用的副本
cCp := ctx.Copy()
// 在主线程中启动一个 goroutine
go func() {
// 模拟耗时任务
time.Sleep(10 * time.Second)
// 从上下文中读取数据并比较
value, exists := cCp.Get("timestamp")
if exists {
// 比较时间戳
if value.(int64) == unixMilli {
println("时间戳相同")
} else {
println("时间戳不同")
}
} else {
println("数据不存在")
}
}()
ctx.JSON(200, gin.H{
"message": "程序员陈明勇",
})
})
r.GET("/healthcheck", func(ctx *gin.Context) {
ctx.JSON(200, gin.H{
"message": "ok",
})
})
r.Run(":8080")
}
修复后,在 Goroutine 中始终能安全地读取上下文数据。
输出结果始终为:时间戳相同。
Gin 框架提供了 context.Copy() 方法,用于创建上下文的只读副本。副本是协程安全的,因为它复制了上下文中的大部分数据,同时与原始上下文隔离。
小结
在 Go Gin 框架中,启动 Goroutine 处理异步任务时,直接使用原始上下文可能会导致数据竞态、不安全访问、或意外的数据丢失等问题。这是因为 Gin 的上下文 Context 对象是复用的。在请求处理完成后,上下文会被放回对象池供后续请求使用。当新的请求从对象池获取上下文时,Gin 会通过 reset 方法清空上下文中的状态和数据。
如果 Goroutine 延迟访问上下文对象,此时上下文对象可能已经被复用为另一个请求的上下文对象,从而导致不可预测的结果。通过使用上下文对象的只读副本,可以避免这些问题,确保数据在 Goroutine 中的独立性和安全性。因此,在 Goroutine 中操作上下文时,使用只读副本是必要的。


