Go Context 完全指南

Go Context 套件完整指南 — Background/TODO、WithCancel/Timeout/Deadline/Value、傳遞慣例、取消傳播、最佳實踐


目錄


什麼是 Context?

context.Context 是 Go 標準庫的型別,用來在多 goroutine 之間傳遞取消訊號、deadline、與請求範圍 metadata

為什麼需要 Context?

場景:HTTP server 收到一個 request,會啟動多個 goroutine(查 DB、呼外部 API、處理快取)。如果 client 在中途斷線,這些 goroutine 該怎麼知道要停?

沒有 Context 的方案

  • 自己定義 cancel channel 傳給每個 goroutine
  • 各層自己實作 timeout
  • 沒有統一機制 → 容易漏

有 Context 的方案

  • 標準介面,所有 goroutine 統一聽 ctx.Done()
  • 取消會自動傳播到所有衍生 context
  • timeout / deadline 內建

核心特點

  • 🎯 取消訊號傳播:父 context 取消 → 所有子 context 自動取消
  • Deadline / Timeout:到時間自動取消
  • 🔧 請求範圍 Value:傳遞 trace ID、user info 等
  • 📦 不可變:每個衍生函式都回傳 context
  • 🚀 慣例:函式第一個參數 ctx context.Context

Context 介面與基本概念

介面定義

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

每個方法的角色:

方法 用途
Done() 回傳 channel,被關閉 = context 已被取消
Err() 如果 Done 關閉了,回傳取消原因
Deadline() 取得 deadline(如果有)
Value(key) 取出之前 WithValue 存的資料

「Done」channel 的用法

select {
case <-ctx.Done():
    // context 被取消(外部 cancel、超時、deadline 到)
    return ctx.Err()
case result := <-resultChan:
    // 拿到結果
    return result, nil
}

ctx.Done() 回傳的 channel 在以下情況會被關閉

  • 父 context 取消
  • 自己被 cancel() 呼叫
  • timeout / deadline 到

Err() 取消原因

ctx.Err() == nil                       // 還沒取消
ctx.Err() == context.Canceled          // 手動 cancel
ctx.Err() == context.DeadlineExceeded  // 超時

Background 與 TODO

兩個「根 context」:

context.Background() // 程式進入點用
context.TODO()       // 暫時不知道用什麼時用

Background

  • 程式最頂層用(main 函式、初始化、測試)
  • 永不取消、永無 deadline、無 value
  • 所有其他 context 都是從 Background 衍生而來
func main() {
    ctx := context.Background()

    // 從 main 衍生有 timeout 的 context
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    runServer(ctx)
}

TODO

  • 程式設計階段,「還沒決定該傳什麼 context」時用
  • 行為跟 Background 完全相同
  • 給 linter / reviewer 看的訊號:「這裡之後該想清楚」
func legacyCode() {
    // TODO: 之後該從上層接 context
    ctx := context.TODO()
    callAPI(ctx)
}

何時用哪個

情境
main、init、頂層 Background
測試(沒有自然上游 ctx) Background
重構中,先放著 TODO

WithCancel

context.WithCancel(parent) 回傳新 context + 一個 cancel function。呼叫 cancel 會關閉 Done channel。

基本用法

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 永遠記得 defer cancel

go worker(ctx)

time.Sleep(time.Second)
cancel() // 主動取消

Worker 端聽訊號

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("worker 收到取消訊號,原因:", ctx.Err())
            return
        default:
            // 做事
            time.Sleep(100 * time.Millisecond)
        }
    }
}

為什麼一定要 cancel?

即使 context 因為 timeout 自動取消,也必須呼叫 cancel

ctx, cancel := context.WithCancel(parent)
defer cancel() // 必須

// 否則 context 內部資源不會釋放,造成 leak

go vet 會幫你檢查這點:

$ go vet ./...
the cancel function returned by context.WithCancel should be called, not discarded

WithTimeout 與 WithDeadline

兩個都會自動取消,差別只是表達方式。

WithTimeout

// 從現在起 5 秒後自動取消
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

select {
case <-time.After(10 * time.Second):
    fmt.Println("不會印(會先被取消)")
case <-ctx.Done():
    fmt.Println("取消:", ctx.Err()) // context deadline exceeded
}

