🍶

ginのcontextはpoolを通じて使い回されているので、goroutineに渡すときはCopyしよう

2022/05/30に公開約7,500字

ginのcontextをgoroutineに渡す際はコピーしなければならない

gin/context.goには下記のようなことが書いてあります(zennで見やすいように勝手に改行しました)。

// Copy returns a copy of the current context
// that can be safely used outside the request's scope.
// (訳: Copyはリクエストスコープの外でも安全に使えるcontextのコピーを作ります)
// This has to be used
// when the context has to be passed to a goroutine.
// (訳: contextをgoroutineに渡す際にはこのメソッドを使う必要があります)

気になるのは、goroutineにcontextを渡す際になぜCopyしなければならないかです。色々調べてみても、公式の説明は出てきませんでした(ご存じの方、情報頂けますと幸いです👶)。

そこでこの記事では、私が気づいた「contextをコピーせずにgoroutineに渡すことの危険性」についてメモしておきます。具体的には、ginがpoolを通じてcontextを使い回しているため、goroutineがcontextを使用するよりも前にcontextがresetされてしまいうることについてです。

コードを読んだり動かしたりで発見したことなので、網羅的な話ではありません。もし他の観点もあるよという方がいれば、コメントにてご共有頂ければと思います。

そもそもCopyは何をしているのか

Copyは元になるcontextのパラメーターを引き継いだうえで、新しいcontext構造体を作ります。

func (c *Context) Copy() *Context {
	cp := Context{
		writermem: c.writermem,
		Request:   c.Request,
		Params:    c.Params,
		engine:    c.engine,
	}
	cp.writermem.ResponseWriter = nil
	cp.Writer = &cp.writermem
	cp.index = abortIndex
	cp.handlers = nil
	cp.Keys = map[string]interface{}{}
	for k, v := range c.Keys {
		cp.Keys[k] = v
	}
	paramCopy := make([]Param, len(cp.Params))
	copy(paramCopy, cp.Params)
	cp.Params = paramCopy
	return &cp
}

ここでは新しい構造体cpを作って、そのアドレスを返しているところがポイントとなります。

Goのcontextの実体はポインタであり、そうであるからこそgoroutineやそうでない処理をまたいで好きに受け渡しをしていても、そのcontextがcancelされたかどうか皆が検知できるわけです
例えばRequestにcontextをセットするとは、要するにRequestにcontext本体となる構造体のアドレスをセットしているだけなのだということが下記のようなコードをplaygroundで実行すると分かります。


package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

func main() {
	_ctx := context.Background()
	ctx, cancel := context.WithCancel(_ctx)

	_req := http.Request{}
	req := _req.WithContext(ctx)
	got_ctx := req.Context()

	fmt.Println("ctxの実体はポインタであり、両者は同じ")
	fmt.Printf("%p\n", got_ctx)
	fmt.Printf("%p\n", ctx)

	go func(c context.Context) {
		time.Sleep(time.Second * 1)
		select {
		case <-c.Done():
			fmt.Println(c.Err())
			fmt.Println("the original was canceled")
		}
	}(ctx)

	go func(c context.Context) {
		time.Sleep(time.Second * 3)
		select {
		case <-c.Done():
			fmt.Println(c.Err())
			fmt.Println("cancel propagated")
		}
	}(got_ctx)

	cancel()
	time.Sleep(time.Second * 5)
}

//実行結果//
//ctxの実体はポインタであり、両者は同じ
//0xc0000b8200
//0xc0000b8200
//context canceled
//the original was canceled
//context canceled
//cancel propagated
//
//Program exited.

contextがポインタであるからこそ、同じcontextを使っている異なるgoroutineに対してcancelを検知させることができるのです。

contextをコピーせずにgoroutineに渡すことの危険性

ではここまで踏まえて、ginにおいてcontextをコピーせずにgoroutineに渡すことの何が危険なのか再度述べますと、ginがpoolを通じてcontextを使い回しているため、goroutineがcontextを使用するよりも前にcontextがresetされてしまいうることがあります。

以下で順を追って説明します。

ginはpoolを通じてcontextを使い回している

あまり言及されているのを見かけませんが、ginはcontextをsync.poolで使い回しています。

下記はginのServeHTTPであり、これはgoの標準パッケージのhttp.ServerのServeAndListen()を起点に呼び出されるginのいわばエントリーポイントです。

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

engine.handleHTTPRequest()の中で(c.Next()が呼ばれて)登録されているhandlerが呼び出されます。要するに、engine.handleHTTPRequest(c)が実際のリクエスト処理を行う部分です。

さて、engine.handleHTTPRequest()にセットされているcontextの出どころはというとpool.Get()となっています。

poolとはgoのsyncパッケージにあるモジュールです。poolについて非常によくまとめられていた記事から、poolとは何かを引用します。

