Golang 並發編程:等待 Goroutine 完成(sync.WaitGroup)
簡介
在 Go 語言中,Goroutine 是實現併發的核心機制。它比傳統的 OS 執行緒更輕量,允許我們在同一個程式中同時執行大量任務。然而,僅僅啟動 Goroutine 並不足以保證程式的正確性,何時結束、何時收集結果 同樣重要。
sync.WaitGroup 正是為了這個需求而設計的:它提供了一個簡潔且安全的方式,讓主程式能夠 等待 所有子 Goroutine 完成後再繼續執行。無論是批次處理、網路爬蟲、或是資料庫併發寫入,正確使用 WaitGroup 都能避免資源泄漏、資料不一致或程式提前結束等問題。
本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整介紹 如何在 Go 中使用 sync.WaitGroup 來協調 Goroutine,讓你在實務開發中更得心應手。
核心概念
1. WaitGroup 的基本原理
sync.WaitGroup 內部維護一個 計數器(counter),用來記錄還有多少個 Goroutine 尚未結束。其主要方法如下:
| 方法 | 說明 |
|---|---|
Add(delta int) |
增加(或減少)計數器的值。通常在啟動 Goroutine 前呼叫 Add(1)。 |
Done() |
等價於 Add(-1),在 Goroutine 結束時呼叫,表示該工作已完成。 |
Wait() |
阻塞當前執行緒,直到計數器變為 0 為止。 |
重要:
Add必須在所有Done之前完成,否則可能產生競爭條件(race condition)。
2. 為什麼不直接使用 time.Sleep 或 Channel?
time.Sleep只能猜測等待時間,無法保證所有 Goroutine 真正完成。- 使用單向 Channel 需要自行管理關閉與接收,且在多個 Goroutine 時會變得複雜。
WaitGroup語意清晰、效能好,且是標準庫提供的安全工具。
3. WaitGroup 與 Panic 的關係
如果某個 Goroutine 發生 panic,且未被 recover,整個程式會直接退出,導致 WaitGroup 永遠不會減少計數。為了避免此情況,建議在每個 Goroutine 最外層加入 defer wg.Done() 與 recover 處理。
程式碼範例
以下示範 5 個常見情境,從最簡單的使用方式到較為進階的錯誤處理與併發限制。
範例 1:最基本的 WaitGroup 用法
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 先把計數器加 1
go func(id int) {
defer wg.Done() // 工作結束時減 1
time.Sleep(time.Duration(id) * time.Second)
fmt.Printf("Goroutine %d 完成\n", id)
}(i)
}
wg.Wait() // 等待全部工作結束
fmt.Println("所有 Goroutine 已完成")
}
說明
wg.Add(1)必須在 Goroutine 啟動前呼叫,確保計數器正確。defer wg.Done()放在最外層,保證即使發生 panic 也會減少計數。
範例 2:使用 WaitGroup 搭配 Channel 收集結果
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup, out chan<- string) {
defer wg.Done()
// 模擬計算
out <- fmt.Sprintf("worker %d 的結果", id)
}
func main() {
var wg sync.WaitGroup
results := make(chan string, 5) // 緩衝通道,避免阻塞
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg, results)
}
// 另一個 Goroutine 用來關閉通道
go func() {
wg.Wait()
close(results)
}()
// 主程式直接遍歷通道,取得所有結果
for r := range results {
fmt.Println(r)
}
}
說明
- 透過 緩衝通道 (
chan<- string) 把每個 Goroutine 的結果送回主程式。 - 在
wg.Wait()完成後關閉通道,讓for r := range results能正確結束。
範例 3:限制同時執行的 Goroutine 數量(工作池)
package main
import (
"fmt"
"sync"
"time"
)
func main() {
const maxWorkers = 3
var wg sync.WaitGroup
sem := make(chan struct{}, maxWorkers) // 信號量
for i := 1; i <= 10; i++ {
wg.Add(1)
sem <- struct{}{} // 取得一個名額
go func(id int) {
defer wg.Done()
defer func() { <-sem }() // 釋放名額
time.Sleep(500 * time.Millisecond)
fmt.Printf("任務 %d 完成\n", id)
}(i)
}
wg.Wait()
fmt.Println("全部任務已完成")
}
說明
sem(semaphore)是一個緩衝為maxWorkers的空結構通道,用來 限制同時執行的 Goroutine 數量。- 每個 Goroutine 在開始前必須寫入
sem,結束後再讀出,以釋放資源。
範例 4:在 Goroutine 中捕獲 Panic,確保 WaitGroup 正常減少
package main
import (
"fmt"
"sync"
)
func safeGo(wg *sync.WaitGroup, fn func()) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Printf("捕獲 panic: %v\n", r)
}
}()
fn()
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go safeGo(&wg, func() {
fmt.Println("正常執行")
})
go safeGo(&wg, func() {
panic("故意觸發 panic")
})
wg.Wait()
fmt.Println("所有 Goroutine 已安全結束")
}
說明
safeGo包裝了wg.Done()與recover,讓即使發生 panic 也不會導致WaitGroup卡住。- 這是 防止程式因單一 Goroutine 異常而整體崩潰 的常用技巧。
範例 5:在大型專案中封裝 WaitGroup(可重用的 Helper)
package wghelper
import "sync"
type WaitGroup struct {
sync.WaitGroup
}
// Run 包裝一段需要同步的工作,確保 Add 與 Done 成對
func (wg *WaitGroup) Run(fn func()) {
wg.Add(1)
go func() {
defer wg.Done()
fn()
}()
}
使用方式
package main
import (
"fmt"
"myproj/wghelper"
)
func main() {
var wg wghelper.WaitGroup
wg.Run(func() { fmt.Println("工作 1") })
wg.Run(func() { fmt.Println("工作 2") })
wg.Wait()
fmt.Println("全部完成")
}
說明
- 透過自訂型別 封裝
Add、Done的配對關係,降低忘記呼叫Done()的風險。 - 在大型程式碼基礎上,這種封裝能提升可讀性與維護性。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 解決方案 |
|---|---|---|
在 Goroutine 內部呼叫 wg.Add() |
可能導致計數器在 Wait() 之後才增長,造成死鎖。 |
永遠在啟動 Goroutine 前 呼叫 wg.Add()。 |
忘記 wg.Done() |
Wait() 永遠不會返回,程式卡住。 |
使用 defer wg.Done(),或封裝成 Run 方法(如範例 5)。 |
在 wg.Add() 時傳入負值 |
會觸發 panic。 | 確認 delta 為正值,或使用 sync/atomic 追蹤計數。 |
在 wg.Wait() 之後再呼叫 wg.Add() |
會產生 race,導致 panic。 | 所有 Add 必須在 Wait 開始前完成。 |
| Goroutine 中未捕獲 panic | 程式直接退出,WaitGroup 無法減少。 |
在每個 Goroutine 最外層加入 recover,或使用 safeGo 包裝。 |
| Channel 未正確關閉 | for range 迴圈永遠阻塞。 |
在 wg.Wait() 完成後關閉相關通道。 |
最佳實踐
- 使用
defer wg.Done():保證即使提前return或發生 panic,也會正確減少計數。 - 將
Add與 Goroutine 啟動寫在同一段程式碼,避免因程式路徑分支導致遺漏。 - 盡量避免在
Wait()之後再改變 WaitGroup,若需要動態新增工作,可考慮 工作池(worker pool)或 Context 控制。 - 結合
Context:在需要取消所有 Goroutine 時,可傳遞context.Context,配合select判斷取消訊號。 - 測試競爭條件:使用
go test -race確認 WaitGroup 的使用不會產生 race。
實際應用場景
| 場景 | 為什麼需要 WaitGroup |
|---|---|
| 批次資料處理(例如 CSV 轉 JSON) | 每個檔案可由獨立 Goroutine 處理,最後統一等待所有檔案完成。 |
| 網路爬蟲 | 同時發送多個 HTTP 請求,收集回應後再做匯總。 |
| 微服務間併發呼叫 | 同時呼叫多個下游服務,等全部回應後再回傳給前端。 |
| 測試套件 | 在測試中同時執行多個子測試,確保所有子測試結束後才結束整體測試。 |
| 資料庫併發寫入 | 多個 Goroutine 同時寫入資料庫,使用 WaitGroup 確保所有寫入完成後再提交事務。 |
範例:假設我們要同時向三個外部 API 發送請求,只有全部回傳成功才回傳給使用者。使用
WaitGroup可以輕鬆同步這三個非同步操作,並在任何一個失敗時立即取消剩餘請求(結合Context)。
總結
sync.WaitGroup是 Go 中最常用、也是最安全的 同步原語,專門用來等待多個 Goroutine 完成。- 正確的使用步驟是:先
Add→ 啟動 Goroutine → 在 Goroutine 內部defer Done→Wait。 - 透過範例我們看到,WaitGroup 可以與 Channel、semaphore、panic 捕獲以及自訂封裝結合,形成彈性且易維護的併發模型。
- 常見的陷阱多與計數器的增減時機、panic 處理以及不當的
Add/Wait順序有關,遵循最佳實踐即可避免死鎖與資源泄漏。 - 在實務開發中,無論是 批次處理、爬蟲、微服務呼叫,或是 測試並行,WaitGroup 都是不可或缺的工具。
掌握了 sync.WaitGroup 的使用,你就能在 Go 中自如地管理大量併發工作,寫出既高效又可靠的程式碼。祝你在 Go 的併發世界裡玩得開心,寫出更好的軟體!