WithDeadline

// 指定絕對時間
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

WithTimeout(parent, t) 等於 WithDeadline(parent, time.Now().Add(t))

Deadline 繼承父 context

子 context 的 deadline 不能比父晚:

parent, cancelP := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelP()

// 想設 10 秒?實際還是 5 秒(取父子最小值)
child, cancelC := context.WithTimeout(parent, 10*time.Second)
defer cancelC()

d, _ := child.Deadline()
fmt.Println("實際 deadline:", time.Until(d)) // ~5s

WithValue 傳遞請求範圍資料

傳遞「跟整個 request 相關」的資料,例如 trace ID、user info。

基本用法

type ctxKey string

const userIDKey ctxKey = "userID"

func setUserID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, userIDKey, id)
}

func getUserID(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(userIDKey).(string)
    return id, ok
}

// 使用
ctx := context.Background()
ctx = setUserID(ctx, "user-123")

if id, ok := getUserID(ctx); ok {
    fmt.Println("UserID:", id)
}

Key 必須是自訂型別

// ❌ 用 string 當 key 容易跟其他套件衝突
ctx = context.WithValue(ctx, "userID", "123")

// ✅ 用自訂型別避免衝突
type ctxKey int
const userIDKey ctxKey = iota
ctx = context.WithValue(ctx, userIDKey, "123")

規則:key 用未匯出的自訂型別,這樣其他套件不可能放同一個 key。

包裝 getter / setter

package mycontext

type ctxKey int
const userKey ctxKey = iota

func WithUser(ctx context.Context, u *User) context.Context {
    return context.WithValue(ctx, userKey, u)
}

func UserFrom(ctx context.Context) (*User, bool) {
    u, ok := ctx.Value(userKey).(*User)
    return u, ok
}

呼叫端用:

ctx = mycontext.WithUser(ctx, u)
u, _ := mycontext.UserFrom(ctx)

WithValue 該存什麼?該不該存?

該存

  • ✅ Request ID / Trace ID
  • ✅ User ID / Session token
  • ✅ Logger(with request context)
  • ✅ Locale / timezone

不該存

  • ❌ DB 連線
  • ❌ 設定參數(用 function argument)
  • ❌ 函式行為控制 flag(function argument 更明確)

判斷:「這個資料是不是請求範圍、只在跨函式呼叫鏈傳遞用?」如果是,OK。否則用 function argument。


取消傳播機制

Context 的最大價值:取消會自動沿著樹往下傳

父取消 → 子全部取消

parent, cancelP := context.WithCancel(context.Background())
child1, _ := context.WithCancel(parent)
child2, _ := context.WithCancel(parent)
grandchild, _ := context.WithCancel(child1)

cancelP() // 取消 parent

// child1.Done()、child2.Done()、grandchild.Done() 都會關閉

圖示

        Background
        WithCancel ← cancelP() 在這邊
        parent ctx
       /    │    \
    child1  child2
   grandchild

cancelP() → 整棵子樹都收到 Done 訊號

子取消不影響父

parent, _ := context.WithCancel(context.Background())
child, cancelC := context.WithCancel(parent)

cancelC() // 只取消 child

// parent.Done() 還沒關
// child.Done() 已關

Worker pool 場景

func processItems(ctx context.Context, items []string) error {
    var wg sync.WaitGroup
    errCh := make(chan error, len(items))

    // 派 n 個 worker
    for _, item := range items {
        wg.Add(1)
        go func(it string) {
            defer wg.Done()
            select {
            case <-ctx.Done():
                errCh <- ctx.Err()
                return
            default:
            }

            if err := processOne(ctx, it); err != nil {
                errCh <- err
            }
        }(item)
    }

    wg.Wait()
    close(errCh)

    for err := range errCh {
        if err != nil {
            return err // 一個錯就 return(其他 worker 還跑,可選擇 cancel)
        }
    }
    return nil
}

更好的做法用 errgroup

import "golang.org/x/sync/errgroup"

