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 有三個關鍵操作。最好把它們用表格記在腦中:
| 方法 | 作用 | 「中文怎麼讀」 |
|---|---|---|
|
將計數器增加/減少 n | 「加/減任務」 |
|
將計數器減少 1 | 「這個任務完成了」 |
|
阻塞直到計數器變成 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。
GO TO FULL VERSION