Go Goroutines 與 Channels:實用併發指南

Backend2026-09-30TryQuickToolBox

你大概寫過一些 Go 程式碼,原本運作良好,直到某天突然出錯——也許是資料競爭(data race)在負載下讓服務崩潰,或是死鎖(deadlock)凍結了你的 API。併發是 Go 最大的賣點之一,卻也是許多開發者卡關的地方。Goroutines 與 channels 是建構併發的基礎,但要正確使用它們,必須理解其行為與常見陷阱。

本指南將帶你走過使用 goroutines 與 channels 的實用模式,附上程式碼範例與能立即套用的建議。

什麼是 Goroutines?

Goroutine 是由 Go 執行環境管理的輕量級執行緒。只要在函式呼叫前加上 go 就能建立一個:

go doSomething()

Goroutine 起始時堆疊很小(幾 KB),會視需要成長。這讓建立數千甚至數百萬個 goroutine 的成本極低。Go 排程器會將 goroutines 多工對應到作業系統執行緒上,因此你不需要直接管理執行緒。

但若沒有協調機制就隨意啟動 goroutine,只會帶來混亂。你需要方法來等待它們、傳遞資料或取消它們——這就是 channels 的用途。

Channels:Goroutines 之間的溝通

Channel 是帶有型別的通道,用於傳送與接收值。它們提供同步機制:傳送會阻塞直到有接收者準備好,接收會阻塞直到有值可用。這讓 channels 非常適合協調 goroutines。

使用 make(chan T) 建立 channel:

ch := make(chan int)
go func() {
    ch <- 42 // 傳送
}()
value := <-ch // 接收

預設情況下,channels 是無緩衝的,代表傳送與接收是同步進行的。有緩衝的 channels 允許在沒有接收者的情況下進行有限次數的傳送:

ch := make(chan int, 3) // 緩衝大小為 3
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 會阻塞直到有人接收

有緩衝的 channels 可以平滑突發流量,但無法免除協調的需求。

常見併發模式

1. 工作池(Worker Pool)

將任務分配給固定數量的工作者,以限制資源使用:

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    for i := 1; i <= 5; i++ {
        jobs <- i
    }
    close(jobs)

    for r := 1; r <= 5; r++ {
        <-results
    }
}

此模式非常適合需要控制平行度的 CPU 密集或 IO 密集任務。

2. 扇出、扇入(Fan-Out, Fan-In)

扇出是指多個 goroutines 從同一個 channel 讀取。扇入則是將多個 channels 的結果合併到一個:

func fanIn(chs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    wg.Add(len(chs))
    for _, ch := range chs {
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

當你有多個生產者,且想要單一結果串流時,就使用這個模式。

3. 使用 Select 進行多工

select 陳述式讓 goroutine 能同時等待多個 channel 操作:

select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case ch2 <- 42:
    fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
}

搭配 default 分支使用 select 可進行非阻塞操作,或搭配逾時以避免無限期等待。

陷阱與避免方法

最佳實務

比較:Channels 與 Mutexes

面向ChannelsMutexes
使用情境傳遞資料、協調保護共享狀態
複雜度簡單任務較高簡單任務較低
效能同步帶來的額外開銷短臨界區通常更快
風險死鎖、洩漏不小心會死鎖

常見問題

何時該使用有緩衝的 channel?

當你想解耦傳送者與接收者以處理突發流量,或當你知道確切的傳送次數並想避免阻塞時,可以使用有緩衝的 channel。然而,無緩衝的 channels 提供更強的同步保證,且通常更容易推理。

如何避免 goroutine 洩漏?

確保每個 goroutine 都有明確的退出路徑。使用 context.Context 發出取消訊號,在不再傳送值時關閉 channels,並避免在沒有逾時或 default 分支的情況下無限期阻塞於 channel 操作。

我可以什麼都用 channels 嗎?

不行。雖然 channels 很強大,但它們會增加開銷與複雜度。對於簡單的共享狀態,mutex 通常更直接且有效率。Go 的諺語說:「不要透過共享記憶體來溝通;要透過溝通來共享記憶體」,但這並不代表 channels 永遠是答案。

如果你正在除錯併發問題,或分析 Go 服務的日誌,Nginx 日誌分析器可以幫助你快速找出模式與錯誤。