并发编程是 Go 的灵魂,而 Mutex(互斥锁) 是并发控制的第一道防线。这篇文章带你从"为什么需要锁"开始,一路走到 Mutex 的演进原理、常见坑点和读写锁 RWMutex,争取一文把 Mutex 讲透。
一、没有锁的世界有多危险?
先看一个经典的并发计数器问题:
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // 非原子操作:读取 → 加1 → 写回
}()
}
wg.Wait()
fmt.Println("count:", count) // 几乎不可能是 10000
}多次运行,结果可能是 9831、9917、9756……每次都不一样。这就是典型的 数据竞争(Data Race)。
count++ 看起来是一行代码,实际上是三步操作:
┌─ goroutine A ─┐ ┌─ goroutine B ─┐
│ 读取 count=0 │ │ │
│ │ │ 读取 count=0 │
│ count=0+1=1 │ │ │
│ 写回 count=1 │ │ count=0+1=1 │
│ │ │ 写回 count=1 │ ← 两次 +1,结果只增加了 1
└───────────────┘ └───────────────┘两个 goroutine 同时读到了旧值,各自加 1 后写回,最终只加了 1。这就是 临界区 没有被保护的后果。
临界区:程序中访问共享资源的那段代码。多个 goroutine 同时执行临界区代码,就会产生竞争。
二、Mutex 基本用法
解决方案很简单——用互斥锁把临界区"锁"起来:
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // 进入临界区
count++
mu.Unlock() // 离开临界区
}()
}
wg.Wait()
fmt.Println("count:", count) // 稳定输出 10000
}Mutex 的 API 极简,只有两个方法:
| 方法 | 说明 |
|---|---|
Lock() | 获取锁,获取不到则阻塞等待 |
Unlock() | 释放锁,允许其他 goroutine 获取 |
工作原理用一张图表示:
goroutine A Mutex goroutine B
│ │ │
│── Lock() ───────→│ │
│ (获取成功) │ │
│ │←── Lock() ───────│
│ │ (阻塞等待...) │
│ [执行临界区] │ │
│ │ │
│── Unlock() ─────→│ │
│ │──→ (唤醒,获取) │
│ │ [执行临界区] │
│ │←── Unlock() ─────│三、Mutex 的演进之路
Go 的 Mutex 并非一成不变,它经历了 四个阶段 的演进,每一步都是在性能和公平性之间做权衡:
| 阶段 | 年份 | 特点 | 说明 |
|---|---|---|---|
| v1 初版 | 2008 | 一个 flag,CAS 抢锁 | 简单粗暴 |
| v2 给新人机会 | 2011 | 新 goroutine 也能直接竞争 | 不必排队 |
| v3 多给些机会 | 2015 | 自旋等待 | 减少上下文切换,提升吞吐量 |
| v4 解决饥饿 | 2018 | 正常模式 + 饥饿模式 | 兼顾吞吐和公平 |
第一阶段:初版(2008)
最早的 Mutex 非常简洁——用一个 flag 字段表示是否被持有,通过 CAS 原子操作抢锁:
// 伪代码,展示核心思路
type Mutex struct {
key int32 // 0=未锁, 1=已锁
sema uint32
}
func (m *Mutex) Lock() {
if atomic.AddInt32(&m.key, 1) == 1 {
return // CAS 成功,直接获取锁
}
runtime.Semacquire(&m.sema) // 获取不到,信号量等待
}
func (m *Mutex) Unlock() {
if atomic.AddInt32(&m.key, -1) == 0 {
return // 没有等待者
}
runtime.Semrelease(&m.sema) // 唤醒一个等待者
}问题:所有抢不到锁的 goroutine 都进入等待队列,即使锁刚释放,新来的 goroutine 也得排队。这导致了不必要的上下文切换。
第二阶段:给新人机会(2011)
改进思路:新到达的 goroutine 在锁刚释放时,可以直接和被唤醒的 goroutine 竞争。新 goroutine 正在 CPU 上运行,如果让它直接拿锁,可以避免一次上下文切换,吞吐量更高。
第三阶段:多给些机会(2015)
引入 自旋(Spinning):当锁被持有时,新来的 goroutine 不立即进入等待队列,而是短暂自旋几次。如果这期间锁被释放了,就能直接获取,避免了昂贵的上下文切换。
新 goroutine 到达
│
├─ 锁空闲?─── 是 ──→ 直接获取
│
└─ 锁被占用
│
├─ 自旋几次 ──→ 锁释放了?─── 是 ──→ 获取锁
│ │
│ └── 否
│
└─ 进入等待队列,休眠问题:新来的 goroutine 一直有优势,排队等待的老 goroutine 可能长期拿不到锁——这就是 饥饿问题。
第四阶段:解决饥饿(2018,当前版本)
引入 两种模式:
正常模式(Normal)
- 等待队列中被唤醒的 goroutine 和新到达的一起竞争
- 新到达的有优势(已经在 CPU 上运行)
- 吞吐量高
饥饿模式(Starving)
- 触发条件:某个 goroutine 等待超过 1ms
- 锁直接交给等待队列头部的 goroutine,新来的必须排队
- 保证公平性,避免尾延迟过高
- 退出条件:拿到锁的是队列最后一个 或 等待时间 < 1ms
这个设计兼顾了吞吐量和公平性——大多数时候运行在高吞吐的正常模式,只有在检测到饥饿时才切换到公平模式。
四、使用 Mutex 最常踩的 4 个坑
坑 1:Lock/Unlock 不配对
忘记 Unlock——最常见,也最致命:
func doSomething(mu *sync.Mutex) {
mu.Lock()
if err := process(); err != nil {
return // ❌ 忘记 Unlock,锁永远不会释放!
}
mu.Unlock()
}最佳实践:始终用 defer:
func doSomething(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // ✅ 无论如何都会释放
if err := process(); err != nil {
return // defer 保证 Unlock 一定执行
}
}误删 Lock——直接 Unlock 一个未加锁的 Mutex 会 panic:
func foo() {
var mu sync.Mutex
// mu.Lock() 被误删
defer mu.Unlock() // panic: sync: unlock of unlocked mutex
fmt.Println("hello")
}坑 2:复制已使用的 Mutex
Mutex 是 值类型,复制一个已使用的 Mutex 会把当前的锁状态也复制过去:
type Counter struct {
mu sync.Mutex
count int
}
func printCount(c Counter) { // ❌ 值传递,复制了 Mutex
c.mu.Lock()
defer c.mu.Unlock()
fmt.Println(c.count)
}
func printCount(c *Counter) { // ✅ 传指针,共享同一把锁
c.mu.Lock()
defer c.mu.Unlock()
fmt.Println(c.count)
}小技巧:用
go vet可以检测 Mutex 的复制问题。在 CI 中加入go vet ./...是一个好习惯。
坑 3:重入(Reentrant)
Go 的 Mutex 不支持重入。同一个 goroutine 对同一把锁调用两次 Lock,直接死锁:
func foo(mu *sync.Mutex) {
mu.Lock()
bar(mu) // 💀 死锁!
mu.Unlock()
}
func bar(mu *sync.Mutex) {
mu.Lock() // 同一个 goroutine 再次 Lock,永远等不到释放
// ...
mu.Unlock()
}解决方案:重构代码,把需要锁保护的逻辑抽取到内部函数,只在最外层加锁:
func foo(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock()
doWork() // 内部函数不再加锁
}
func bar(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock()
doWork() // 同一个无锁的内部函数
}
func doWork() {
// 实际业务逻辑,不操作锁
}坑 4:死锁
多把锁交叉获取,经典的 ABBA 死锁:
// goroutine 1 // goroutine 2
mu1.Lock() mu2.Lock()
mu2.Lock() // 等待 B mu1.Lock() // 等待 A
// 💀 互相等待,永远无法继续goroutine 1 goroutine 2
│ │
│── 持有 mu1 ──┐ ┌── 持有 mu2 ──│
│ │ │ │
│── 等待 mu2 ──┘ └── 等待 mu1 ──│
│ ↑ ↑ │
│ └── 循环等待 ────┘ │
│ 💀 DEADLOCK │解决方案:始终按固定顺序获取多把锁:
// 所有 goroutine 都先锁 mu1,再锁 mu2
mu1.Lock()
mu2.Lock()
// ... 业务逻辑
mu2.Unlock()
mu1.Unlock()五、读多写少?用 RWMutex
如果你的场景是 读多写少,所有读操作都用 Mutex 串行化就太浪费了。sync.RWMutex 允许多个读操作并发执行:
type Cache struct {
mu sync.RWMutex
data map[string]string
}
// 读操作:可以多个 goroutine 同时读
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
// 写操作:独占,阻塞所有读和写
func (c *Cache) Set(key string, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}RWMutex 的规则:
| 当前状态 | 新的 RLock | 新的 Lock |
|---|---|---|
| 无锁 | ✅ 获取 | ✅ 获取 |
| 被 Reader 持有 | ✅ 并发读 | ❌ 阻塞等所有读完 |
| 被 Writer 持有 | ❌ 阻塞等写完 | ❌ 阻塞等写完 |
什么时候用 RWMutex? 简单的经验法则:
- 读写比 > 10:1,RWMutex 明显优于 Mutex
- 读写比接近 1:1,直接用 Mutex,RWMutex 反而有额外开销
- 不确定的话,用 benchmark 测
六、实战建议
最后总结几条实战中的经验:
- 锁的粒度要小——只锁必须锁的代码,不要把 I/O 操作放在锁里面
- 善用
defer mu.Unlock()——防止忘记释放,尤其是多 return 分支 - 不要复制 Mutex——包含 Mutex 的结构体传指针
- 用
go vet做静态检查——自动检测锁复制等问题 - 用
-race检测数据竞争——go test -race ./...应该是 CI 标配 - 考虑是否真的需要锁——有时候
atomic、channel或sync.Map是更好的选择
# 两个应该加入 CI 的命令
go vet ./...
go test -race ./...互斥锁看起来简单,用好不容易。理解它的演进思路,知道它的常见陷阱,才能在并发编程中写出既正确又高效的代码。