本文 AI 產出,尚未審核

Golang - 通道與同步

主題:Context 包的使用(取消與超時)


簡介

Go 的併發程式設計中,context 套件是管理 工作生命週期取消超時 的核心工具。無論是 HTTP 伺服器、資料庫查詢、或是長時間跑的背景工作,都需要一套統一的機制,讓呼叫端能在適當的時機終止子任務,避免資源泄漏與不可預期的阻塞。

context 的設計理念是 傳遞(propagation)而非 全域(global)。透過將 Context 物件作為函式的第一個參數,我們可以在呼叫鏈上層層傳遞取消訊號、截止時間或其他請求範圍的值,讓程式碼保持 可測試可組合易維護

本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,完整介紹如何在 Go 中正確使用 context 來達成 取消超時 的需求,適合剛踏入併發領域的初學者,也能為中階開發者提供實務參考。


核心概念

1. Context 的三大功能

功能 說明 常見使用情境
取消 (Cancellation) 透過 CancelFunc 讓所有子任務收到 Done() 信號,主動停止執行。 HTTP 請求被客戶端關閉、使用者點擊「取消」按鈕。
超時 (Timeout / Deadline) Context 設定一個絕對的截止時間或相對的超時時長,時間到自動觸發取消。 資料庫查詢、遠端 API 呼叫、長時間批次處理。
值傳遞 (Value) 允許在 Context 中儲存鍵值對,供子程式讀取。 追蹤請求 ID、使用者認證資訊、日誌欄位。

注意Context 不應被用來傳遞可變的業務資料,僅限於請求範圍的元資訊。

2. 建立與傳遞 Context

// 建立根 Context(背景 Context)
ctx := context.Background()

// 以 WithCancel 包裝,取得取消函式
ctx, cancel := context.WithCancel(ctx)

// 必須在適當時機呼叫 cancel()
defer cancel()

在大多數情況下,我們會在 最外層(例如 HTTP handler、main 函式)建立 Context,然後把它 作為第一個參數 傳遞給所有需要的函式或 goroutine。

3. 監聽取消訊號

select {
case <-ctx.Done():
    // 收到取消或超時訊號
    err := ctx.Err() // context.Canceled 或 context.DeadlineExceeded
    log.Printf("任務被終止: %v", err)
    return
default:
    // 正常執行的程式碼
}

Done() 會回傳一個只讀的 channel,當 Context 被取消或超時時會被關閉。永遠selectfor 迴圈配合 Done() 來檢查,避免死等。


程式碼範例

以下提供 五個 常見且實用的範例,說明 Context 在不同情境下的使用方式。每段程式碼皆附上說明註解,方便讀者快速掌握要點。

範例 1️⃣ 基本取消(WithCancel)

package main

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

func main() {
	// 建立可取消的 Context
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // 確保資源釋放

	// 啟動一個長時間執行的 goroutine
	go func() {
		for i := 0; i < 5; i++ {
			// 每秒輸出一次訊息
			fmt.Println("工作中:", i)
			time.Sleep(time.Second)
		}
		fmt.Println("工作完成")
	}()

	// 2 秒後手動觸發取消
	time.AfterFunc(2*time.Second, func() {
		fmt.Println("主程式呼叫 cancel()")
		cancel()
	})

	// 監聽取消訊號
	<-ctx.Done()
	fmt.Println("主程式結束:", ctx.Err())
}

重點

  • cancel() 會關閉 ctx.Done(),所有監聽此 channel 的 goroutine 都會立即收到訊號。
  • 使用 defer cancel() 可以保證即使程式提前結束也會釋放資源。

範例 2️⃣ 超時控制(WithTimeout)

package main

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

func main() {
	// 設定 3 秒的超時
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// 模擬一個需要 5 秒才能完成的工作
	done := make(chan struct{})
	go func() {
		time.Sleep(5 * time.Second) // 工作過長
		close(done)
	}()

	select {
	case <-done:
		fmt.Println("工作順利完成")
	case <-ctx.Done():
		// 超時或被取消
		fmt.Println("工作被終止:", ctx.Err()) // context.DeadlineExceeded
	}
}

說明

  • WithTimeout 會自動在指定時間後呼叫 cancel(),等同於 WithDeadline(time.Now().Add(d))
  • ctx.Err() 能分辨是 超時 (DeadlineExceeded) 還是 手動取消 (Canceled)。

範例 3️⃣ 期限控制(WithDeadline)

package main

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

func main() {
	// 設定絕對截止時間(現在起 4 秒後)
	deadline := time.Now().Add(4 * time.Second)
	ctx, cancel := context.WithDeadline(context.Background(), deadline)
	defer cancel()

	// 執行一段可能被截止的工作
	for i := 0; ; i++ {
		select {
		case <-ctx.Done():
			fmt.Println("截止時間到:", ctx.Err()) // DeadlineExceeded
			return
		default:
			fmt.Printf("處理第 %d 筆資料\n", i)
			time.Sleep(1 * time.Second)
		}
	}
}

要點

  • WithDeadline 允許使用 絕對時間,適合需要根據外部事件(如排程)設定截止點的情況。
  • 若 deadline 已經過去,返回的 Context 會立即為 已取消 狀態。

範例 4️⃣ 在 HTTP 伺服器中使用 Context(自動取消)

