CodeGym /課程 /Go SELF /sync.WaitGroup: Add、Done、Wait 與常見錯誤

sync.WaitGroup: Add、Done、Wait 與常見錯誤

Go SELF
等級 65, 課堂 2
開放

1. 為什麼 goroutine 不會「攔住」程式

當你第一次啟動 goroutine 時,會有種感覺,好像你雇了一個小幫手:「把工作做完,我先往前走。」但 Go 有自己的幽默感:幫手在工作,而你卻可能突然把整個辦公室的燈關掉,因為 main 已經結束了。所以我們需要一種方法來等待背景工作完成——不是靠「目測」,不是靠「先睡 100 毫秒試試看」,而是靠一個清楚的契約。

先來看問題示範。看起來很合理:在 goroutine 裡印東西,然後繼續往下走。

package main

import "fmt"

func main() {
	go fmt.Println("hello from goroutine")
	fmt.Println("main finished")
}

實際上,你常常只會看到:

main finished

因為 main 已經結束,程序停掉了,而 goroutine 甚至還沒來得及拿到 CPU 時間。

有時新手會加上 Sleep,然後很高興地說「修好了」:

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("hello from goroutine")

	time.Sleep(10 * time.Millisecond) // temporary hack for demonstration
	fmt.Println("main finished")
}

這確實可能「有效」,但這就像拿膠帶修水龍頭:水暫時不流了,直到你眨個眼。對並發程式來說,「在我的筆電上有跑」不算是正確性的證明。Sleep 可以拿來幫助理解機制(展示 goroutine 的確有啟動),但不能作為等待方式。

2. 什麼是 sync.WaitGroup

當程式裡出現多個平行(並發)任務時,大腦很快就會打結:「還有多少個活著?誰已經完成了?什麼時候可以印結果?」sync.WaitGroup 就是 Go 對這個日常痛點的最簡單回應:它在內部保存一個「還有多少工作尚未完成」的計數器,並且能等待這個計數器變成零。

心智模型可以這樣想:WaitGroup 就是一個寫著「剩餘任務數:N」的看板。你在安排工作時增加 N,當某個工作完成時減少 N。然後再說一次:「我等到 N 變成 0 為止。」

在 Go 中,WaitGroup 位於 sync 套件,所以匯入永遠是這樣:

import "sync"

WaitGroup 有三個關鍵操作。最好把它們用表格記在腦中:

方法 作用 「中文怎麼讀」
Add(n)
將計數器增加/減少 n 「加/減任務」
Done()
將計數器減少 1 「這個任務完成了」
Wait()
阻塞直到計數器變成 0 「等大家都完成」

一個重要細節:零值 的 sync.WaitGroup 就可以直接使用。也就是說,你可以寫 var wg sync.WaitGroup —— 這完全沒問題。不需要 make,不需要 new,也不需要「初始化」。

3. Add、Done、Wait 的契約

WaitGroup 看起來很簡單,這也確實是真的……直到你開始把 Add、Done 和 Wait 放到一些「有趣」的位置。這裡不只是要知道方法,還要把它們的使用契約記在腦中。否則你會遇到卡住(永遠等待)、程式提早結束,甚至還可能收到來自 sync 的 panic,訊息裡會提到 misuse。

核心規則雖然有點囉嗦,但它真的能省下很多時間: 我們透過 Add 在啟動 goroutine 之前增加計數器, 而透過 Done 在 goroutine 內部減少計數器,並且用 defer 保證這個減少一定會發生。

這是一個單一任務「理想」生命週期的流程:

flowchart TD
    A[main] --> B["wg.Add(1)"]
    B --> C["go worker()"]
    C --> D["worker: defer wg.Done()"]
    D --> E[worker: does the work]
    E --> F[worker finished]
    F --> G[wg counter decreased]
    G --> H["main: wg.Wait()"]
    H --> I[main continues]

為什麼 Add 必須在 go 之前?因為 go 會並發啟動工作:新的 goroutine 可能會在你「把它加入等待清單」之前就先結束,而這樣等待邏輯就會壞掉。在現代 Go 中,這通常會直接導致明確的 panic「WaitGroup misuse」,避免你活在錯覺裡。

為什麼 Done 最好在 goroutine 一開始就透過 defer 設定?因為你可能會有提前 return、錯誤處理、分支邏輯——而你不希望某個分支忘了「在看板上打勾」,導致 Wait() 一直等下去。

