Golang 並發編程:Goroutine 的基本概念
簡介
在現代的網路服務、即時資料處理與分散式系統中,並發已成為提升效能與資源利用率的關鍵技術。Go 語言(Golang)自設計之初就把並發作為核心特性,透過輕量級的 goroutine 與通道(channel)讓開發者能以簡潔的語法寫出高效能的程式。
本單元將帶你從 什麼是 goroutine、如何 啟動與管理、以及在實務開發中常見的 陷阱與最佳實踐,一步步建立對 Go 並發模型的直觀認識,讓你能快速在專案中運用。
核心概念
1. Goroutine 是什麼?
- Goroutine 是由 Go 執行階段(runtime)管理的輕量級執行單元。相較於作業系統的 thread,goroutine 只佔用數 KB 的堆疊空間,且會在需要時自動擴增或收縮。
- 每個 Go 程式在
main函式啟動時即有一個 主 goroutine,其他的 goroutine 皆由go關鍵字觸發。
重點:goroutine 的排程完全由 Go runtime 控制,開發者不需要直接與 OS thread 打交道。
2. 啟動 Goroutine
使用 go 關鍵字即可在新 goroutine 中執行函式或匿名函式:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from goroutine")
}
func main() {
go sayHello() // <-- 啟動新 goroutine
fmt.Println("Hello from main")
time.Sleep(time.Second) // 防止程式提前結束
}
註解:
time.Sleep只是一種最簡單的同步方式,實務上會使用sync.WaitGroup、channel 或 context 來協調。
3. Goroutine 與同步(channel、WaitGroup)
3.1 使用 channel 傳遞資料
Channel 是 goroutine 之間安全傳遞資料的管道,具備 阻塞 與 同步 的特性。
package main
import (
"fmt"
)
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("worker %d 處理 job %d\n", id, j)
results <- j * 2 // 假設工作是把數字乘以 2
}
}
func main() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// 建立 3 個 worker goroutine
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 送出工作
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // 關閉 channel,讓 worker 知道沒有新工作
// 收集結果
for a := 0; a < numJobs; a++ {
fmt.Println("結果:", <-results)
}
}
3.2 使用 sync.WaitGroup 等待多個 goroutine 完成
WaitGroup 為最常見的同步工具之一,適合「啟動 N 個 goroutine,等全部結束」的情境。
package main
import (
"fmt"
"sync"
"time"
)
func task(id int, wg *sync.WaitGroup) {
defer wg.Done() // 任務結束時呼叫 Done
fmt.Printf("Task %d 開始\n", id)
time.Sleep(time.Duration(id) * time.Second)
fmt.Printf("Task %d 完成\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 計數器加 1
go task(i, &wg) // 啟動 goroutine
}
wg.Wait() // 等待所有 goroutine 完成
fmt.Println("所有任務已完成")
}
3.3 使用 context 控制生命週期
context 用於在多層 goroutine 中傳遞取消訊號、截止時間或其他請求範圍的值。
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d 收到取消訊號,結束\n", id)
return
default:
fmt.Printf("worker %d 正在執行\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
for i := 1; i <= 2; i++ {
go worker(ctx, i)
}
// 等待 timeout 或手動 cancel
<-ctx.Done()
fmt.Println("主程式結束")
}
4. 調度與 GOMAXPROCS
Go runtime 會根據機器的 CPU 數量自動決定同時執行的 OS thread 數目。runtime.GOMAXPROCS 可手動調整:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("預設 GOMAXPROCS:", runtime.GOMAXPROCS(0))
runtime.GOMAXPROCS(2) // 限制同時執行的 thread 數為 2
fmt.Println("調整後 GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
提示:在 CPU 密集型工作時,將
GOMAXPROCS設為實體核心數可取得最佳效能;IO 密集型則可維持預設值。
常見陷阱與最佳實踐
| 陷阱 | 可能的問題 | 解決方式 |
|---|---|---|
| 忘記同步 | 主程式提前結束,導致 goroutine 尚未執行完畢 | 使用 sync.WaitGroup、channel 或 context 進行協調 |
| 資料競爭 (race condition) | 多個 goroutine 同時寫同一變數,產生不可預期結果 | 使用 sync.Mutex、sync.RWMutex 或 channel 進行互斥 |
| 過度產生 goroutine | 產生過多 goroutine 會耗盡記憶體或導致排程開銷過大 | 控制併發數量(如使用 worker pool) |
| 阻塞的 channel | 未正確關閉 channel 或未接收,導致死鎖 | 確保所有發送端都有對應的接收端,必要時使用 select 超時 |
忘記 defer wg.Done() |
WaitGroup 計數不減,導致 wg.Wait() 永遠阻塞 |
在每個 goroutine 開頭使用 defer wg.Done(),或在所有路徑都呼叫 Done |
最佳實踐
- 盡量使用 channel 作為通訊橋樑,讓資料流向自然、程式易於閱讀。
- 將共享資源封裝在結構體內,並提供方法保護(如使用 mutex 包裝的
type SafeCounter struct { mu sync.Mutex; v map[string]int })。 - 使用 context 來傳遞取消與截止時間,尤其在服務端處理 HTTP 請求時。
- 限制同時執行的 goroutine 數,可透過 buffered channel 或 semaphore 模式實作 worker pool。
- 在開發階段啟用 race detector:
go run -race ./...,即時捕捉競爭條件。
實際應用場景
| 場景 | 為何使用 Goroutine | 範例 |
|---|---|---|
| Web 伺服器 | 每個連線可獨立處理,提升併發量 | net/http 內部已使用 goroutine 處理每個請求 |
| 資料流處理 (pipeline) | 多階段處理可分為不同 goroutine,使用 channel 串接 | 圖像處理、日誌聚合 |
| 定時任務 / 背景工作 | 輕量級的 timer + goroutine 可實作 cron 功能 | time.Ticker + goroutine |
| 分散式爬蟲 | 同時抓取多個網頁,減少等待時間 | worker pool + channel |
| 即時訊息推送 | 多個客戶端同時接收訊息,使用 broadcast channel | Pub/Sub 模式 |
範例:簡易的 Pub/Sub 系統
package main
import (
"fmt"
"sync"
"time"
)
type PubSub struct {
subs map[chan string]struct{}
mu sync.RWMutex
}
func NewPubSub() *PubSub {
return &PubSub{
subs: make(map[chan string]struct{}),
}
}
func (ps *PubSub) Subscribe() <-chan string {
ch := make(chan string, 1)
ps.mu.Lock()
ps.subs[ch] = struct{}{}
ps.mu.Unlock()
return ch
}
func (ps *PubSub) Unsubscribe(ch <-chan string) {
ps.mu.Lock()
delete(ps.subs, ch.(chan string))
ps.mu.Unlock()
close(ch.(chan string))
}
func (ps *PubSub) Publish(msg string) {
ps.mu.RLock()
for ch := range ps.subs {
ch <- msg
}
ps.mu.RUnlock()
}
func main() {
ps := NewPubSub()
// 兩個訂閱者
sub1 := ps.Subscribe()
sub2 := ps.Subscribe()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for m := range sub1 {
fmt.Println("[Sub1] 收到:", m)
}
}()
go func() {
defer wg.Done()
for m := range sub2 {
fmt.Println("[Sub2] 收到:", m)
}
}()
// 發布訊息
for i := 1; i <= 3; i++ {
ps.Publish(fmt.Sprintf("訊息 %d", i))
time.Sleep(300 * time.Millisecond)
}
// 關閉訂閱
ps.Unsubscribe(sub1)
ps.Unsubscribe(sub2)
wg.Wait()
fmt.Println("Pub/Sub 結束")
}
此範例展示了 goroutine 搭配 channel、sync.RWMutex 共同實作一個簡易的發布/訂閱機制,適合在即時聊天、事件驅動系統中使用。
總結
- Goroutine 是 Go 語言的核心並發單元,輕量、易於建立,讓程式可以在同一時間執行多件事。
- 透過 channel、sync.WaitGroup、context 等工具,我們可以安全、可控地協調多個 goroutine 的執行與生命週期。
- 常見的陷阱包括忘記同步、資料競爭與過度產生 goroutine,遵循 「以 channel 為主、以 mutex 為輔」 的設計原則,可大幅降低錯誤風險。
- 在 Web 伺服器、資料流水線、分散式爬蟲、即時訊息推送等場景,goroutine 都能提供高效且易於維護的解決方案。
掌握了 goroutine 的基本概念與實務技巧後,你就能在 Go 專案中自信地使用並發,寫出既 高效 又 可讀 的程式碼。祝你在並發編程的旅程中玩得開心、寫得順利! 🚀