package main

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

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

	// 模擬一個需要 10 秒才能完成的外部呼叫
	resultCh := make(chan string, 1)
	go func() {
		// 假設這裡是長時間的 I/O
		time.Sleep(10 * time.Second)
		resultCh <- "完成結果"
	}()

	select {
	case <-ctx.Done():
		// 客戶端已關閉連線或請求逾時
		http.Error(w, "請求已取消", http.StatusRequestTimeout)
		fmt.Println("HTTP 請求被取消:", ctx.Err())
	case res := <-resultCh:
		fmt.Fprintln(w, res)
	}
}

func main() {
	http.HandleFunc("/", handler)
	fmt.Println("伺服器啟動於 :8080")
	http.ListenAndServe(":8080", nil)
}

說明

  • http.Request 內建的 Context 會在 客戶端斷線伺服器關閉設定的 Timeout 時自動取消。
  • 在長時間 I/O 時,務必 監聽 ctx.Done(),避免不必要的資源占用。

範例 5️⃣ 透過 Context 傳遞請求 ID(Value)

package main

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

type ctxKey string

const requestIDKey ctxKey = "requestID"

func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 產生唯一的 request ID(簡化示範)
		reqID := fmt.Sprintf("%d", time.Now().UnixNano())
		// 把 request ID 放入 Context
		ctx := context.WithValue(r.Context(), requestIDKey, reqID)
		// 把新 Context 傳給下一個 handler
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func handler(w http.ResponseWriter, r *http.Request) {
	// 從 Context 取出 request ID
	if v := r.Context().Value(requestIDKey); v != nil {
		fmt.Fprintf(w, "Request ID: %s\n", v.(string))
	}
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", handler)

	// 加入 middleware
	http.ListenAndServe(":8080", loggingMiddleware(mux))
}

重點

  • ContextValue 功能適合傳遞 只讀跨層級 的元資料,如 trace ID使用者資訊
  • 使用自訂型別作為 key(如 ctxKey)可以避免 key 衝突。

常見陷阱與最佳實踐

陷阱 可能的後果 解決方案 / 最佳實踐
忘記呼叫 cancel() 產生 goroutine 泄漏、資源無法釋放 在建立 Context 後立即使用 defer cancel(),即使不需要手動取消也能保證釋放。
select 中遺漏 default 可能造成 阻塞,導致程式無法回應取消訊號 若需要非阻塞檢查,務必加入 default 分支;若要阻塞等待結果,使用 select 搭配 ctx.Done()
將大量業務資料放入 Context 增加記憶體使用、破壞 Context 的語意 僅放入 輕量、只讀 的元資料(如 ID、token),業務資料應透過參數傳遞。
使用 time.Sleep 取代 select 監聽 ctx.Done() 無法即時感知取消,造成不必要的等待 永遠使用 select { case <-ctx.Done(): … } 來等待或中斷長時間操作。
在多個 goroutine 中共用同一 CancelFunc 可能在不適當的時機提前取消 為每個子任務建立 獨立ContextWithCancelWithTimeout),或使用 父子層級Context 來控制。

其他最佳實踐

  1. Context 必須是第一個參數
    func DoSomething(ctx context.Context, arg1 string) error { … }
    
  2. 永遠不要在 Context 之外自行建立 channel 來傳遞取消訊號,除非有特殊需求。
  3. 對於 I/O 操作(如 DB、HTTP),優先使用支援 Context 的 API(如 http.NewRequestWithContextsql.DB.QueryContext)。
  4. 在測試中使用 context.WithCancelcontext.WithTimeout,可以模擬取消與超時情境,提高測試覆蓋率。
  5. 保持 Context 輕量:避免在 Context 中儲存大型 slice、map 或指向可變結構的指標。

實際應用場景

場景 為何需要 Context 典型實作方式
HTTP 伺服器 客戶端可能隨時斷線,需即時釋放資源 使用 r.Context()http.ServerReadTimeout/WriteTimeout
微服務間 RPC 呼叫鏈路可能因上游服務失效而必須取消下游請求 在每個 RPC 客戶端傳入 Context,如 grpc.CallOption 中的 grpc.WaitForReady
資料庫批次匯入 大量寫入可能因外部條件(如維護)被迫中止 db.ExecContext(ctx, ...) 搭配 WithTimeout
背景工作(Worker) 系統關機或部署需要平滑停止所有工作 主程式建立根 Context,在 os.Signal 捕獲後呼叫 cancel()
定時任務 任務執行時間不可超過預設上限,避免卡住排程 context.WithDeadline 設定明確截止時間

總結

  • context 是 Go 併發程式設計的 基礎建設,提供 取消超時值傳遞 三大功能。
  • 正確的使用模式是:在最外層建立 Context(或使用 r.Context()),把它作為第一個參數 傳遞下去,並在需要的地方 監聽 Done() 以及 檢查 Err()
  • 常見的陷阱包括忘記 cancel()、在長時間阻塞時未監聽 ctx.Done(),以及濫用 Context 儲存大量業務資料。透過 defer cancel()select + Done()、以及 輕量的 Value,即可避免這些問題。
  • 在實務上,無論是 HTTP 服務、微服務 RPC、資料庫操作或背景工作,都應該把 Context 融入流程,讓系統在面對斷線、超時或關機時能 快速、可預測 地回收資源。

掌握 Context 的使用技巧,將讓你的 Go 程式在 可靠性可維護性 以及 效能 上都有顯著提升。祝你寫程式愉快,開發出更健壯的併發應用!