🎯 什麼情境該想到我
當你在 Go 裡處理並行,手很癢想用鎖去保護一個共享變數的時候。
「不要透過共享記憶體來通訊;要透過通訊來共享記憶體。」
⚙️ 怎麼用(步驟 / 公式)
1. 心智模型:值在 channel 上傳遞,不被多方同時持有
Go 鼓勵的做法是把值放上 channel 傳來傳去,而不是讓多個執行緒主動共享它。
任何時刻只有一個 goroutine 能存取那個值 → 資料競爭在設計上就不可能發生。
2. 原文給的直覺(很好用)
想像一個單執行緒程式跑在一顆 CPU 上——它不需要同步。
再跑一個實例——它也不需要同步。
現在讓這兩個互相通訊——如果通訊本身就是同步機制,那就仍然不需要其他同步。
Unix pipeline 完美符合這個模型。Go 的並行源自 Hoare 的 CSP,也可以看成是型別安全版的 Unix pipe。
3. goroutine 的成本模型
就是一個「與其他 goroutine 併行執行的函式」。輕量,成本略高於配置堆疊空間;堆疊從小開始、按需增長。它們被多工到多個 OS 執行緒上,所以其中一個因 I/O 阻塞時,其他照跑。
go list.Sort() // 併行跑,不等它函式字面量在 Go 裡是閉包,實作會確保被引用的變數活得夠久。
4. ⭐ 但這句標語有邊界——原文自己講的
“This approach can be taken too far. Reference counts may be best done by putting a mutex around an integer variable, for instance.”
引用計數這種東西,用 mutex 包一個整數就好。 channel 是高階做法,不是所有同步問題的答案。
判準:要傳遞「所有權」或「資料流」用 channel;只是保護一小塊狀態用 mutex。
5. ★ goroutine 洩漏:四種最常見的模式
「開了 goroutine 卻沒人負責它結束」是 Go 最常見的生產問題。
| 洩漏模式 | 為什麼 | 修法 |
|---|---|---|
| 送到沒人收的 channel | 無緩衝 channel 上的 ch <- v 永遠阻塞 | 加緩衝,或用 select + ctx.Done() |
| 從沒人送的 channel 收 | <-ch 永遠阻塞 | 同上;或確保一定有人 close |
忘記 ctx 取消 | 上游放棄了,下游還在跑 | ★ 每個長命 goroutine 都要 select { case <-ctx.Done(): return; ... } |
for range ch 但沒人 close | range 會等到 channel 關閉 | 由「唯一的送出方」負責 close |
// ★ 可被取消的 goroutine 骨架
func worker(ctx context.Context, in <-chan Job, out chan<- Result) {
for {
select {
case <-ctx.Done(): // ★ 一定要有這條
return
case j, ok := <-in:
if !ok { return } // ★ 上游關閉了
select {
case out <- process(j):
case <-ctx.Done(): // ★ 送出時也可能阻塞,也要能取消
return
}
}
}
}怎麼偵測:
runtime.NumGoroutine()只增不減 → 有洩漏pprof的 goroutine profile(/debug/pprof/goroutine?debug=2)能直接看到卡在哪一行go.uber.org/goleak可以在測試結束時斷言沒有殘留 goroutine
6. ★ Channel 的語義表(必背)
| 操作 | nil channel | 開著且空 | 開著且滿 | 已關閉 |
|---|---|---|---|---|
<-ch 接收 | 永遠阻塞 | 阻塞 | 取到值 | ★ 立刻回零值,ok == false |
ch <- v 送出 | 永遠阻塞 | 送出 | 阻塞 | ★ panic |
close(ch) | panic | ok | ok | ★ panic |
三條由此推出的鐵律:
- ★ 只有送出方能 close,而且只能有一個送出方 close(多個送出方就用
sync.WaitGroup+ 一個協調者) - 不要 close 接收端拿到的 channel
- ★ 「nil channel 永遠阻塞」是特性不是 bug——在
select裡把某個 case 的 channel 設成 nil,就能動態關掉那個分支
7. Worker Pool 與 errgroup
import "golang.org/x/sync/errgroup"
func processAll(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx) // ★ 任一個回錯,ctx 自動取消其他的
g.SetLimit(10) // ★ 併發上限(見 [[AI 工程 Coding 題型]] 的批次處理器)
for _, it := range items {
it := it // ★ Go < 1.22 必須這行;1.22+ 可省
g.Go(func() error { return process(ctx, it) })
}
return g.Wait() // ★ 回傳第一個非 nil 的錯誤
}★ errgroup 是 Go 並行的實務標配:它把「等待、錯誤傳播、取消、限流」四件事一次解決。
自己用 sync.WaitGroup + channel 收錯誤幾乎總是重新發明它。
★ 對照 Java 線程池:Go 的「拒絕策略」長什麼樣
Java 的 ThreadPoolExecutor 有三個關鍵旋鈕:執行緒數、有界佇列、佇列滿時的拒絕策略。常見的踩雷點是 Executors.newFixedThreadPool 背後用的 LinkedBlockingQueue 容量是 Integer.MAX_VALUE,等於無界,量一大就 OOM(阿里巴巴 Java 開發規約因此禁止用 Executors 建執行緒池)。Go 沒有執行緒池,但這三個旋鈕都有對應:
| Java | Go | 滿了會怎樣 |
|---|---|---|
| 核心/最大執行緒數 | g.SetLimit(n) 或固定 n 個 worker goroutine | — |
| 有界佇列 | make(chan Job, n) | 送不進去時阻塞 |
CallerRunsPolicy(呼叫端自己執行,拖慢提交速度) | g.Go(...):阻塞到有空位才回傳 | 生產者自然被拖慢 → 背壓 |
AbortPolicy(預設,丟 RejectedExecutionException 給呼叫端) | g.TryGo(...) 回傳 false;或 select { case ch <- job: default: } | 呼叫端自己決定重試、落地或回錯 |
- errgroup 官方註解:
Go會 “block until the new goroutine can be added without the number of goroutines in the group exceeding the configured limit”;TryGo只在低於上限時才啟動,“The return value reports whether the goroutine was started.”(查證 2026-09-27) - 選哪種看場景:離線批次(從 DB 撈千萬筆推送)要的是「生產者慢下來」,用會阻塞的
g.Go/阻塞送 channel。線上請求不能讓請求執行緒被拖住,用TryGo/select default快速失敗,回 429 或降級。Java 的 CallerRuns 也是同樣的取捨:在 Web 請求執行緒上用它,會把整個服務卡死。 AbortPolicy不是「資料直接丟了」,而是把例外丟給呼叫端;真正靜默丟棄的是DiscardPolicy。Go 的TryGo回false也一樣:丟不丟,取決於呼叫端有沒有處理這個回傳值。- 併發數怎麼估:與其背「IO 密集型設 2N」這類經驗值,不如用 Little’s Law 反推:
所需併發數 = 目標吞吐 × 單次延遲。例如 1,000 萬則簡訊要在 1 小時內發完 → 10,000,000 ÷ 3,600 ≈ 2,778 則/秒;若簡訊閘道每次呼叫約 200ms,就需要約 2,778 × 0.2 ≈ 556 個同時在途的請求。這是推導出來的起點,還要再看閘道的限流上限、壓測調整(見 工具-限流器設計)。 - 在記憶體裡排隊的工作,當機就沒了。千萬級任務要先在 DB/Redis 落一個「發送中」狀態,做完回寫「已完成」,再用補償任務重送卡太久的(跟 工具-延遲任務方案選型 的兜底對帳同一個思路)。
(Java 線程池的部分補充自 https://juejin.cn/post/7626595191144595456,對照 OpenJDK 原始碼與 errgroup 原始碼,查證 2026-09-27)
8. ★ Go 1.22 的迴圈變數變更(2026-09-21 查證)
Go 1.22 release notes:“Previously, the variables declared by a ‘for’ loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.”
for _, v := range items {
go func() { use(v) }() // ★ Go 1.22 前:所有 goroutine 看到同一個 v(最後一個值)
} // ★ Go 1.22 起:每輪新的 v,行為正確但要注意:
- 這由
go.mod裡的go指令版本決定,不是編譯器版本 go vet也跟著改了——1.22+ 的檔案不再報告這類引用- 舊專案(
go 1.21以下)仍然需要v := v
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 這句標語有邊界,原文自己講了:“This approach can be taken too far.”
保護一小塊狀態 →sync.Mutex更簡單也更快;傳遞所有權或資料流 → channel。
用 channel 做引用計數是過度工程。 - ★ channel 不是免費的。每次收送都有排程與鎖的開銷,比 mutex 貴。
高頻的小操作(計數器、快取查找)用sync/atomic或RWMutex。 - ★ 無緩衝 channel 是同步點,會讓雙方互相等待。加緩衝能解耦,但緩衝滿了還是會阻塞——
緩衝只是延後問題,不是背壓的解答(見 工具-MQ消費端防禦三原則)。 - 無界的 goroutine = 無界的記憶體。
for { go handle(req) }在流量尖峰時會爆。一定要有併發上限。 - panic 跨不過 goroutine 邊界。子 goroutine 裡的 panic 會直接終止整個程式,主 goroutine 的
recover攔不到。
→ 每個go出去的函式都該有自己的defer recover()(見 工具-defer-panic-recover)。 select的多個就緒 case 是隨機選的,不是依序。不要依賴順序。sync.WaitGroup的Add必須在go之前呼叫,放在 goroutine 內部是競態。
🔗 相關工具
- 工具-select超時與競速 —— select 的逾時、競速與 nil channel 技巧
- 工具-用race偵測器抓資料競爭 —— 驗證「沒有共享」是真的
- 工具-defer-panic-recover —— ★ 每個 goroutine 都要自己 recover
- 工具-服務容錯設計 —— 超時、重試、斷路器
- 工具-MQ消費端防禦三原則 —— 背壓與有界佇列
- 堆疊與佇列 —— channel 作為有界佇列
- 中介者模式 —— Go 的 hub 模式(單一 goroutine 序列化狀態)
- Effective Go、Go Blog 經典六篇