Go context goroutine termination, timeouts, and passing values

使用 Go Context 處理 goroutine 終止、超時、傳遞請求相關的值

前言

Context 是 Go 1.7 添加於標準函式庫的功能。常在存取資料庫或其他服務時會遇到,初步看起來是用於「傳遞取消信號」用途的語法,用於處理 goroutine:

  • 背負期限(deadline)
  • 取消信號(cancellation signal)
  • 傳遞請求相關的值(request-scoped values)

Context 是啥?

舉例當一個請求會衍生出多個 goroutine、或會呼叫多層函式時,常會遇到幾個問題:

  • 上層請求已經結束了,但底下 goroutine 還在跑
  • 某個操作卡住,希望超時後能全部中斷
  • 想在整條呼叫鏈中攜帶 request id 之類的資訊

Go context 包了 channel 在 goroutine 間傳遞,定義其存在的理由(某條件或期限達成),而這個理由應該與 context 相關聯。

// Context 背負期限(deadline)、取消信號(cancellation signal)、與請求相關的值(request-scoped values)
// 它的方法可以被多個 goroutine 同時安全地使用。
type Context interface {
// Done 會回傳一個 channel,
// 當此 Context 被取消或逾時(timeout)時,該 channel 會被關閉。
Done() <-chan struct{}
// Err 會在 Done channel 關閉之後,
// 回傳此 Context 被取消的原因。
Err() error
// Deadline 會回傳此 Context 預計被取消的時間(如果有設定的話)。
// ok 表示是否存在 deadline。
Deadline() (deadline time.Time, ok bool)
// Value 會回傳與指定 key 關聯的值;
// 如果沒有對應的值,則回傳 nil。
Value(key interface{}) interface{}
}

Done() <-chan struct{} 用例

結束時 Done() channel 才會被關閉。未關閉前會返回 nil

  1. Context 被取消(cancel)
  2. 超時(timeout)
  3. parent Context 結束
select {
case <-ctx.Done():
// 收到取消或超時訊號
return ctx.Err()
case result := <-workChan:
return result
}

Err() error 用例

當 Context 結束後得知原因。未關閉前會返回 nil

<-ctx.Done()
log.Println(ctx.Err())

Deadline() (time.Time, bool) 用例

查 Context 是否有設定截止時間。

  • ok == false:沒有 deadline
  • ok == true:deadline 是什麼時候

Value(key interface{}) interface{} 用例

Value 允許你在 Context 裡存放「請求範圍內」的資料,例如:

// 上游創建 context
ctx = context.WithValue(ctx, "request_id", "abc-123")
// 下游讀取 context
reqID := ctx.Value("request_id")

與 Context 搭配的方法

Background

所有 Context 的根節點,通常用於主函式、初始化或測試中

返回一個非 nil 的空 Context,它:

  • 永遠不會被取消
  • 沒有截止時間
  • 沒有攜帶任何值
// ctx1 是根節點(Background)
ctx1 := context.Background()
// ctx2 是 ctx1 的子節點
ctx2 := context.WithValue(ctx1, "key", "value") // 仍然不可取消(只是帶了值)
// ctx3 是 ctx2 的子節點
ctx3, cancel := context.WithTimeout(ctx2, 5*time.Second) // 可以取消(有超時)
defer cancel()
context.Background() (永遠不會被取消)
└─> WithValue(...) (繼承父節點,仍不可取消,但帶了值)
└─> WithTimeout(...) (可以被取消!5秒後自動取消)
└─> WithCancel(...) (可以被取消!呼叫 cancel() 時取消)

TODO

當你不確定該用哪個 Context 時的佔位符
func someFunction() {
// 暫時不確定要用什麼 Context,先用 TODO
ctx := context.TODO()
anotherFunction(ctx)
}

WithCancel

當你希望「某個事件發生時,主動中止所有相關 goroutine」
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 很重要,避免資源洩漏
go func() {
time.Sleep(2 * time.Second)
cancel()
}()
<-ctx.Done()
fmt.Println(ctx.Err()) // context.Canceled

WithDeadline

當你希望「在指定的時間點之前完成,主動中止所有相關 goroutine」
// API 必須在特定時間前回應
deadline := time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
resp, err := client.DoWithContext(ctx, req)

WithTimeout

當你希望「某個期限過後,主動中止所有相關 goroutine」
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
select {
case <-time.After(5 * time.Second):
fmt.Println("finished work")
case <-ctx.Done():
fmt.Println(ctx.Err()) // context.DeadlineExceeded
}

WithValue

Context 數值應基於 request 範圍內的資料傳輸過程和 API 數據
// 上游創建 context
ctx := context.Background()
ctx = context.WithValue(ctx, "request_id", "abc-123")
// 下游讀取 context
reqID := ctx.Value("request_id")
// 創建私有型別避免 key 撞名
type ctxKeyRequestID struct{}
ctx = context.WithValue(ctx, ctxKeyRequestID{}, "abc-123")

總結

Go Context 是一種官方方式去管理 Go Routine 的生命週期基於 Channel

目前我只在與 DB 互動上使用到這項功能,或許之後會遇到更多情境幫得上忙。就是一種官方的方式管理 Go Routine 的生命週期。

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var todos []models.Todo
cursor, err := db.GetCollection().Find(ctx, bson.M{})

延伸閱讀