Go 讓並發看起來很簡單.
你寫:
go func() {
// do something concurrently
}()
然後你的程式碼就在另一個 goroutine 中執行.
這種簡單性是我喜歡 Go 的原因之一。但經過在後端系統、通知管線、高流量 API 和真實負載下的生產服務上工作後,我學到了一個重要的道理:
Go 中的大多數並發問題並非來自於沒有使用並發.
它們來自於在沒有理解實際瓶頸在哪裡的情況下使用並發.
有時問題是缺少鎖.
但非常經常,尤其是在生產環境中的 Go 服務,問題是相反的:
- 鎖太多
- 鎖持續時間過長
- 關鍵區域內的網絡 I/O
- 從不退出的 goroutines
- 無限制的 goroutine 創建
- 按值複製的 WaitGroups
- 未使用取消策略的通道
在本篇文章中,我想透過實際系統中看到的並發問題,闡述我對互斥鎖和信號量的思考方式,以及我通常如何在這些問題變成生產事故之前進行調試。
真實問題:意外變成串行的並發
一個服務在外觀上可以看起來是並發的,但在內部仍然可能像單線程應用程式一樣行為。
這通常發生在請求流程的大部分都被一個共享鎖隱藏起來的時候。
這種模式比許多開發者承認的更常見:
mu.Lock()
user.Name = "Test User"
sendEmail(user)
callDatabase(user)
mu.Unlock()
初看之下,它可能看起來很安全。
開發者想要保護共享狀態。那部分是合理的。但現在這個鎖保護的遠不止共享記憶體。它保護的是整個流程:
- 更新一個欄位
- 發送一封郵件
- 呼叫資料庫
- 可能要等待網路 I/O
- 可能要重試
- 可能會讓其他 goroutines 等很久
這已經不僅僅是一個互斥鎖了.
這是一個交通堵塞.
每個需要相同鎖的goroutine必須等待整個流程完成。所以即使你的服務有數百或數千個goroutine,系統的大部分都變成了串行。
危險之處在於 CPU 使用率可能仍然看起來正常,甚至很低。記憶體也可能看起來沒問題。但延遲增加,通量下降,以及 p95/p99 反應時間變得不穩定。
這就是為什麼鎖爭奪有時單憑基礎設施指標難以察覺的原因。
一個生產風格的例子:互斥鎖中的郵件
想像我們有一個服務會更新使用者狀態並發送通知.
type Service struct {
mu sync.Mutex
state map[int]string
}
func (s *Service) ProcessUsers(users []User) {
s.mu.Lock()
defer s.mu.Unlock()
for _, user := range users {
s.state[user.ID] = "processed"
sendEmail(user) // slow network I/O inside the lock
}
}
這段程式碼從資料競爭的觀點來看是安全的.
但它從效能的觀點來看是危險的.
互斥鎖應該保護最小可能的共享記憶體操作。它不應該保護慢速的外部工作,例如:
- 傳送電郵
- 呼叫另一個微服務
- 資料庫查詢
- HTTP 請求
- 檔案上傳
- 將記錄寫入一個緩慢的外部接收端
- 等待第三方 API
記憶體更新可能需要納秒或微秒。電郵呼叫可能需要毫秒或秒。
這個差異很重要。
若在sendEmail執行時持有鎖,則需要s.mu的每個其他goroutine都會在網絡呼叫後被阻擋。
一個更好的版本將共享狀態變更與緩慢工作分開:
func (s *Service) ProcessUsers(users []User) {
emails := make([]User, 0, len(users))
s.mu.Lock()
for _, user := range users {
s.state[user.ID] = "processed"
emails = append(emails, user)
}
s.mu.Unlock()
for _, user := range emails {
sendEmail(user)
}
}
這已經更好了,因為鎖只保護共享映射。
但在實際的生產系統中,我通常會選擇將緩慢的工作推送到隊列或有限的工作者池中:
func (s *Service) ProcessUsers(users []User, jobs chan<- EmailJob) {
s.mu.Lock()
for _, user := range users {
s.state[user.ID] = "processed"
}
s.mu.Unlock()
for _, user := range users {
jobs <- EmailJob{UserID: user.ID, Email: user.Email}
}
}
現在請求路徑不再直接依賴電子郵件供應商的延遲。
這才是真正的解決方法。
不僅僅是「使用goroutines。」
修復是設計共享記憶體、外部 I/O 和反向壓力的邊界.
互斥鎖並不好。大的關鍵區域是壞的。
我偶爾看到開發者害怕互斥鎖.
那是錯誤的教訓.
sync.Mutex 很簡單、快速,當正確使用時完全沒問題。問題不在於互斥鎖。問題在於關鍵區域的大小。
這是我試圖保持在心裡的:
mu.Lock()
// only touch shared memory here
mu.Unlock()
不是這樣:
mu.Lock()
// shared memory
// database call
// HTTP call
// email call
// JSON encoding
// logging
// metrics push
mu.Unlock()
一個好的關鍵區段應該是無聊的。
它應該通常做以下其中之一:
- 讀取共享狀態
- 更新共享狀態
- 將共享狀態複製到本地變數
- 交換一個指標
- 增加一個計數器
- 附加到受保護的切片/映射
然後解除鎖定.
在那之後,在鎖定外做耗時的工作.
內部運作:互斥鎖給你什麼
在概念上,互斥鎖為您提供互斥:一次只能有一個goroutine進入受保護區域。
但它也為您提供記憶體順序保證。
在Go的記憶模型中,一個解除鎖操作會在後續對同一個互斥鎖的鎖操作之前進行同步。實際上,這意味著如果一個goroutine更新了共享數據並解除鎖,另一個後來鎖定同一個互斥鎖的goroutine可以安全地觀察到那個更新。
這就是許多開發者會忘記的部分。
互斥鎖不僅僅是關於“阻擋其他 goroutines。”它還是關於在 goroutines 之間創建一個安全的可見性邊界.
沒有這個邊界,不同的 goroutines 可能會同時讀取和寫入相同的記憶體,這樣你就有了數據競爭。一旦出現數據競爭,你的程序就不再是你能夠自信地推理的東西。
這就是為什麼我不喜歡「聰明」的無鎖代碼,除非有非常強有力的理由。
大多數後端服務都不需要聰明的並發。
它們需要清晰的並發。
信号量:控制容量,而非擁有權
互斥鎖通常關乎共享內存的擁有權。
信號量關乎容量。
例如,假設您想處理10,000名用戶,但您不希望同時發送10,000封郵件。
一個簡單的版本可能會這樣做:
for _, user := range users {
go sendEmail(user)
}
這很危險,因為它會創建無界的並發。
如果users 有 10,000 項目,你創建了 10,000 個 goroutine。如果每個 goroutine 都進行網絡 I/O、打開連接、分配記憶體並等待外部供應商,你可以在超載郵件供應商之前就超載你自己的服務。
一個簡單的信号量模式可以解決這個問題:
sem := make(chan struct{}, 20) // allow only 20 concurrent email sends
var wg sync.WaitGroup
for _, user := range users {
user := user
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
sendEmail(user)
}()
}
wg.Wait()
目前程式仍然使用並發,但並發是有限制的.
這個細節在生產環境中非常重要.
無限制的並發並非可擴展性.
這是延遲的失敗.
用於生產程式的更好工作池
信號量模式很有用,但對於持續運行的服務,我通常更喜歡工作池。
type EmailJob struct {
UserID int
Email string
}
func startEmailWorkers(ctx context.Context, workerCount int, jobs <-chan EmailJob) {
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
if err := sendEmailJob(ctx, job); err != nil {
// In real systems: log, retry, dead-letter, or expose metrics.
fmt.Printf("worker=%d failed to send email user_id=%d err=%v\n", workerID, job.UserID, err)
}
}
}
}(i)
}
go func() {
wg.Wait()
}()
}
這讓你獲得更好的操作控制:
- 固定的並發性
- 更容易的指標
- 更容易的關閉
- 更容易的重試策略
- 更容易的背壓
- 更容易的速率限制
這是「我使用了 goroutines」和「我設計了一個並行系統」之間的差異
Goroutine洩漏:從不立即爆炸的bug
Goroutine洩漏是Go語言中最常見的生產問題之一
它們很危險,因為服務可能不會立即崩潰。它可能會在數小時或數天內慢慢變得嚴重
這是一個經典的例子:
func process() error {
ch := make(chan result)
go func() {
ch <- heavyComputation()
}()
select {
case res := <-ch:
return handle(res)
case <-time.After(1 * time.Second):
return errors.New("timeout")
}
}
這個問題很微妙.
ch沒有緩衝.
如果超時發生在先,process就會回傳。之後,沒有接收者在等待ch.
當heavyComputation()完成時,goroutine嘗試向ch發送,並且永久阻塞。
當前那個 goroutine 已經洩漏了。
一個洩漏的 goroutine 或許無關緊要。
成千上萬個洩漏的 goroutine 就很重要了。
一個更安全的版本使用一個緩衝通道:
func process() error {
ch := make(chan result, 1)
go func() {
ch <- heavyComputation()
}()
select {
case res := <-ch:
return handle(res)
case <-time.After(1 * time.Second):
return errors.New("timeout")
}
}
這可以防止 goroutine 在超時後在發送操作上阻塞。
但在實際服務中,我更喜歡基於語境的取消:
func process(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
ch := make(chan result, 1)
go func() {
res := heavyComputation(ctx)
select {
case ch <- res:
case <-ctx.Done():
}
}()
select {
case res := <-ch:
return handle(res)
case <-ctx.Done():
return ctx.Err()
}
}
重要的教訓:
每個goroutine都需要一個退出路徑。
如果你無法解釋一個goroutine如何停止,你可能有一個即將發生的洩漏。
WaitGroup按值傳遞:一個小錯誤帶來巨大影響
在程式碼審查中這個錯誤非常容易錯過:
func worker(wg sync.WaitGroup) { // wrong: copied by value
defer wg.Done()
// do work
}
sync.WaitGroup第一次使用後不得複製。
當你以值傳遞時,你複製了它的內部狀態。工作員在複製上呼叫Done(),而不是主goroutine等待的原始WaitGroup。
這可能會導致死結。
正確版本:
func worker(wg *sync.WaitGroup) {
defer wg.Done()
// do work
}
而且使用方式:
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go worker(&wg)
}
wg.Wait()
此規則也適用於其他同步原語,例如sync.Mutex.
首次使用後不要複製它們.
環路變數陷阱
這曾經是 Go 並發程式中最著名的錯誤之一:
for _, user := range users {
go func() {
sendEmail(user)
}()
}
取決於 Go 版本和語境,錯誤地捕捉迴圈變數可能會導致 goroutines 使用錯誤的值。
防禦模式仍然簡單且清晰:
for _, user := range users {
user := user
go func() {
sendEmail(user)
}()
}
即使更新版本的Go語言有所改進,我仍然喜歡在生產代碼中使用這種風格,因為它讓變量的擁有權對讀者顯而易見。
易讀的並發是可維護的並發。
如何在Go中調試鎖競爭
當我懷疑並發瓶頸時,我並不是從猜測開始。
我從測量開始。
1. 啟用 pprof
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// start application
}
接著收集剖析檔案:
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
針對互斥鎖競爭,啟用互斥鎖剖析:
runtime.SetMutexProfileFraction(1)
接著檢查:
go tool pprof http://localhost:6060/debug/pprof/mutex
2. 檢查goroutine計數
goroutine計數上升通常是阻塞goroutine或洩漏的信號
fmt.Println("goroutines:", runtime.NumGoroutine())
對於生產環境,將其作為指標公開:
prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "go_goroutines_current",
Help: "Current number of goroutines.",
},
func() float64 {
return float64(runtime.NumGoroutine())
},
)
3. 倒出 goroutine 堆疊
當服務卡住時,goroutine 倒出是寶貴的資訊.
curl http://localhost:6060/debug/pprof/goroutine?debug=2
尋找許多 goroutine 在同一行阻塞:
sync.(*Mutex).Lock
chan send
chan receive
net/http.(*Transport).RoundTrip
若 5,000 個 goroutines 被同一個鎖或通道鎖定,你已找到你的瓶頸。
4. 在測試中使用競態檢測器
go test -race ./...
競態檢測器不是免費的,你通常不會在生產環境中運行它,但在持續整合和本地調試中極其有用。
我在編寫或審閱 Go 並行代碼時所遵循的實用規則
這些是我寫作或審閱並行 Go 代碼時試圖遵循的規則:
1. 保持鎖盡可能小
僅鎖定需要保護的數據。
不要鎖定整個請求生命週期。
2. 別把慢速 I/O 放在互斥鎖裡
避免在關鍵區段內進行資料庫調用、HTTP 調用、郵件發送、檔案上傳和第三方 API 調用.
3. 限制並發
不要建立無限的 goroutine.
使用工作池、信號量、隊列或速率限制器.
4. 每個 goroutine 需要有關閉路徑
使用 context.Context、通道關閉或明確的取消。
5. 切勿複製同步原語
分享時,透過指標傳遞*sync.WaitGroup、*sync.Mutex以及類似的原語。
6. 優先測量,再進行優化
使用pprof、執行時期指標、追蹤、日誌以及goroutine轉储。
猜測不是除錯。
7. 選擇無聊的並行處理
最佳的並行程式碼通常不是花哨的.
它是清晰的、可測量的,而且容易關閉.
結語
Go 提供了我們強大的並行工具,但它並不會自動給我們好的並行設計.
一個 goroutine 是廉價的,但它不是免費的.
一個互斥鎖是快速的,但如果你在慢速工作周圍持續持有它,它可能會摧毀吞吐量。
一個頻道很優雅,但如果沒有人接收,它可能會洩漏 goroutines.
一個 WaitGroup 很簡單,但複製它可能會打亂你整個流程.
對我來說,高階 Go 工程不是關於使用每個並發原語。它關於知道何時不使用它們,真正的界限在哪裡,以及系統在負載下的行為.
下次你寫這個時:
mu.Lock()
在繼續之前請提一個問題:
我究竟在保護什麼,以及我有多快能釋放這個鎖?
這個問題能拯救你的服務免於一個靜默的生產瓶頸.
參考資料
- Go 内存模型:https://go.dev/ref/mem
- Go
sync套件文件說明:https://pkg.go.dev/sync - Go 診斷和剖析工具:https://go.dev/doc/diagnostics
- Go 博客:Go 調度器與執行時注意事項:https://go.dev/blog/











