如果你看过 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
中操作上下文时,使用只读副本是必要的。
你好,我是陈明勇,一名热爱技术、乐于分享的开发者,同时也是开源爱好者。
成功的路上并不拥挤,有没有兴趣结个伴?
关注我,加我好友,一起学习一起进步!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。