1. 前言
Goroutine 是 Go 语言最亮眼的特性,轻量协程让并发代码写起来很方便。但很多新手写多协程代码时,会遇到一个诡异问题:明明逻辑看着没问题,多次运行结果却不一样。
这就是竞态条件,而sync包里的锁,就是用来解决这个问题的。
网上不少讲解 sync.Mutex 和 RWMutex 的文章,一上来就切入底层状态机、源码实现,刚学 Go 的同学很容易看得头大。
这篇文章,我们不从底层源码开始,而是从问题出发,用极简的代码例子搞懂两件事:
- 互斥锁 Mutex 到底做了什么,怎么正确使用,会踩哪些坑
- 读写锁 RWMutex 和普通互斥锁的区别,什么时候才该选用读写锁
读完之后,你能分清两种锁,并且知道在业务代码里怎么选,避开新手高频 bug。
前置基础:知道什么是 goroutine,简单了解并发、竞态条件。
2. 先搞懂:为什么需要锁?竞态条件是什么
先看一个计数器示例:多个 goroutine 同时修改同一个变量,结果和预期不一致。
package main import ( "fmt" "time" ) func main() { cnt := 0 for i := 0; i < 1000; i++ { go func() { cnt++ }() } time.Sleep(100 * time.Millisecond) fmt.Println("cnt =", cnt) }多次运行,你会发现输出不是固定 1000,每次数值不一样。
原因:cnt++不是原子操作,会拆成「读取值、+1、写回」三步,多个协程同时执行,就会互相覆盖,出现数据丢失。这就是竞态条件。想要修复,就要加锁。
一句话总结:多个协程同时读写同一份共享数据,就会产生竞态,锁就是用来保护共享资源的工具。
3. sync.Mutex 互斥锁:最简单的排他锁
核心特性:同一时间,只能有一个 goroutine 拿到锁,不管读还是写,拿到锁才能操作共享变量,操作完解锁,其他协程才有机会获取锁。
两个核心方法:
mu.Lock():尝试获取锁,如果锁已经被别人拿着,当前 goroutine 阻塞等待mu.Unlock():释放锁,其他等待的协程可以竞争获取
最佳实践:用
defer mu.Unlock(),保证即使临界区代码 panic,锁也一定会释放,避免死锁。
下面用 Mutex 修复上面的计数器,完整可运行:
package main import ( "fmt" "sync" "time" ) func main() { var mu sync.Mutex cnt := 0 for i := 0; i < 1000; i++ { go func() { mu.Lock() cnt++ mu.Unlock() }() } time.Sleep(100 * time.Millisecond) fmt.Println("cnt =", cnt) }多次运行,输出稳定等于1000,竞态问题消失。
关键要点:
- Unlock 必须执行,推荐
defer mu.Unlock()。 - 禁止锁复制!把 Mutex 当成值拷贝,会复制一把全新未上锁的锁,防护直接失效。
- 常见坑:忘记解锁(死锁)、重复解锁(panic)。
结构体里带 Mutex,传参一律用指针。
❌ 错误示例:
type Counter struct { mu sync.Mutex cnt int } // 传值,锁被拷贝,防护失效 func (c Counter) Add() { c.mu.Lock() defer c.mu.Unlock() c.cnt++ }
- 不要在
Lock()之前写defer mu.Unlock()
// ❌ 错误!还没拿到锁,就注册了解锁,运行直接panic defer mu.Unlock() mu.Lock()defer 的执行逻辑:
defer的作用:注册一个函数,等到当前函数退出的时候才执行,但注册动作是立刻发生的。
代码执行顺序:
- 执行
defer mu.Unlock()→立刻注册:函数结束时,调用mu.Unlock() - 然后执行
mu.Lock(),尝试获取锁(有可能阻塞,拿不到锁)
重复 Unlock 会直接 panic;同一个 goroutine 重复 Lock 直接死锁。
4. sync.RWMutex 读写锁:读多写少场景优化
核心规则
sync.RWMutex读写锁,把操作分成读锁 RLock ()/RUnlock ()和写锁 Lock ()/Unlock ()规则记住这 4 条就够:
- ✅读读并行:多个 goroutine 可以同时拿到读锁,一起读数据,提升并发读性能
- ❌读写互斥:有协程持有读锁,写锁需要等待;有写锁,读锁要等待
- ❌写写互斥:同一时间只能一个协程拿写锁
- 写锁优先级较高:申请写锁到来后,后续新来的读锁会被阻塞,防止写饥饿
适用场景:读多写少,比如本地缓存、配置信息,大量查询,修改操作很少。
下面用 RWMutex 实现一个简单的配置缓存,对比 Mutex 看读多场景下的性能优势:
package main import ( "fmt" "sync" ) type Config struct { mu sync.RWMutex data map[string]string } func (c *Config) Get(key string) string { c.mu.RLock() defer c.mu.RUnlock() return c.data[key] } func (c *Config) Set(key, value string) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = value } func main() { cfg := &Config{data: make(map[string]string)} cfg.Set("theme", "dark") fmt.Println("theme =", cfg.Get("theme")) }误区:不是所有场景都用 RWMutex!写频繁场景,RWMutex 开销反而大于普通 Mutex。
RWMutex 常见误区
- ❌ 不是万能加速!写频繁场景不要上 RWMutex。读写锁内部逻辑更复杂,写多的时候开销比普通 Mutex 更大,性能反而变差。
- ❌ 读锁只是保护读,读的时候如果要修改数据,必须上写锁。
- 配套成对使用:
RLock()必须搭配RUnlock(),不能混用Lock/Unlock。
5. Mutex vs RWMutex 快速对比表
| 锁 | 规则 | 适用场景 |
|---|---|---|
| sync.Mutex | 读写互斥,同一时间仅一个协程访问 | 读写频率差不多,或者写操作频繁 |
| sync.RWMutex | 读读并行,读写互斥、写写互斥 | 读多写少 |
6. 高频踩坑合集(重点,博客收藏点)
建议:锁保护范围尽量缩小,只包裹读写共享变量的那一小段临界区代码。
- defer Unlock 顺序错误:必须先 Lock,再注册 defer 解锁,顺序颠倒直接 panic
- 锁拷贝:结构体包含锁,方法接收者不用指针,锁拷贝导致并发保护失效
- 死锁场景:同一个 goroutine 重复 Lock;协程 A 持有锁等待 B,协程 B 持有锁等待 A,循环等待
- RWMutex 滥用:写操作并不少,强行换成读写锁,增加额外开销,收益很小
- 锁粒度太大:一把大锁包裹大量无关业务逻辑,并发直接退化成串行,失去 goroutine 并发优势
7. 什么时候尽量不要用锁?
Go 设计理念:不要通过共享内存通信;通过通信共享内存。
简单说:优先用 channel 做协程之间消息传递,而不是直接共享变量加锁。
举例子:简单计数器可以用 channel 来实现,完全规避竞态。
package main import ( "fmt" ) func main() { ch := make(chan int, 1) done := make(chan struct{}) go func() { cnt := 0 for i := 0; i < 1000; i++ { cnt++ } ch <- cnt close(done) }() <-done fmt.Println("cnt =", <-ch) }思路:channel 作为唯一通道,所有协程只能通过 channel 来读写计数,天然串行化访问共享数据,消除竞态。
当然 channel 也有适用边界,复杂状态管理场景,锁依然是更合适的选择。
锁是兜底方案,不是并发首选方案。
8. 小结
sync.Mutex:排他互斥锁,简单可靠,是并发保护的通用首选。读写差不多、写频繁优先选它。sync.RWMutex:读写锁,仅读多写少场景才有性能收益,不要盲目使用。锁的核心目的:保护共享变量,消除竞态条件。
编码准则:锁粒度越小越好,用完尽快释放;永远不要拷贝锁。
Go 并发哲学:优先 channel,锁作为兜底方案。
9. 附录:完整源码汇总(可直接复制运行)
下面是本文所有示例代码的汇总,方便读者直接复制运行、修改测试。
9.1 无锁计数器(错误示例)
package main import ( "fmt" "time" ) func main() { cnt := 0 for i := 0; i < 1000; i++ { go func() { cnt++ }() } time.Sleep(100 * time.Millisecond) fmt.Println("cnt =", cnt) }9.2 Mutex 修复计数器
package main import ( "fmt" "sync" "time" ) func main() { var mu sync.Mutex cnt := 0 for i := 0; i < 1000; i++ { go func() { mu.Lock() cnt++ mu.Unlock() }() } time.Sleep(100 * time.Millisecond) fmt.Println("cnt =", cnt) }9.3 RWMutex 配置缓存
package main import ( "fmt" "sync" ) type Config struct { mu sync.RWMutex data map[string]string } func (c *Config) Get(key string) string { c.mu.RLock() defer c.mu.RUnlock() return c.data[key] } func (c *Config) Set(key, value string) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = value } func main() { cfg := &Config{data: make(map[string]string)} cfg.Set("theme", "dark") fmt.Println("theme =", cfg.Get("theme")) }9.4 Channel 实现计数器(无锁方案)
package main import ( "fmt" ) func main() { ch := make(chan int, 1) done := make(chan struct{}) go func() { cnt := 0 for i := 0; i < 1000; i++ { cnt++ } ch <- cnt close(done) }() <-done fmt.Println("cnt =", <-ch) }你写 Go 并发时踩过锁的坑吗?死锁、锁复制、defer 顺序错误这类问题都可以在评论聊聊。收藏本文,可在评论区留下疑惑,将为你更新 sync 包疑惑的通俗讲解。