4. WaitGroup 的使用模式

最小範例:一個 goroutine

現在我們來組一個「標準」的最小範例:一個 goroutine 負責工作,main 等待它。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("work done") // work done
	}()

	wg.Wait()
	fmt.Println("main exits") // main exits
}

這裡很明顯可以看到那個「儀式感」(正面的意思):先說「會有 1 個工作」(Add(1)),接著啟動 goroutine,在裡面立刻放上 defer wg.Done(),然後在 main 裡呼叫 wg.Wait()。

如果你把順序搞反,變成在 go ... 之後 才做 wg.Add(1),至少你會得到非常脆弱的行為,最糟則是程式直接 panic 停掉。這其實是對的:與其偶爾印出「work done」,偶爾又沒有,偶爾還在奇怪的時間卡住,不如立刻失敗來得好。

在迴圈中啟動多個 goroutine

在真實程式裡,通常更常見的是啟動一批相同的工作:處理多個元素、準備多行報告、計算幾個結果片段。這時 WaitGroup 就真正派上用場了:它可以等待整個群組。

假設我們有一個教學應用程式「任務追蹤器」(task tracker)。今天我們先直接在程式碼裡準備資料,不用檔案也不用資料庫——因為今天的重點是等待機制。

先定義一個最小的任務模型:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

現在我們想產生像這樣的報告列:

  • [ ] 1: Buy milk
  • [x] 2: Fix bug

假設格式化「看起來」很貴(為了示範,我們根據 ID 加上一個小延遲)。重點是:這個延遲只是讓你能用眼睛看出並發效果,而不是拿來「同步」程式。

package main

import (
	"fmt"
	"time"
)

func formatTaskLine(t Task) string {
	time.Sleep(time.Duration(t.ID) * 10 * time.Millisecond)
	mark := " "
	if t.Done {
		mark = "x"
	}
	return fmt.Sprintf("[%s] %d: %s", mark, t.ID, t.Title)
}

接著我們並發地組報告:每個任務一個 goroutine,但結果寫到 預先配置好的 slice 的對應索引。這是很重要的紀律:每個 goroutine 寫自己的「格子」,而不是從多個 goroutine 一起對同一個 append 動手。

package main

import (
	"fmt"
	"sync"
)

func main() {
	tasks := []Task{
		{ID: 1, Title: "Buy milk", Done: false},
		{ID: 2, Title: "Fix bug", Done: true},
		{ID: 3, Title: "Read Go docs", Done: false},
	}

	lines := make([]string, len(tasks))

	var wg sync.WaitGroup
	wg.Add(len(tasks))

	for i, t := range tasks {
		go func(i int, t Task) {
			defer wg.Done()
			lines[i] = formatTaskLine(t)
		}(i, t)
	}

	wg.Wait()

	for _, line := range lines {
		fmt.Println(line)
	}
}

這裡你會看到幾個很強的觀念。

  • 我們一次做 wg.Add(len(tasks))。這能降低出錯機率,也避免在迴圈內忘記寫 Add(1)。
  • 我們把 索引和任務本身 都當作參數傳給匿名函式。沒錯,就算你聽過「新的 Go 已經把迴圈變數的問題修好了」,這種寫法依然非常易讀:你可以很明確地看到 goroutine 正在處理哪些具體值。
  • 我們不從 goroutine 裡直接印出來,而是在 wg.Wait() 之後才印,所以即使 goroutine 完成的順序是「交錯的」,輸出順序仍然會依照任務順序保持穩定。

把 WaitGroup 傳給函式

當程式變大時,你很快就會想把 goroutine 的邏輯抽到獨立函式中:這樣更易讀,也更容易測試,main 裡也比較不會變成一團漿糊。這時就很容易出現一個常見錯誤:把 WaitGroup 以值傳遞(也就是複製),然後驚訝為什麼 Wait() 卡住了。WaitGroup 是帶有內部狀態的結構,開始使用後就不能隨便複製。

正確的做法是傳指標。

package main

import (
	"fmt"
	"sync"
)

func renderTask(wg *sync.WaitGroup, t Task) {
	defer wg.Done()
	fmt.Println("render:", t.ID) // for example: render: 2
}

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go renderTask(&wg, Task{ID: 1, Title: "Buy milk"})

	wg.Wait()
}

