本文 AI 產出,尚未審核

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("全部完成")
}

說明

  • 透過自訂型別 封裝 AddDone 的配對關係,降低忘記呼叫 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() 完成後關閉相關通道。

最佳實踐

  1. 使用 defer wg.Done():保證即使提前 return 或發生 panic,也會正確減少計數。
  2. Add 與 Goroutine 啟動寫在同一段程式碼,避免因程式路徑分支導致遺漏。
  3. 盡量避免在 Wait() 之後再改變 WaitGroup,若需要動態新增工作,可考慮 工作池(worker pool)或 Context 控制。
  4. 結合 Context:在需要取消所有 Goroutine 時,可傳遞 context.Context,配合 select 判斷取消訊號。
  5. 測試競爭條件:使用 go test -race 確認 WaitGroup 的使用不會產生 race。

實際應用場景

場景 為什麼需要 WaitGroup
批次資料處理(例如 CSV 轉 JSON) 每個檔案可由獨立 Goroutine 處理,最後統一等待所有檔案完成。
網路爬蟲 同時發送多個 HTTP 請求,收集回應後再做匯總。
微服務間併發呼叫 同時呼叫多個下游服務,等全部回應後再回傳給前端。
測試套件 在測試中同時執行多個子測試,確保所有子測試結束後才結束整體測試。
資料庫併發寫入 多個 Goroutine 同時寫入資料庫,使用 WaitGroup 確保所有寫入完成後再提交事務。

範例:假設我們要同時向三個外部 API 發送請求,只有全部回傳成功才回傳給使用者。使用 WaitGroup 可以輕鬆同步這三個非同步操作,並在任何一個失敗時立即取消剩餘請求(結合 Context)。


總結

  • sync.WaitGroup 是 Go 中最常用、也是最安全的 同步原語,專門用來等待多個 Goroutine 完成。
  • 正確的使用步驟是:Add → 啟動 Goroutine → 在 Goroutine 內部 defer DoneWait
  • 透過範例我們看到,WaitGroup 可以與 Channel、semaphore、panic 捕獲以及自訂封裝結合,形成彈性且易維護的併發模型。
  • 常見的陷阱多與計數器的增減時機、panic 處理以及不當的 Add/Wait 順序有關,遵循最佳實踐即可避免死鎖與資源泄漏。
  • 在實務開發中,無論是 批次處理、爬蟲、微服務呼叫,或是 測試並行,WaitGroup 都是不可或缺的工具。

掌握了 sync.WaitGroup 的使用,你就能在 Go 中自如地管理大量併發工作,寫出既高效又可靠的程式碼。祝你在 Go 的併發世界裡玩得開心,寫出更好的軟體!