撰文原因

最近工作遇到服務使用的新方法需要傳遞context到函數中執行, 結果導致本來要跑在背景任務沒完成(ex: 通知訊息/發送郵件)。

問題程式碼(範例)

事情是因為傳入的是 c.Request.Context(),然而這個gin所實作的Context在回應HTTP Response結束時會自動取消,
導致引用ctx的do函數在Context連帶著被取消後內部的函數也跟著取消。

程式碼範例如下:

package main

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

	"github.com/gin-gonic/gin"
)

func main() {
	// Create a Gin router with default middleware (logger and recovery)
	r := gin.Default()

	// Define a simple GET endpoint
	r.GET("/ping", func(c *gin.Context) {
        
		go doWork(c.Request.Context())
		time.Sleep(2 * time.Second)
		// Return JSON response
		c.JSON(http.StatusOK, gin.H{
			"message": "pong",
		})
	})
	r.Run("127.0.0.1:8080")
}

func doWork(ctx context.Context) {
	defer ctx.Done()
	elapsed := 0 * time.Millisecond
	for {
		select {
		case <-ctx.Done(): // Check if the context is done (cancelled or timed out)
			fmt.Printf("worker stopped err: %v\n", ctx.Err())
			return
		default:
			secs := 500 * time.Millisecond
			fmt.Printf("working... %v passed\n", elapsed)
			time.Sleep(secs)
			elapsed += secs
		}
		if elapsed >= 5*time.Second {
			fmt.Println("worker completed")
			break
		}
	}
}

實際跑起來會發現doWork只是每0.5秒印出一段訊息,原本預期最多跑5秒就會結束, 然而實際上2秒就會因為gin response被標記為完成自動結束, 這邊只是粗略模擬尊重context機制的函式庫的行為(qmgo…), 當select捕捉到<-ctx.Done()會停止或取消後續的行為。

所以內部函數的ctx.Err() 回傳 context canceled這個error也是合理的, 畢竟parent context都取消

阿…講那麼多,要如何避免呢??

解法

1. 暫時先傳 context.TODO()

當你不知道該傳什麼context時,可以使用 context.TODO() 來表示這是一個尚未決定的context。 如果確定這是個背景任務,可以使用 context.Background()

go doWork(context.TODO())

2. 使用 context.WithoutCancel

當你需要在context中的相同資料但不受parent context的取消影響, 可以使用 context.WithoutCancel 來創建一個新的context。

ctx := context.WithoutCancel(c.Request.Context())
go doWork(ctx)

3. 不要用go關鍵字

如果其實可以做完事情再回應,那就等事情做完再回應。

doWork(c.Request.Context())

結語

在使用Web框架時,正確地處理context非常重要,特別是在處理背景任務時。 透過上述3種方法,我們可以有效地避免因為context被取消而導致的問題。

應該還有更多解,但我懶得學了😆

參考資料