🎯 什麼情境該想到我

當你在 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 但沒人 closerange 會等到 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)panicokok★ panic

三條由此推出的鐵律:

  1. ★ 只有送出方能 close,而且只能有一個送出方 close(多個送出方就用 sync.WaitGroup + 一個協調者)
  2. 不要 close 接收端拿到的 channel
  3. ★ 「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 沒有執行緒池,但這三個旋鈕都有對應:

JavaGo滿了會怎樣
核心/最大執行緒數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 內部是競態。

🔗 相關工具