golang的Gin框架异步Context异常

文章讲述了在使用Gin框架处理数据导入时,前端上传文件后端异步处理时遇到的Context空问题,原因是Gin的Context被缓存池中的其他请求重用。解决办法是复制Context以避免共享状态问题。
摘要由CSDN通过智能技术生成

引言

前两天做的数据导入功能,考虑到后端处理比较慢,所以前端上传完文件,后端开启协程异步进行处理,同时立即返回给前端一个上传成功标识及本次上传的uuid。前端拿着返回的uuid进行轮训查询后端处理状态。逻辑上没有问题,但偶现获取 ctx 中存储的信息为空。

1.分析复现
1.1操作流程
1.执行上传接口: /upload

2.紧接着执行查询状态接口:/check-status

问题:有时上传接口中的异步内部获取 tid 失败(为nil)。
1.2 简化后代码逻辑
package main

import (
 "fmt"
 "github.com/gin-gonic/gin"
 "time"
)

func main() {
 r := gin.Default()

 // 上传接口
 r.GET("/upload", func(c *gin.Context) {
  // 设置tid的值
  c.Set("tid", "abc")
  // 异步处理业务
  go func() {
   // 打印 ctx 的地址,以及tid的值
   fmt.Printf("\nupload-1------:%p, tid:%+v\n", c, c.Value("tid"))
   // 处理业务,使用sleep替代处理逻辑
   time.Sleep(time.Second * 10)
   // 再次打印 ctx 的地址,以及tid的值
   fmt.Printf("\nupload-2======:%p, tid:%+v\n", c, c.Value("tid"))
  }()
  // 立即返回给前端
  c.JSON(200, gin.H{
   "message": "upload pong",
   "uuid":    "123",
  })
 })

 // 获取上传后的处理状态
 r.GET("/check-status", func(c *gin.Context) {
  fmt.Printf("\ncheck-status ctx***********:%p\n", c)
  c.JSON(200, gin.H{
   "message": "check status pong",
   "uuid":    c.Query("uuid"),
  })
 })

 // 启动http服务
 if err := r.Run(":80"); err != nil {
  fmt.Println("listen err")
 }
}
2. 问题分析
2.1 因为子协程直接使用父协程的变量?

刚开始考虑的方向是 子协程直接使用父协程中的变量c,引起的问题?那么把c 传进协程,能否解决?于是代码改为:

// 省略前面未改到代码...

  // 异步处理业务
  go func(c context.Context) {
   // 打印 ctx 的地址,以及tid的值
   fmt.Printf("\nupload-1------:%p, tid:%+v\n", c, c.Value("tid"))
   // 处理业务,使用sleep替代处理逻辑
   time.Sleep(time.Second * 10)
   // 再次打印 ctx 的地址,以及tid的值
   fmt.Printf("\nupload-2======:%p, tid:%+v\n", c, c.Value("tid"))
  }(c)

// 省略后面未改动代码...

改动后再按1.1操作流程还是会出现此问题。分析原因是因为ctx本身就是指针类型,所以传进子协程,父协程把 ctx修改了依然后引起子协程的获取失败。但问题是现在父协程也没有修改ctx,那是什么问题?

2.2 发现Gin的问题

因为在sleep之前可以成功获取ctx中的值,sleep之后ctx中存储的值为空,可以肯定的是ctx被修改了。可查看了一下自己写的代码,根本没有地方修改ctx。那么查找一下gin包中有没有修改ctx的方法。

发现确实在gin包中的 context.go 文件中有个 reset() 方法:

// Copyright 2014 Manu Martinez-Almeida. All rights reserved.
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file.

package gin

... 省略不相关的代码

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]
}

这个方法中有c.Keys = nil,可能是它引起的问题。找到 gin 包中的 gin.go 文件中的 ServeHTTP() 方法:

// ServeHTTP conforms to the http.Handler interface.
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 c := engine.pool.Get().(*Context)
 c.writermem.reset(w)
 c.Request = req
 c.reset()

 engine.handleHTTPRequest(c)

 engine.pool.Put(c)
}

注意到 c.reset() 方法上面3行中的 c := engine.pool.Get().(*Context) 这是什么玩意?engine.pool 是 sync.Pool 类型,可以看出来,gin把 ctx 放在了一个缓存池中。大爷的,原因不会是我上传时使用的ctx因为异常还没有使用完,又被重新使用了吧?其实通过打印多次访问的日志可以看出来,当上传使用的 ctx 与 查询状态使用的 ctx 地址相同(证明是同一个ctx被重复使用)时 上传接口的异步sleep后是获取不到tid的。

因为是从缓冲池中获取 ctx ,不一定每次都能获取到与 上传接口相同的 ctx,下面代码就没有获取到相同的 ctx ,所以上传异步sleep后还是可以获取到 tid 的值:

// 上传接口,sleep前输出ctx地址,以及tid的值
upload-1------:0xc000163a00, tid:abc
[GIN] 2023/07/05 - 10:10:52 | 200 |       505.8µs |       127.0.0.1 | GET      "/upload"
// 获取状态,从地址可以看出来与上传是不同的ctx
check-status ctx***********:0xc00047c100
[GIN] 2023/07/05 - 10:10:57 | 200 |       992.1µs |       127.0.0.1 | GET      "/check-status?uuid=123"
// 获取状态,从地址可以看出来与上传是不同的ctx
check-status ctx***********:0xc00047c100
// 上传接口,sleep后输出的ctx地址,以及tid的值,这个是正确的
upload-2======:0xc000163a00, tid:abc

下面请求获取状态接口,(多刷几遍可能会一样)正好使用了与上传接口相同的ctx,导致问题出现:

// 上传接口,sleep前输出ctx地址,以及tid的值
upload-1------:0xc0000a0100, tid:abc
[GIN] 2023/07/05 - 10:18:34 | 200 |            0s |       127.0.0.1 | GET      "/upload"
// 获取状态,从地址可以看出来与上传使用的是相同的 ctx: 0xc0000a0100
check-status ctx***********:0xc0000a0100
[GIN] 2023/07/05 - 10:18:37 | 200 |       140.4µs |       127.0.0.1 | GET      "/check-status?uuid=123"
// 上传接口,sleep后输出的ctx地址,以及tid的值,这时获取tid就为空
upload-2======:0xc0000a0100, tid:<nil>

注:获取地址时不能使用 &c 去获取指针的地址。&c是存储指针的空间的地址,不是实际指针的地址。

这时再去看官方的文档,有一节 “在中间件中使用 Goroutine”:

当在中间件或 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")
}
3. 解决问题

通过查看官方,我们只需要把 gin的ctx 复制一份传在协程中使用,那么协程中使用的 ctx 就不会存在与 Gin的 缓存池中了,就可以安全使用了。正确代码如下:

package main

import (
 "fmt"
 "github.com/gin-gonic/gin"
 "time"
)

func main() {
 r := gin.Default()

 // 上传接口
 r.GET("/upload", func(c *gin.Context) {
  // 设置tid的值
  c.Set("tid", "abc")
  ctx := c.Copy()
  // 异步处理业务
  go func() {
   // 打印 ctx 的地址,以及tid的值
   fmt.Printf("\nupload-1------:%p, tid:%+v\n", ctx, ctx.Value("tid"))
   // 处理业务,使用sleep替代处理逻辑
   time.Sleep(time.Second * 10)
   // 再次打印 ctx 的地址,以及tid的值
   fmt.Printf("\nupload-2======:%p, tid:%+v\n", ctx, ctx.Value("tid"))
  }()
  // 立即返回给前端
  c.JSON(200, gin.H{
   "message": "upload pong",
   "uuid":    "123",
  })
 })

 // 获取上传后的处理状态
 r.GET("/check-status", func(c *gin.Context) {
  fmt.Printf("\ncheck-status ctx***********:%p\n", c)
  c.JSON(200, gin.H{
   "message": "check status pong",
   "uuid":    c.Query("uuid"),
  })
 })

 // 启动http服务
 if err := r.Run(":80"); err != nil {
  fmt.Println("listen err")
 }
}

总结

