この記事は株式会社カオナビ Advent Calendar 2025の4日目の記事です。
Ginの公式ドキュメントには次の注意書きがあります。
新しい goroutine をミドルウェアやハンドラー内で生成する場合、goroutine の内部でオリジナルの context を 使用しないでください。読み込み用のコピーを使ってください。
なぜオリジナルの*gin.Contextをgoroutineに渡すのが適切でないのかについては公式ドキュメントでは説明されていません(おそらく)。
この記事では、この理由を自分で理解するために調べた内容を、実際のコードとGinの実装を踏まえて整理します。
先に結論を書いておくと、
gin.Contextはsync.Poolによって再利用されるため、参照していた*Contextが、その後のリクエストによって上書きされる可能性があるからです。
1. goroutineにオリジナルの*gin.Contextを渡してみる
まずは実際にgoroutineにオリジナルのcontextを渡すコードを動かしてみます。
1-1. テストコード
リクエストパラメータのidを読み取り、goroutine内でログ出力する単純なコードです。
package main
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/async", func(c *gin.Context) {
// リクエストごとのIDをContextに保存
requestId := c.Query("id")
c.Set("id", requestId)
// オリジナルのContextである'c'をgoroutineで使用する(検証用)
go func() {
// 検証のため少し待機し、handlerが終了してContextがプールに戻るのを待つ
time.Sleep(500 * time.Millisecond)
// goroutine内でContextからidを取得
readId := c.GetString("id")
log.Printf("expected: %s, read: %s", requestId, readId)
}()
// 検証のため、goroutineを待たずにレスポンスを返す(ここでContextの使用が終わり、プールに返却される)
c.String(http.StatusOK, "ok")
})
router.Run(":8080")
}
1-2. 検証方法
- ターミナルで下記コマンドを叩く
curl "localhost:8080/async?id=A" && curl "localhost:8080/async?id=B" - ログ出力を確認
1-3. 結果
2025/12/03 01:35:33 expected: A, read: B ← Aのリクエストなのに中身がBになっている
2025/12/03 01:35:33 expected: B, read: B
期待する動作は、リクエストAのgoroutineは「A」を、リクエストBのgoroutineは「B」を読み取ることです。
しかし実際には、リクエストAのgoroutineが「B」を読んでしまっています。これは、goroutineがContextにアクセスする前に、そのContextが別のリクエスト(B)に再利用されてしまったためです。
また、タイミングによっては以下のような結果になることもあります
2025/12/03 01:40:15 expected: A, read: ← 空文字列が返ってきている
これは、Contextがreset()されてc.Keys = nilになった後、まだ新しい値が設定されていないタイミングでgoroutineがアクセスしたためです。
注意: これら結果は
sync.Poolの動作とgoroutineのタイミングに依存するため、必ずしも毎回再現するとは限りません。
2. なぜこうなるのか
gin.Contextのライフサイクルについて、ServeHTTPの実装を見て確認します。
2-1. ServeHTTPの流れ
Ginのエンジンがリクエストを受け取った際の実装は以下のようになっています。
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() // ここで中身を初期化
engine.handleHTTPRequest(c) // ハンドラの実行
engine.pool.Put(c) // ハンドラ終了後、contextをプールに返す
}
- Aのリクエスト終了後、Contextは
pool.Putでプールへ - Bのリクエスト開始時、
pool.Getで同じContextが返ってくる可能性がある -
c.Request = reqで新しいリクエストがセットされ、c.reset()でKeysやParamsなどが初期化される
という流れになっています。
2-2. なぜgoroutineが別リクエストのデータを読みこむ可能性があるのか
時系列で見ると、以下のような流れでデータの不整合が発生します。
3. 安全にcontextを扱うには
3-1. c.Copy()を使う
Ginの公式ドキュメントにも記載されている方法です。
cCp := c.Copy()
go func() {
time.Sleep(500 * time.Millisecond)
log.Println("[goroutine]", cCp.GetString("id"))
}()
c.Copy()は、その時点でのContextの情報をコピーした新しいオブジェクトを作成して返します。sync.Poolのライフサイクルとは無関係になるため、メインのハンドラが終了して元のContextがリセットされても影響を受けません。
注意: Copy()で返されるContextは読み取り専用です。
3-2. GinのContextを渡さず、標準context.Contextを渡す
goroutine内でgin.Context固有の機能(Params、Keysなど)が不要な場合は、標準パッケージのcontext.Contextを渡す方法もあります。
ctx := c.Request.Context()
go func(ctx context.Context) {
// ctxを使った処理
}(ctx)
まとめ
- Ginの
*gin.Contextはsync.Poolで再利用される - ハンドラ終了後、Contextはプールに戻り、別のリクエストで上書きされる可能性がある
- そのため、goroutineに
*gin.Contextをそのまま渡すと意図しないデータを読む危険がある - 解決策は「
Copy()を渡す」か「標準のcontext.Contextを渡す」など