慣性聚合 高效追蹤和閱讀你感興趣的部落格、新聞、科技資訊
閱讀原文 在慣性聚合中打開

推薦訂閱源

Google DeepMind News
Google DeepMind News
人人都是产品经理
人人都是产品经理
M
MIT News - Artificial intelligence
博客园 - 叶小钗
MyScale Blog
MyScale Blog
V
Visual Studio Blog
月光博客
月光博客
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
量子位
I
InfoQ
有赞技术团队
有赞技术团队
阮一峰的网络日志
阮一峰的网络日志
Jina AI
Jina AI
V
V2EX
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Blog — PlanetScale
Blog — PlanetScale
Last Week in AI
Last Week in AI
雷峰网
雷峰网
Stack Overflow Blog
Stack Overflow Blog
博客园 - Franky

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python)
Go 並發的靜默殺手:互斥鎖、信號量與 Goroutine 泄漏
amir · 2026-05-25 · via DEV Community

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()

進入全螢幕模式 退出全螢幕模式

初看之下,它可能看起來很安全。

開發者想要保護共享狀態。那部分是合理的。但現在這個鎖保護的遠不止共享記憶體。它保護的是整個流程:

  1. 更新一個欄位
  2. 發送一封郵件
  3. 呼叫資料庫
  4. 可能要等待網路 I/O
  5. 可能要重試
  6. 可能會讓其他 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}
    }
}

Enter fullscreen mode Exit fullscreen mode

現在請求路徑不再直接依賴電子郵件供應商的延遲。

這才是真正的解決方法。

不僅僅是「使用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()

進入全螢幕模式 離開全螢幕模式

在繼續之前請提一個問題:

我究竟在保護什麼,以及我有多快能釋放這個鎖?

這個問題能拯救你的服務免於一個靜默的生產瓶頸.


參考資料