Go Goroutines 與 Channels:實用併發指南
你大概寫過一些 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 可進行非阻塞操作,或搭配逾時以避免無限期等待。
陷阱與避免方法
- Goroutine 洩漏:阻塞在 channel 傳送/接收上永遠不會被垃圾回收。務必確保 channels 被關閉,或使用 context 取消。
- 死鎖:channels 之間的循環依賴會導致死鎖。使用逾時,或設計時避免循環。
- 資料競爭:未同步就存取共享記憶體會導致競爭。使用 channels 或 mutex 保護共享狀態。
- 關閉 channels:只有傳送者應該關閉 channel。由接收者關閉可能導致 panic。此外,對已關閉的 channel 傳送也會 panic。
最佳實務
- 協調用 channels,狀態用 mutex:用 channels 在 goroutines 之間傳遞資料與訊號;用
sync.Mutex保護簡單的共享狀態。 - 使用 context 進行取消:傳遞
context.Context以實現優雅關閉與逾時。 - 限制併發量:使用工作池或號誌(semaphore)避免資源超載。
- 用競爭檢測器測試:執行
go test -race及早捕捉資料競爭。 - 保持簡單:不要過度設計。很多時候 mutex 比複雜的 channel 編排更簡單且更快。
比較:Channels 與 Mutexes
| 面向 | Channels | Mutexes |
|---|---|---|
| 使用情境 | 傳遞資料、協調 | 保護共享狀態 |
| 複雜度 | 簡單任務較高 | 簡單任務較低 |
| 效能 | 同步帶來的額外開銷 | 短臨界區通常更快 |
| 風險 | 死鎖、洩漏 | 不小心會死鎖 |
常見問題
何時該使用有緩衝的 channel?
當你想解耦傳送者與接收者以處理突發流量,或當你知道確切的傳送次數並想避免阻塞時,可以使用有緩衝的 channel。然而,無緩衝的 channels 提供更強的同步保證,且通常更容易推理。
如何避免 goroutine 洩漏?
確保每個 goroutine 都有明確的退出路徑。使用 context.Context 發出取消訊號,在不再傳送值時關閉 channels,並避免在沒有逾時或 default 分支的情況下無限期阻塞於 channel 操作。
我可以什麼都用 channels 嗎?
不行。雖然 channels 很強大,但它們會增加開銷與複雜度。對於簡單的共享狀態,mutex 通常更直接且有效率。Go 的諺語說:「不要透過共享記憶體來溝通;要透過溝通來共享記憶體」,但這並不代表 channels 永遠是答案。
如果你正在除錯併發問題,或分析 Go 服務的日誌,Nginx 日誌分析器可以幫助你快速找出模式與錯誤。