一般的にITの文脈でいうところの「プール」と考えてOKです。プールには待機状態になっている何らかのリソースがあり、それを取得して処理が終わったらまた戻してやることで再利用できるようになります。
Golangのプール機能においては取得時にリソースが不足している場合は自動的に新規作成され、それが使用後にプールに戻されるので需要の逼迫度合いに応じてリソースが増えていきます。
このような特性から「調達コストが重いオブジェクトを使い回したい」「調達コストが重いので初期化時点である程度用意しておくが、不足した場合には随時追加して欲しい」という性質のものが使いどころになります(例えばメモリーアロケーションやデータベースとの接続等)。
出典: 体はドクペで出来ている sync.Poolの使い方 https://dokupe.hatenablog.com/entry/20190501/1556686106

さて、先ほどのコードからはginのengineが持っているpoolでcontextが使いまわされていることがわかります。

poolから取ってきたcontextが以前に使ったものであれば、ParamsやKey-Valueがそのままになってしまっていますから、当然リセットする必要があります。それがengine.handleHTTPRequest()に入る前のc.reset()の部分です。reset()は下記のようになっています。

func (c *Context) reset() {
	c.Writer = &c.writermem
	c.Params = c.Params[0:0]
	c.handlers = nil
	c.index = -1

	c.fullPath = ""
	c.Keys = nil
	c.Errors = c.Errors[0:0]
	c.Accepted = nil
	c.queryCache = nil
	c.formCache = nil
	*c.params = (*c.params)[0:0]
}

ParamsやKey-Valueが根こそぎ初期化されていることが分かります。

goroutineがcontextを使用するよりも前にcontextがresetされてしまいうる

さて、ここまで来ると話したいことが分かるかもしれませんが、リクエスト処理の中でcontextをコピーせずにgoroutineにわたすと、このc.reset()がgoroutineのcontext読み取りよりも前に実施される可能性があります。

もう一度、リクエスト処理とc.reset()のタイミングを確認します。

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

さて、リクエスト処理の中で起動されたgoroutineとは、つまりengine.handleHTTPRequest()の中で起動されたgoroutineであるということです。

goroutineの処理がいまどのあたりで何をしているかなんてことはengine.ServeHTTP()もengine.handleHTTPRequest()も知りませんから、そんなことおかまいなしにcontextはpoolにPut()され、タイミング次第ではまたGet()されてreset()されます。

goroutineがcontextを渡されてからその値を実際に読み込むまでに、もともとのリクエスト処理が一通り終わってしまうような時間関係が成立する時、contextはgoroutineが読みとる前にpoolに戻され、さらにそのあと異なるリクエストをginが受け取り、poolから、いまからまさにgoroutineが読み取ろうとしているcontextを取得してresetしてしまう。これが起きうることです。

goroutineがginのcontext.MustGet()を使っているなら、panic()してしまうこともありえます。というより目の前でしたことがあります👶

func (c *Context) MustGet(key string) interface{} {
	if value, exists := c.Get(key); exists {
		return value
	}
	panic("Key \"" + key + "\" does not exist")
}

この現象は一応、下記のコードをplaygroundで試すことで擬似的に再現できますので、ご興味のある方はやってみて下さい。

package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

type OriginalContext struct {
	Ctx   context.Context
	Value *int
}

func (cc *OriginalContext) resetValue() {
	cc.Value = nil
}

func main() {
	pool := &sync.Pool{
		New: func() interface{} {
			return &OriginalContext{Ctx: context.Background(), Value: nil}
		},
	}
	for i := 0; i < 10; i++ {
		cc := pool.Get().(*OriginalContext)
		fmt.Printf("poolから取得したcontext, %p, %+v, %v\n", cc, cc, i)
		cc.resetValue()
		fmt.Println("取得したcontextのValueをリセットした")
		if i == 2 {
			//色々な処理が繰り返される中、contextを使う処理がたまに走る
			processUsingContext(cc)
		} else {
			processNOTUsingContext(cc, i)
		}
		pool.Put(cc)
	}
}

func processUsingContext(cc *OriginalContext) {
	fmt.Println("~~~~context使う処理の本体開始~~~~")
	n := 1
	cc.Value = &n
	fmt.Println("processUsingContext: contextへの値の設定が完了")
	go func(_cc *OriginalContext) {
		fmt.Println("!!!!context使う処理のgoroutine開始!!!!")
		fmt.Printf("goroutine: 使用するcontext: %p, %+v, %v\n", _cc, _cc)
		fmt.Println("goroutine: 1秒待機開始")
		time.Sleep(time.Second * 1)
		fmt.Println("goroutine: 1秒待機終了")
		fmt.Println("goroutine: contextの値を読み込み開始")
		fmt.Println("goroutine: _cc.Value:", *_cc.Value)
		fmt.Println("goroutine: contextの値を読み込み終了")
		fmt.Println("!!!!context使う処理のgoroutine終了!!!!")
	}(cc)
	fmt.Println("~~~~context使う処理の本体終了~~~~")
}

func processNOTUsingContext(cc *OriginalContext, i int) {
	time.Sleep(time.Millisecond * 200)
	fmt.Println("----context使わない処理開始----")
	fmt.Println("----context使わない処理終了----")
}

他に知っていたら教えて下さい!

と、contextをコピーせずにgoroutineに渡すと危ないと話をしましたが、これ以外にも思い当たる理由がある方はぜひコメントにて教えて頂けますと幸いです👶

GitHubで編集を提案

Discussion

ログインするとコメントできます