前往小程序,Get更优阅读体验!
立即前往
发布
社区首页 >专栏 >Go Gin 源码分析:上下文复用与 Goroutine 中的潜在坑

Go Gin 源码分析:上下文复用与 Goroutine 中的潜在坑

原创
作者头像
陈明勇
发布2024-12-23 10:35:45
发布2024-12-23 10:35:45
2803
举报
文章被收录于专栏:Go 技术Go技术干货

前言

如果你看过 Go 语言中 Gin 框架的官方文档,你可能会注意到一条重要的提醒:当在中间件或 handler 中启动新的 Goroutine 时,不能使用原始的上下文,必须使用只读副本。文档中还提供了以下示例代码:

代码语言:go
复制
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 来执行异步任务,必须使用只读副本而不是直接使用原始上下文对象,以及直接使用原始上下文对象可能导致的问题。

准备好了吗?准备一杯你最喜欢的咖啡或茶,随着本文一探究竟吧。

存在隐患的代码

我们先来看看这段代码:

代码语言:go
复制
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 框架,定义了两个接口:

  1. /test 接口
    • 处理请求时,获取当前时间戳并将其存储在上下文对象(*gin.Context)中。
    • 启动一个 Goroutine,模拟耗时任务(延迟 10 秒),从上下文中读取存储的时间戳并进行比较。
    • 返回 {"message": "程序员陈明勇"}JSON 响应。
  2. /healthcheck 接口
    • 提供一个健康检查功能,直接返回 {"message": "ok"}JSON 响应,表示服务正在正常运行。

模拟测试

在生产环境中,不同接口会被频繁且交替调用,例如 /test/healthcheck,现在我们来模拟这种场景进行测试:

  • 启动服务
代码语言:bash
复制
go run main.go
  • 并发测试
    • 使用 go-wrk 或其他工具持续一段时间同时请求 /test/healthcheck 接口,观察控制台打印结果。

控制台打印结果分析

预期控制台打印信息应始终为:时间戳相同,但实际情况却还出现:

  • 时间戳不同
  • 数据不存在

这表明上下文对象中的 timestamp 对应的值已被修改或该 key 被删除。然而在 /test 接口里并未对 timestamp 执行修改或删除操作。

原因分析

既然能确定 keytimestamp 的数据被删除,或者其值被修改,并且这种操作并非由我们的代码主动触发。因此,需要确认是否是 Gin 框架内部触发了这些操作。我们可以先看看 gin.Context 结构体的源码(位于 context.go 文件中)。

通过分析源码,可以定位到 gin.Context 结构体的 reset 方法,这个方法负责执行一些清空操作,包括清空上下文中的键值对,源码如下:

代码语言:go
复制
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]
}

由此可见 keytimestamp 的数据被删除的操作是在这里面进行的。那么修改 value 的操作呢?我们不妨猜猜,既然有 reset 方法,那么 gin.Context 对象有可能会被复用,我们可以进一步查看 reset 方法的调用位置,可以在 gin.go 文件中找到 ServeHTTP 方法:

代码语言:go
复制
// 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 对象被复用导致数据不一致的问题,使用只读副本代替原本的上下文对象:

代码语言:go
复制
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 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 存在隐患的代码
    • 模拟测试
    • 控制台打印结果分析
    • 原因分析
  • 修复代码
  • 小结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档