Go Gin 源碼分析:上下文複用與 Goroutine 中的潛在坑

前言

如果你看過Go 語言中Gin 框架的官方文檔,你可能會注意到一條重要的提醒:當在中間件或 handler 中啓動新的 Goroutine 時,不能使用原始的上下文,必須使用只讀副本。文檔中還提供了以下示例代碼:

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

 r.GET("/long_async", func(c *gin.Context) {
// 創建在 goroutine 中使用的副本
  cCp := c.Copy()
gofunc() {
   // 用 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
gofunc() {
   // 模擬耗時任務
   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 接口
  1. /healthcheck 接口

模擬測試

在生產環境中,不同接口會被頻繁且交替調用,例如/test 和/healthcheck,現在我們來模擬這種場景進行測試:

go run main.go

控制檯打印結果分析

預期控制檯打印信息應始終爲:時間戳相同,但實際情況卻還出現:

這表明上下文對象中的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 放回對象池
}

通過閱讀源碼可以得出以下結論:

這就說得通了,在/test 接口中,返回JSON 響應後,Context 對象會被放回對象池。而Goroutine 延遲 10 秒後才從Context 對象中讀取timestamp 的數據。在這 10 秒裏,Context 對象可能已經被複用到其他請求,例如:

修復代碼

爲了解決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
gofunc() {
   // 模擬耗時任務
   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 中操作上下文時,使用只讀副本是必要的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/87PV4KD6dOh0QWN2TVY-hQ