20 - Go 互斥锁: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)

大致流程:

  1. CAS 尝试抢锁(无锁 → 上锁)

  2. 成功:直接返回(快路径)

  3. 失败:

    • 自旋(短时间忙等)
    • 进入阻塞(挂起)

解锁流程(Unlock)

  1. 修改 state

  2. 如果有等待者:

    • 唤醒一个 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 的价值,不在于"加锁",而在于让混乱的并发世界变得可控。

当你真正理解它的设计时,你写的不只是线程安全代码,而是"可预测的系统"。

相关推荐
郝学胜-神的一滴1 小时前
深入epoll封装:event_set与event_add核心原理剖析
linux·服务器·开发语言·网络·c++·unix
gCode Teacher 格码致知1 小时前
Javascript提高:国际化 API(Intl 对象)详解-由Deepseek产生
开发语言·javascript·ecmascript
cany10001 小时前
C++ -- 模板使用进阶
开发语言·c++
littleM2 小时前
深度拆解 HermesAgent(六):研究功能与测试体系
开发语言·人工智能·python·架构·ai编程
小年糕是糕手2 小时前
【C/C++刷题集】栈、stack、队列、queue核心精讲
c语言·开发语言·数据结构·数据库·c++·算法·蓝桥杯
geovindu2 小时前
go: Observer Pattern
开发语言·观察者模式·设计模式·golang
机跃2 小时前
指针(c++)
开发语言·c++
代码羊羊2 小时前
Rust Panic 深入全解:不可恢复错误的处理与原理
开发语言·后端·rust
枫叶丹42 小时前
【HarmonyOS 6.0】Call Service Kit VoIP接口Wearable设备支持详解:从手机到手表,VoIP通话的全场景延伸
开发语言·华为·智能手机·harmonyos