gin使用了缓存池来提高性能。官方文档中已经提示我们正确的使用方法了,只是没有明确说明为什么要这么做,所以这也不算Gin的BUG,只是我们没有正确使用。类似的做法还有内置的包 fmt.Printf ,有兴趣的同学可以再研究一下 sync.Pool。

  • 13
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,我可以为您提供一些关于使用 Golang Gin 框架进行项目实战的建议。 首先,您需要安装 GolangGin 框架。安装过程可以参考官方文档进行操作。然后,您可以按照以下步骤进行项目实战: 1. 创建项目结构 在您的 GOPATH 目录下创建一个新的项目文件夹。通常情况下,您需要为项目创建以下子文件夹: ``` . ├── main.go ├── config │ ├── config.go │ └── ... ├── controller │ ├── home_controller.go │ └── ... ├── middleware │ ├── auth.go │ └── ... ├── model │ ├── user.go │ └── ... ├── router │ ├── router.go │ └── ... └── service ├── user_service.go └── ... ``` 2. 创建路由 在 router 文件夹中创建一个 router.go 文件,并编写以下代码: ```go package router import ( "github.com/gin-gonic/gin" "your-project/controller" "your-project/middleware" ) func SetupRouter() *gin.Engine { r := gin.Default() // 中间件 r.Use(middleware.AuthMiddleware()) // 路由 r.GET("/", controller.HomePageHandler) return r } ``` 在上面的代码中,我们使用了 gin 框架中的 gin.Default() 函数来创建一个默认的 gin.Engine 实例,并使用中间件来进行身份验证。然后我们定义了一个路由,将根路径映射到 HomePageHandler 方法。 3. 创建控制器 在 controller 文件夹中创建 home_controller.go 文件,并编写以下代码: ```go package controller import ( "github.com/gin-gonic/gin" "your-project/service" ) func HomePageHandler(c *gin.Context) { users := service.GetAllUsers() c.JSON(200, users) } ``` 在上面的代码中,我们定义了一个 HomePageHandler 方法,它接收一个 gin.Context 对象作为参数,并使用 service 层中的 GetAllUsers 函数获取所有用户的数据。然后我们使用 JSON 方法将数据以 JSON 格式返回给客户端。 4. 创建服务层 在 service 文件夹中创建 user_service.go 文件,并编写以下代码: ```go package service import "your-project/model" func GetAllUsers() []*model.User { // TODO: 查询数据库获取所有用户数据 return []*model.User{} } ``` 在上面的代码中,我们定义了一个 GetAllUsers 函数,它返回一个包含所有用户数据的切片。在实际项目中,我们需要查询数据库来获取这些数据。 5. 创建模型 在 model 文件夹中创建 user.go 文件,并编写以下代码: ```go package model type User struct { ID int `json:"id"` Name string `json:"name"` Age int `json:"age"` } ``` 在上面的代码中,我们定义了一个 User 结构体,它包含三个字段:ID、Name 和 Age。这些字段将用于存储每个用户的信息。 6. 创建配置文件 在 config 文件夹中创建 config.go 文件,并编写以下代码: ```go package config const ( DB_HOST = "localhost" DB_PORT = "3306" DB_USERNAME = "root" DB_PASSWORD = "password" DB_NAME = "your-database-name" ) ``` 在上面的代码中,我们定义了一些常量,用于存储数据库配置信息。这些常量将在连接数据库时使用。 7. 连接数据库 在 service 层中的 GetAllUsers 函数中,我们需要连接数据库来获取所有用户数据。可以使用以下代码来连接 MySQL 数据库: ```go package service import ( "database/sql" "fmt" "your-project/config" "your-project/model" ) func GetAllUsers() []*model.User { // 连接数据库 db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", config.DB_USERNAME, config.DB_PASSWORD, config.DB_HOST, config.DB_PORT, config.DB_NAME, )) if err != nil { panic(err) } defer db.Close() // 查询所有用户数据 rows, err := db.Query("SELECT * FROM users") if err != nil { panic(err) } // 解析查询结果 users := []*model.User{} for rows.Next() { user := &model.User{} err := rows.Scan(&user.ID, &user.Name, &user.Age) if err != nil { panic(err) } users = append(users, user) } return users } ``` 在上面的代码中,我们使用 sql.Open 函数来打开 MySQL 数据库连接。然后我们使用 db.Query 函数来查询所有用户数据,并使用 rows.Scan 函数将查询结果解析为 User 结构体的切片。 以上就是使用 Golang Gin 框架进行项目实战的流程。当然,在实际项目中,您需要根据自己的需求进行相应的修改和扩展。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值