上次我們證明了在沒有任何併發控制下,一個簡單的「讀取 → 修改 → 寫入」資料庫操作是多麼脆弱。
今天來檢視 Go 語言內建的併發工具。
但必須先釐清一個關鍵點:今天討論的工具,是用於解決單一應用程式內部、多個 goroutine 之間對記憶體共享的競爭問題。
這與 Day 1 中,多個獨立請求對外部資料庫的競爭,處在不同的層級。
理解這個前提後,讓我們來看看 Go 的併發工具:goroutine
、WaitGroup
。
在 Go 語言中,goroutine
是一種超輕量級的並發執行緒。
它並非由作業系統管理,而是直接由 Go 的 runtime
負責調度,因此啟動成本極低。
在函數呼叫前加個 go
關鍵字,即可變成一個新的 goroutine
了。
// 位於 Day3/goroutine/main.go
func printNumbers(prefix string) {
for i := 1; i <= 3; i++ {
fmt.Printf("%s: %d\n", prefix, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// 啟動三個 goroutine 同時執行
go printNumbers("Goroutine-A")
go printNumbers("Goroutine-B")
go printNumbers("Goroutine-C")
// 嚴重錯誤:使用 Sleep 等待 goroutine,極不可靠。
time.Sleep(2 * time.Second)
}
會看到輸出的順序會交錯出現,這證明了 goroutine
是併發執行的。
但是用了 time.Sleep
來等待 goroutine
結束,這是一個糟糕的設計。
如果 printNumbers
執行時間超過 2 秒,主程式就會提前退出,導致未完成的 goroutine
被強制中止。
這引出一個重要的問題:我們如何可靠地等待一組 goroutine
全部完成?
sync.WaitGroup
是一個計數器,用於等待一組 goroutine
全部執行完畢。
Add(n)
: 將計數器增加 n。
Done()
: 將計數器減 1,通常在 goroutine
結束時透過 defer
呼叫。
Wait()
: 阻塞當前執行緒,直到計數器歸零。
透過 WaitGroup
,我們可以精準地協調任務,避免使用不穩定的 time.Sleep
。
WaitGroup 協調併發執行
// 位於 Day3/waitgroup/main.go
func printNumbersWithWG(prefix string, wg *sync.WaitGroup) {
defer wg.Done() // 函數完成時通知 WaitGroup
// 每個 worker 會做 5 次才是完成
for i := 1; i <= 5; i++ {
fmt.Printf("%s: %d\n", prefix, i)
time.Sleep(100 * time.Millisecond)
}
fmt.Printf("%s 完成工作!\n", prefix)
}
func main() {
var wg sync.WaitGroup
wg.Add(3) // 我們要等待 3 個 goroutine
go printNumbersWithWG("Worker-A", &wg)
go printNumbersWithWG("Worker-B", &wg)
go printNumbersWithWG("Worker-C", &wg)
wg.Wait() // 等待所有 goroutine 完成
fmt.Println("主程序:所有 Worker 都完成了!程序結束。")
}
執行結果:
WaitGroup
提供了一種可靠穩定方式來同步多個併發任務的完成。
當所有 worker
都呼叫了 Done()
,wg.Wait()
才會解除阻塞,主程式才能繼續執行。
goroutine
,並使用 WaitGroup
來等待它們全部完成。go
關鍵字啟動,需要協調機制。goroutine
完成。只是當多個 goroutine
同時操作同一個變數時,會引發一個更棘手的問題:共享記憶體競爭。
下一篇會繼續來面對這個問題。
goroutine
和 channel
的絕佳起點。WaitGroup
的用法。