為什麼一定要指標?因為 wg 對所有 goroutine 和執行 Wait() 的程式碼來說,必須是 同一個計數器物件。如果你把 wg 以值傳遞,就會得到一份副本:goroutine 會減少副本裡的計數器,而 main 仍然在等待原始物件中的計數器。這就像「把完成通知報告給隔壁同事,而不是主管」。

WaitGroup 不做什麼

很容易愛上 WaitGroup,然後開始期待它什麼都能做:「讓它保證順序、幫我收集結果、還能打敗 race condition 吧。」但 WaitGroup 的用途很窄:它 只負責等待完成。

它不保證 goroutine 的執行順序。如果你這樣做:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		fmt.Println("A")
	}()

	go func() {
		defer wg.Done()
		fmt.Println("B")
	}()

	wg.Wait()
}

印出來的順序可能是 A 再 B,也可能是 B 再 A。這不是「錯誤」,而是並發執行的正常非決定性。

它也不會讓共享記憶體的存取變安全。如果多個 goroutine 在沒有存取紀律的情況下修改同一個變數,WaitGroup 幫不上忙。它會很誠實地等大家都結束……然後你會得到不可預測的結果,有時看起來「好像能跑」。今天我們的任務是學會正確等待;共享狀態的安全性是另一個主題,也有另一套思考工具。

5. 使用 sync.WaitGroup 時的典型錯誤

幾乎所有 WaitGroup 的問題都長得很像:程式不是卡在 Wait(),就是突然因為 sync 的 panic 而崩潰,或者「有時少印幾行」。最惱人的是,錯誤通常不在複雜邏輯裡,而是在某一行「放錯地方」的程式碼。因此,值得冷靜地看過最常見的坑,這樣之後你一眼就能認出來。

錯誤 1:把 wg.Add(1) 放在 goroutine 裡。
這樣很誘惑人:「反正是我啟動工作,讓它自己註冊就好。」但這會破壞契約:Wait() 可能在你增加計數器之前就開始了,執行時就會合理地對 misuse 提出抱怨,或者你會得到提早退出。正確的紀律是:Add 放在啟動程式碼中,且要在 go 之前。

var wg sync.WaitGroup

go func() {
	wg.Add(1)        // bad: Add inside goroutine
	defer wg.Done()
}()
wg.Wait()

錯誤 2:Add 放在 go 之後。
這幾乎是同一個問題,只是形式相反:你先啟動 goroutine,之後才把它「寫進等待名單」。goroutine 可能在 Add 之前就跑完了,於是等待就不再有保證。

錯誤 3:忘了 Done(),或它不是保證會執行的(沒有 defer)。
這就是「永遠等待」的經典案例:計數器回不到 0,而 Wait() 永遠阻塞。這通常發生在 goroutine 裡有提前 return,或有多個分支時。

正確的習慣:在 goroutine 的第一行就寫 defer wg.Done(),之後再放任何邏輯。

錯誤 4:Add 和 Done 的次數不一致(計數器變成負數)。
如果呼叫 Done() 的次數比「增加」的次數還多,WaitGroup 就會得到負計數,並直接 panic。這通常發生在你在某處做了 wg.Add(1),但又不小心呼叫了兩次 wg.Done():一次直接呼叫,一次透過 defer,或者你把同一個函式當作 worker 啟動,卻忘了 Done 已經在裡面。

錯誤 5:把 WaitGroup 以值傳遞,結果不小心複製了它。
這就是那種「程式可以編譯,但 Wait() 會卡住」的陷阱。如果你把 wg 當成 sync.WaitGroup 型別的參數傳入,你其實是在複製它。

func worker(wg sync.WaitGroup) { // bad: copy
	defer wg.Done()
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go worker(wg)
	wg.Wait() // may block forever
}

正確的做法是接收 *sync.WaitGroup,並傳入 &wg。

1
任務
Go SELF, 等級 65, 課堂 2
上鎖
等待工作者
等待工作者
1
任務
Go SELF, 等級 65, 課堂 2
上鎖
依序計算平方
依序計算平方
1
任務
Go SELF, 等級 65, 課堂 2
上鎖
Worker 報告
Worker 報告
1
任務
Go SELF, 等級 65, 課堂 2
上鎖
並行分類
並行分類
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION