⏱️

go-ginのタイムアウト後も処理は続いている

2023/09/19に公開

Go言語の Gin Web Framework で timeout middleware gin-contrib/timeout を使った場合、タイムアウト時にはどうやって処理を止めてタイムアウト用のレスポンスを返しているのか、という事について書きます。

結論から言うと、このmiddlewareを使うとタイムアウトのレスポンスは返してくれますが、処理を止めてはくれません。処理を止めたければ意識的に自分で止める必要があります。

これはGin Web Frameworkに限らず、echoなどのフレームワークでも似た実装になっていることが多いようです。

gin-contrib/timeout の使い方

まずは簡単な例を使って gin-contrib/timeout の使い方を改めて確認します。

このmiddlewareを使わない場合、次の例だとSleepしている間ひたすら待って100秒後にレスポンスを返すことになります。

func main() {
	r := gin.New()
	r.GET("/", func(c *gin.Context) {
		fmt.Println("start")
		time.Sleep(100 * time.Second) // 何か重い処理
		fmt.Println("end")
	})
	r.Run("0.0.0.0:8084")
}

これに gin-contrib/timeout を次のように設定すると、2秒待ってもレスポンスが返らなかったタイムアウトのレスポンスを返してくれるようになります。他にも書き方はありますが、基本的な使い方はこんな感じです。

func main() {
	r := gin.New()
	// ↓↓↓追加↓↓↓
	r.Use(mytimeout.New(
		mytimeout.WithTimeout(2*time.Second),
		mytimeout.WithHandler(func(c *gin.Context) {
			c.Next()
		}),
		mytimeout.WithResponse(func(c *gin.Context) {
			c.String(http.StatusRequestTimeout, "timeout")
		}),
	))
	// ↑↑↑追加↑↑↑
	r.GET("/", func(c *gin.Context) {
		fmt.Println("start")
		time.Sleep(100 * time.Second) // 何か重い処理
		fmt.Println("end")
	})
	r.Run("0.0.0.0:8084")
}

基本的なおさらいができたので、この後からもう少し実践的な内容でタイムアウトした時、しなかった時を確認していこうと思います。

タイムアウトしなかった場合

では timeout middleware を使った上で、タイムアウトしなかった場合はどうなるでしょうか。
今度は、DBに2回SQLを発行するハンドラを登録した場合で考えてみます。

タイムアウトが発生せず正常終了するパターンでは、このような処理フローになります(簡単のため図は若干簡略化しています)。
簡単に説明すると、 TimeoutMiddleware が goroutine を作って、その中で HandlerFunc を実行してくれるような作りになっています。

タイムアウトした場合

ここで例えば1本目のSQLが長引き一定時間が経過した場合、TimeoutMiddleware は HandlerFunc からの結果を待たずに、タイムアウト用のハンドラを実行して408を応答します。

またちょっと意外ですが、特に対策を入れていない場合、実行中のハンドラでは最後まで処理が進みSQLが2回発行されてしまいます。すでに1本目のSQLの時点でタイムアウトの応答をしているにも関わらず、追加で2本目のSQLを発行するのは無駄な感じがしますね。

このように、せっかくタイムアウトmiddlewareを使って処理を中断したつもりでいても、実は処理は中断されていないという事が起こりえます。

タイムアウトした時に処理を中断させたい

このままでは無駄にリソースを消費してしまうので、Handlerの中で既にタイムアウトが発生していないかを確認するように実装を書き換えます(GORMを使っている前提です)。

このように WithContext() に context.Context を渡すだけです。

g := gin.New()
g.GET("/", func(ctx *gin.Context) {
    err = db.WithContext(ctx.Request.Context()).First(&user).Error
    if err != nil {
        fmt.Printf("gorm error: %#v\n", err) // gorm error: &errors.errorString{s:"context canceled"}
    }
    // ... 略 ...
))

こうすることで、GORMの中でContextが既にタイムアウトしていないかをチェックしてくれて、タイムアウトしていればGORMがerrorを返してくれます。
※正確にいうとContextが閉じられていないかをチェックしてくれている

シーケンス図にすると次のようになります。

上記ではGORMを使っていた場合の実装でしたが、それ以外でも次のように書くことで、タイムアウトしているかを判断してHandlerFuncの処理を中断させることができます。

func Handler(ctx *gin.Context) {
    // 補足: ContextWithFallback = true に設定したなら ctx.Err() でも良い
    if ctx.Request.Context().Err() != nil {
        // 既にtimeoutしていたらここを通る
        ctx.Abort()
        return
    }
    // timeoutしていなければここを通る
}

以上です。
gin-contrib/timeout を使った場合でも、タイムアウトを意識した実装にしておくと良さそうです。

Discussion