func processItems(ctx context.Context, items []string) error {
    g, gctx := errgroup.WithContext(ctx)
    for _, item := range items {
        item := item
        g.Go(func() error {
            return processOne(gctx, item)
        })
    }
    return g.Wait() // 任一 goroutine 出錯 → cancel gctx → 其他全停
}

實戰範例

範例 1:HTTP server 全鏈路 context

package main

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

func main() {
    http.HandleFunc("/user/", handleUser)
    http.ListenAndServe(":8080", nil)
}

func handleUser(w http.ResponseWriter, r *http.Request) {
    // r.Context() 在 client 斷線時會自動取消
    ctx := r.Context()

    // 加 5 秒上限
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    id := r.URL.Path[len("/user/"):]
    user, err := getUser(ctx, id)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            http.Error(w, "timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, "%+v", user)
}

func getUser(ctx context.Context, id string) (User, error) {
    db := getDB()
    // database/sql 支援 context,會在取消時取消查詢
    row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id)

    var u User
    if err := row.Scan(&u.Name); err != nil {
        return User{}, err
    }
    return u, nil
}

type User struct {
    Name string
}

func getDB() *sql.DB { return nil } // 略

範例 2:取消可能慢的計算

func slowComputation(ctx context.Context) (int, error) {
    resultCh := make(chan int, 1)

    go func() {
        // 慢慢算
        time.Sleep(5 * time.Second)
        resultCh <- 42
    }()

    select {
    case r := <-resultCh:
        return r, nil
    case <-ctx.Done():
        return 0, ctx.Err()
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    r, err := slowComputation(ctx)
    if err != nil {
        fmt.Println("失敗:", err) // context deadline exceeded
        return
    }
    fmt.Println(r)
}

⚠️ 注意:上面的 goroutine 還會繼續跑(無法強制終止),只是不再 wait 結果。要真的避免浪費需在計算內檢查 ctx.Done()

範例 3:定期檢查取消的長運算

func chunkedWork(ctx context.Context, items []Item) error {
    for i, item := range items {
        // 每處理完一個檢查一次
        select {
        case <-ctx.Done():
            return fmt.Errorf("處理到第 %d 個被取消: %w", i, ctx.Err())
        default:
        }

        if err := process(item); err != nil {
            return err
        }
    }
    return nil
}

範例 4:自訂 logger 注入 context

package mylog

import "context"

type loggerCtxKey struct{}

type Logger struct {
    RequestID string
}

func (l *Logger) Info(msg string) {
    fmt.Printf("[%s] %s\n", l.RequestID, msg)
}

func WithLogger(ctx context.Context, l *Logger) context.Context {
    return context.WithValue(ctx, loggerCtxKey{}, l)
}

func From(ctx context.Context) *Logger {
    if l, ok := ctx.Value(loggerCtxKey{}).(*Logger); ok {
        return l
    }
    return &Logger{RequestID: "unknown"}
}

使用:

ctx := mylog.WithLogger(r.Context(), &mylog.Logger{RequestID: uuid.New().String()})
processRequest(ctx)

// 子函式
func processRequest(ctx context.Context) {
    log := mylog.From(ctx)
    log.Info("processing")
}

最佳實踐

1. 第一個參數放 ctx,永遠

// ✅
func GetUser(ctx context.Context, id string) (*User, error)
func ProcessBatch(ctx context.Context, items []Item) error

// ❌ 不要藏在結構或第二個參數
func GetUser(id string, ctx context.Context) (*User, error)
func (s *Service) ContextField *Context // 別把 ctx 存進 struct

2. 永遠 defer cancel

// ✅
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

// ❌ 漏 cancel → 資源 leak
ctx, _ := context.WithTimeout(parent, 5*time.Second)

3. 不要把 ctx 存進 struct

// ❌ struct 不該存 ctx
type Worker struct {
    ctx context.Context // 反模式
}

// ✅ 每個 method 接 ctx
type Worker struct {}
func (w *Worker) Do(ctx context.Context) error { ... }

理由:ctx 是請求範圍的,存進長壽 struct 會跟著 struct 活很久,違反設計初衷。

4. Value key 用未匯出型別

// ❌ 容易衝突
ctx = context.WithValue(ctx, "user", u)

// ✅ 套件私有 key
type ctxKey int
const userKey ctxKey = iota
ctx = context.WithValue(ctx, userKey, u)

5. 寫支援 context 的函式時,立即檢查

func doWork(ctx context.Context) error {
    // 一進來就檢查
    if err := ctx.Err(); err != nil {
        return err
    }

    // ...慢操作
}

6. nil ctx 永遠不要傳

// ❌
doWork(nil)

// ✅ 沒上游 ctx 就用 Background
doWork(context.Background())

go vet 會檢查這點。

7. 不要用 context.Value 當 function argument

// ❌ 用 ctx 偷渡參數,難維護
func handle(ctx context.Context) {
    timeout := ctx.Value("timeout").(time.Duration)
    // ...
}

// ✅ 直接當參數
func handle(ctx context.Context, timeout time.Duration) {
    // ...
}

只用 ctx.Value 傳「跟整個 request lifecycle 相關」的資料(trace ID、user info)。


常見問題

問題 1:取消 ctx 後子函式還在跑

原因:子函式沒檢查 ctx.Done()

解決

  • 慢迴圈內每 N 次檢查一次
  • <-ctx.Done() 配合 select
for i := range items {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }
    process(items[i])
}

