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

說明

  • 透過自訂型別 封裝 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() 完成後關閉相關通道。

最佳實踐

  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 Done → Wait。
  • 透過範例我們看到,WaitGroup 可以與 Channel、semaphore、panic 捕獲以及自訂封裝結合,形成彈性且易維護的併發模型。
  • 常見的陷阱多與計數器的增減時機、panic 處理以及不當的 Add/Wait 順序有關,遵循最佳實踐即可避免死鎖與資源泄漏。
  • 在實務開發中,無論是 批次處理、爬蟲、微服務呼叫,或是 測試並行,WaitGroup 都是不可或缺的工具。

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