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 框架,定義了兩個接口:
/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
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