文章目录
- [20 - Go 互斥锁:Mutex 与并发安全](#20 - Go 互斥锁:Mutex 与并发安全)
- 核心概念
- 基础使用示例
- 进阶使用示例
-
- [场景一:并发安全的 Map](#场景一:并发安全的 Map)
- 场景二:计数器封装(面向对象思维)
- [场景三:读多写少优化(引出 RWMutex)](#场景三:读多写少优化(引出 RWMutex))
- 常见错误与坑(重点)
-
- [坑一:忘记 Unlock(导致死锁)](#坑一:忘记 Unlock(导致死锁))
- [坑二:重复 Unlock(panic)](#坑二:重复 Unlock(panic))
- 坑三:锁拷贝(极其隐蔽)
- 坑四:锁范围过大(性能问题)
- 底层原理解析(核心)
- 对比与扩展
-
- [Mutex vs RWMutex](#Mutex vs RWMutex)
- [Mutex vs Channel](#Mutex vs Channel)
- 最佳实践
- 思考与升华(加分项)
-
- [如果让你实现一个 Mutex?](#如果让你实现一个 Mutex?)
- 本质提炼
- 点睛总结
20 - Go 互斥锁:Mutex 与并发安全
在 Go 的并发编程中,goroutine 让并发变得非常轻松,但"并发安全"却远没有那么简单。很多线上问题,往往不是代码写不出来,而是写出来的代码在并发场景下悄悄出错。
这篇文章,我们就把 sync.Mutex 从"会用"讲到"看透"。
核心概念
解决什么问题?
在多个 goroutine 同时访问共享资源时,如果没有同步机制,就会出现:
- 数据竞争(race condition)
- 数据不一致
- 程序行为不可预测
例如:
go
package main
import (
"fmt"
)
var count int
func main() {
for i := 0; i < 10; i++ {
go func() {
count++
}()
}
fmt.Println("equal to:", count) // 输出可能不是10,而是小于10
}
理论上 count 应该是 10,但实际几乎不可能正确。
👉 原因:count++ 不是原子操作,它包含:
- 读
- 修改
- 写回
多个 goroutine 会互相覆盖。
本质是什么?
Mutex 的本质是:
一种对共享资源进行"排他访问控制"的机制
也就是说:
- 同一时间只允许一个 goroutine 进入临界区
- 其他 goroutine 必须等待
从设计角度看:
- 它是"控制并发访问顺序"的工具
- 而不是"提高性能"的工具(甚至可能降低性能)
小结
- 并发 ≠ 安全
- Mutex 的核心是"控制",不是"并发能力"
基础使用示例
最简单的例子:保护一个共享变量
go
package main
import (
"fmt"
"sync"
)
func main() {
// 并发安全的计数器
var count int
// 互斥锁
var mu sync.Mutex
// 等待组
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
// 等待组计数增加
wg.Add(1)
go func() {
// 等待组计数减少,保证主goroutine在所有子goroutine执行完毕后才退出
defer wg.Done()
// 加锁,保证并发安全
mu.Lock()
// 并发安全地增加计数器
count++
// 解锁,释放锁
mu.Unlock()
}()
}
// 等待所有子goroutine执行完毕
wg.Wait()
fmt.Println("count =", count) // 输出:count = 10
}
关键点说明
mu.Lock():进入临界区mu.Unlock():释放锁defer可避免忘记释放锁(推荐)
小结
- 锁的粒度要尽量小
- 临界区代码越短越好
进阶使用示例
场景一:并发安全的 Map
👉 Go 原生 map 在并发写时是不安全的
不是"可能错",而是:
直接 panic(程序崩溃)
来看一个最简单的错误示例:
go
package main
func main() {
m := make(map[int]int)
for i := 0; i < 10; i++ {
go func(i int) {
m[i] = i // 并发写
}(i)
}
select {}
}
运行后你大概率会看到:
go
fatal error: concurrent map writes
根本原因(关键)
map 内部结构(哈希表)在写入时会发生:
- 扩容(rehash)
- bucket 重排
- 指针修改
👉 这些操作不是原子的
如果多个 goroutine 同时写:
- 结构会被破坏
- Go 直接 panic,避免更严重的数据错误
小结
- map 并发读:有风险(可能读到脏数据)
- map 并发写:直接炸(panic)
改造:
go
package main
import (
"fmt"
"sync"
"time"
)
// SafeMap 是一个线程安全的 map
type SafeMap struct {
mu sync.Mutex
m map[string]int
}
// Set 设置键值对
func (s *SafeMap) Set(key string, value int) {
s.mu.Lock() // 加锁
fmt.Println("写入开始:", key)
s.m[key] = value // 写入键值对
fmt.Println("写入结束:", key)
s.mu.Unlock() // 解锁
}
// Get 获取键对应的值
func (s *SafeMap) Get(key string) int {
s.mu.Lock() // 加锁
fmt.Println("读取开始:", key)
value := s.m[key] // 获取键对应的值
fmt.Println("读取结束:", key)
s.mu.Unlock() // 解锁
return value // 返回键对应的值
}
// NewSafeMap 创建 SafeMap 的实例
func NewSafeMap() *SafeMap {
// 初始化 map
return &SafeMap{
m: make(map[string]int),
}
}
// main 函数
func main() {
s := NewSafeMap()
go s.Set("a", 1) // 并发设置键值对
go s.Set("b", 2)
go s.Set("c", 3)
time.Sleep(time.Second) // 等待足够的时间让所有 goroutine 运行
}
输出:
bath
写入开始: c
写入结束: c
写入开始: a
写入结束: a
写入开始: b
写入结束: b
SafeMap:
核心点只有一句话:
👉 在访问 map 前,加锁
SafeMap 做的事情:
- ❌ 没有让 map 支持并发
- ✅ 是让并发访问"变成串行"
场景二:计数器封装(面向对象思维)
go
package main
import (
"fmt"
"sync"
)
// Counter 是一个线程安全的计数器
type Counter struct {
mu sync.Mutex // 互斥锁,用于保护计数器的并发访问
value int // 计数器的值
}
// Inc 方法用于增加计数器的值
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
// Get 方法用于获取计数器的当前值
func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
// main 函数演示了如何使用 Counter
func main() {
var c Counter // 创建一个 Counter
c.Inc() // 增加计数器的值
fmt.Println(c.Get()) // 输出: 1
c.Inc() // 再次增加计数器的值
fmt.Println(c.Get()) // 输出: 2
}
👉 优点:
- 封装并发控制
- 调用者无需关心锁
场景三:读多写少优化(引出 RWMutex)
go
package main
import (
"fmt"
"sync"
"time"
)
var mu sync.RWMutex // 读写锁
var data int // 演示读写锁的使用
func read() int { // 演示读操作
mu.RLock() // 加读锁
defer mu.RUnlock() // 释放读锁
return data // 读取数据
}
func write(v int) { // 演示写操作
mu.Lock() // 加写锁
defer mu.Unlock() // 释放写锁
data = v // 写入数据
}
func main() { // 主函数
go write(1) // 写操作
// time.Sleep(time.Second) // 等待足够的时间让 write goroutine 运行然后输出结果是1
v := read() // 读操作
fmt.Println("read:", v) // 输出结果
// 输出是 0,为什么? 为什么不是 1?
// 因为在执行read的时候,另外一个goroutine还没有完成write操作。
// 所以,在读的时候,数据还没有更新完,所以读到的还是原来的值。
}
小结
- 锁可以作为结构体的一部分
- 推荐"锁内聚",不要让调用者手动控制
常见错误与坑(重点)
坑一:忘记 Unlock(导致死锁)
这是使用 sync.Mutex 最常见、也是最隐蔽的坑之一。代码能编译、也可能在测试阶段正常,但一旦进入复杂分支,就可能直接卡死。
错误示例
go
package main
import (
"fmt"
"sync"
"time"
)
var mu sync.Mutex
func main() {
go worker(1) // 启动第一个goroutine
go worker(2) // 启动第二个goroutine
time.Sleep(time.Second * 2) // 等待2秒,确保goroutine执行完成
}
func worker(i int) {
fmt.Println("worker 正在等待获取锁", i) // 等待获取锁
mu.Lock() // 获取锁
if true {
fmt.Println("worker 获取到锁", i)
return
}
fmt.Println("worker 没有获取到锁", i) // 永远不会执行到这行代码
mu.Unlock() // 解锁操作
}
输出:
bath
worker 正在等待获取锁 2
worker 获取到锁 2
worker 正在等待获取锁 1
执行流程(关键理解)
假设有两个 goroutine:A 和 B
- A 执行
mu.Lock()→ 成功拿到锁 - A 进入
if someCondition分支 → 直接return - ❗ A 没有执行
mu.Unlock()
此时系统状态:
- 锁仍然是"已加锁状态"
- 但持锁的 goroutine A 已经退出
- 没有人再去释放这把锁
接下来:
- B 执行
mu.Lock()→ 尝试获取锁 - 但锁已经被"永久占用"
👉 B 会一直阻塞
为什么会错?(底层原因)
Mutex 本身不会"自动释放锁",它只是一个状态:
- Lock → 修改状态为"已锁定"
- Unlock → 修改状态为"未锁定"
如果:
- Lock 之后没有 Unlock
- 那么这个状态永远不会被恢复
👉 本质就是:
资源被占用,但没有释放路径 → 死锁
正确写法(推荐)
go
package main
import (
"fmt"
"sync"
"time"
)
var mu sync.Mutex
func main() {
go worker(1) // 启动第一个goroutine
go worker(2) // 启动第二个goroutine
time.Sleep(time.Second * 2) // 等待2秒,确保goroutine执行完成
}
func worker(i int) {
fmt.Println("worker 正在等待获取锁", i) // 等待获取锁
mu.Lock() // 获取锁
defer mu.Unlock() // 延迟解锁操作
if true {
fmt.Println("worker 获取到锁", i)
return
}
fmt.Println("worker 没有获取到锁", i) // 永远不会执行到这行代码
// mu.Unlock() // 解锁操作
}
输出:
bath
worker 正在等待获取锁 2
worker 获取到锁 2
worker 正在等待获取锁 1
worker 获取到锁 1
为什么 defer 能解决?
defer 的执行规则是:
在函数返回之前,一定会执行(无论从哪个 return 出去)
执行流程变成:
- 执行
mu.Lock() - 注册
defer mu.Unlock() - 进入 if → return
- ❗ 在真正返回前,先执行
mu.Unlock()
👉 锁一定会被释放
更隐蔽的真实场景(高危)
go
mu.Lock()
if err != nil {
return err
}
if x > 10 {
return nil
}
mu.Unlock()
问题:
- 多个 return 分支
- 极容易漏掉 Unlock
改进写法
go
mu.Lock()
defer mu.Unlock()
if err != nil {
return err
}
if x > 10 {
return nil
}
小结
- 只要 Lock 之后存在"提前 return"的可能,就必须用
defer Unlock - 手动 Unlock 在复杂逻辑中极易遗漏
- 死锁问题往往不是"报错",而是程序卡住不动
思考点
如果你的函数有多个 return 分支,你能保证每一条路径都执行了 Unlock 吗?
如果不能:
👉 那就不要手动 Unlock,用 defer 托底。
坑二:重复 Unlock(panic)
错误示例
go
package main
import (
"fmt"
"sync"
)
var mu sync.Mutex
func main() {
mu.Lock() // 第一次加锁
fmt.Println("Locked") // 打印 Locked
mu.Unlock() // 第一次解锁
mu.Unlock() // 第二次解锁,会引发 panic
}
原因
Mutex 内部状态检测:
- 未锁定状态调用 Unlock → 直接 panic
正确写法
保证 Lock/Unlock 成对出现
坑三:锁拷贝(极其隐蔽)
错误示例
go
package main
import (
"fmt"
"sync"
)
// 定义一个计数器结构体
type Counter struct {
mu sync.Mutex
val int
}
// 定义一个增加计数的方法
func (c Counter) Inc() { // 这里应该使用指针接收者,以便修改原始对象的值
c.mu.Lock() // 加锁
fmt.Println("1") // 打印1
defer c.mu.Lock() // 等待解锁
c.val++ // 增加计数
}
func main() {
c := Counter{} // 创建计数器实例
c.Inc() // 调用增加计数的方法
}
为什么会错?
c是副本mu也被复制- 每个 goroutine 用的是不同锁
👉 实际没有加锁!
正确写法
go
package main
import (
"fmt"
"sync"
)
// 互斥锁的使用
type Counter struct {
mu sync.Mutex
val int
}
// 加锁的临界区
func (c *Counter) Inc() {
c.mu.Lock() // 加锁
fmt.Println("1") // 临界区
defer c.mu.Unlock() // 解锁
c.val++ // 临界区
}
func main() {
c := Counter{} // 初始化
c.Inc() // 加锁
}
坑四:锁范围过大(性能问题)
错误示例
go
mu.Lock()
time.Sleep(time.Second) // 非必要操作
mu.Unlock()
原因
- 长时间持锁
- 阻塞其他 goroutine
正确写法
go
time.Sleep(time.Second)
mu.Lock()
data++
mu.Unlock()
小结
- 锁错误往往不会报错,但会"悄悄错"
- 最危险的是"看起来对,其实没锁"
底层原理解析(核心)
Go 的 sync.Mutex 不是简单的"加锁标志",它是一个复杂的状态机。
核心结构(简化)
go
type Mutex struct {
state int32
sema uint32
}
state 含义
- 是否被锁
- 是否有等待者
- 是否进入饥饿模式
加锁流程(Lock)
大致流程:
-
CAS 尝试抢锁(无锁 → 上锁)
-
成功:直接返回(快路径)
-
失败:
- 自旋(短时间忙等)
- 进入阻塞(挂起)
解锁流程(Unlock)
-
修改 state
-
如果有等待者:
- 唤醒一个 goroutine(通过 sema)
是否使用原子操作?
是的:
- 使用
atomic.CompareAndSwap - 避免系统调用(性能关键)
是否有阻塞机制?
有:
- 基于 runtime 的信号量(sema)
- 调度器负责挂起/唤醒 goroutine
为什么这样设计?
核心目标:
- 低延迟(无竞争时)
- 公平性(避免饥饿)
- 性能(减少上下文切换)
设计策略:
- 先自旋(避免频繁阻塞)
- 再阻塞(避免 CPU 空转)
- 饥饿模式(防止一直抢不到锁)
小结
Mutex ≠ 一把锁
而是:
原子操作 + 自旋 + 阻塞队列 的组合体
对比与扩展
Mutex vs RWMutex
| 特性 | Mutex | RWMutex |
|---|---|---|
| 并发读 | ❌ | ✅ |
| 写互斥 | ✅ | ✅ |
| 性能 | 一般 | 读多写少更优 |
Mutex vs Channel
Mutex
- 控制访问(共享内存)
- 适合:状态修改
Channel
- 传递数据(通信)
- 适合:任务流转
👉 Go 的经典理念:
不要通过共享内存来通信,而要通过通信来共享内存
小结
- 控制 vs 通信
- 锁 vs 数据流
最佳实践
在实际工程中:
- 锁尽量封装在结构体内部
- 临界区越小越好
- 优先考虑是否可以不用锁(channel / immutable)
- 不要复制带锁的结构体
- 用
defer Unlock保证安全 - 避免嵌套锁(容易死锁)
- 高并发场景考虑 RWMutex 或原子操作
思考与升华(加分项)
如果让你实现一个 Mutex?
一个简化版思路:
go
type MyMutex struct {
locked int32
}
func (m *MyMutex) Lock() {
for !atomic.CompareAndSwapInt32(&m.locked, 0, 1) {
// 自旋
}
}
func (m *MyMutex) Unlock() {
atomic.StoreInt32(&m.locked, 0)
}
问题:
- 没有阻塞机制(CPU 飙升)
- 没有公平性
- 没有等待队列
👉 这就是为什么 Go 的 Mutex 设计复杂。
本质提炼
- Mutex 是"控制工具"
- Channel 是"通信工具"
更高一层:
并发的本质不是"同时执行",而是"如何协调执行"
点睛总结
Mutex 的价值,不在于"加锁",而在于让混乱的并发世界变得可控。
当你真正理解它的设计时,你写的不只是线程安全代码,而是"可预测的系统"。