問題 2:goroutine leak

症狀:函式 return 了,但內部 goroutine 還在跑

// ❌ 沒 cancel
func leaky() {
    ctx, _ := context.WithTimeout(context.Background(), time.Hour)
    go func() {
        <-ctx.Done()
        fmt.Println("結束")
    }()
    // 函式 return 但 goroutine 等 1 小時才會跑完
}

解決:永遠 defer cancel

問題 3:WithValue 偷渡函式參數

反模式

ctx = context.WithValue(ctx, "limit", 100)
ctx = context.WithValue(ctx, "offset", 0)
listUsers(ctx) // 內部讀 ctx 拿 limit/offset

解決:直接當參數

listUsers(ctx, 100, 0)

問題 4:deadline 沒生效

檢查

  1. 函式內部有沒有用到 ctx?(database/sql、net/http 等大多支援)
  2. 是不是建了新 context 沒從父繼承?
// ❌ 沒繼承父
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// 應該從父 context 來
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)

問題 5:ctx 跨 goroutine 但取消傳不過去

// ❌ 開新 goroutine 沒傳 ctx
go func() {
    doSlowThing() // 沒接 ctx,外部取消不到
}()

// ✅
go func() {
    doSlowThing(ctx) // 傳進去
}()

問題 6:context.Canceled vs context.DeadlineExceeded

兩個都讓 Done() 關閉,但 Err() 不同:

ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()

<-ctx.Done()

switch ctx.Err() {
case context.Canceled:
    fmt.Println("手動 cancel")
case context.DeadlineExceeded:
    fmt.Println("超時")
}

對 HTTP 場景常用:

  • Canceled → client 斷線(回 499 / 不回應)
  • DeadlineExceeded → 超時(回 504 Gateway Timeout)

總結

核心要點

Context = 取消訊號 + Deadline + Value 三合一介面
慣例:函式第一個參數 ctx context.Context
取消會自動沿樹傳播
永遠 defer cancel(即使知道會超時自動取消)

設計原則

  • ✅ 第一個參數 ctx,不存進 struct
  • ✅ 永遠 defer cancel()
  • ✅ WithValue 只放請求範圍 metadata
  • ✅ Key 用未匯出自訂型別
  • ✅ 長運算內定期檢查 ctx.Done()

速查表

// 根 context
context.Background()  // main、init、test
context.TODO()        // 暫不知道

// 衍生
ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
ctx = context.WithValue(parent, key, val)

// 檢查
<-ctx.Done()       // 取消訊號
ctx.Err()          // 取消原因
ctx.Deadline()     // 有沒有 deadline
v := ctx.Value(k)  // 取值

// 永遠
defer cancel()

取消原因對照

ctx.Err() 意義 HTTP 對應
nil 還沒取消
context.Canceled 手動 cancel / client 斷線 499 / 不回應
context.DeadlineExceeded 超時 504

相關閱讀


參考資源


建立日期:2026-05-16 最後更新:2026-05-16

🔗相關文章