保护共享数据
sync.Mutex 同一时刻只允许一个 goroutine 进入 Lock 和 Unlock 之间的代码。把互斥锁放在它所保护的数据旁边,通常就放在同一个 struct 里:
这段代码总是打印 hits: 10000。如果不加锁,100 个 goroutine 同时写同一个 map,程序通常会以 fatal error: concurrent map writes 崩溃。这个错误来自运行时的一项尽力而为的检查,它无法被 recover,而且程序没有崩溃也不能证明代码是正确的。
需要注意的细节:
sync.Mutex的零值就是未加锁、可直接使用的状态,不需要构造函数。- 方法使用指针接收者(
*Counter)。值接收者会锁住互斥锁的一个副本,什么也保护不了。 - 在
Lock之后紧跟defer c.mu.Unlock(),意味着每一条返回路径,以及发生 panic 时,都会释放锁。 - 每一次访问都要经过锁,读操作也不例外。在另一个 goroutine 写入时不加锁地读取,仍然是数据竞争。
让临界区尽量小
defer 会在函数结束时解锁。对于上面这样的短方法,这样做没问题。在较长的函数里,一旦不再访问共享数据就立即解锁,这样其他 goroutine 就不必等待那些根本不需要锁的工作:
func (s *Store) Save(key string) error {
s.mu.Lock()
data := s.items[key] // copy what you need
s.mu.Unlock()
return writeToDisk(key, data) // slow I/O, outside the lock
}
在持有锁的同时进行网络调用、磁盘 I/O 或向 channel 发送数据,是并发程序变慢最常见的原因,而在锁内向 channel 发送数据也是导致死锁的常见原因。
读多写少用 RWMutex
sync.RWMutex 有两种模式。RLock/RUnlock 获取共享读锁,多个 goroutine 可以同时持有。Lock/Unlock 获取独占写锁,它会一直等到所有读者离开。
当读操作占绝大多数,并且每次读都要在锁内做实际的工作时,RWMutex 才划算。对于像单次 map 查找这样极小的临界区,普通的 Mutex 往往一样快,因为读锁本身也有额外的簿记开销。选择之前先做基准测试。
读锁不能升级为写锁。在同一个 goroutine 中持有 RLock 时调用 Lock 会导致死锁。应先释放读锁,再获取写锁,然后重新检查条件,因为在这之间可能有别的写者已经修改了数据。
单个值用 sync/atomic
对于单个计数器或标志位,sync/atomic 比互斥锁更简单、开销更小。应该使用带类型的封装(Go 1.19):
原子操作一次只保护一个值。一旦有两个值必须一起改变(余额和交易次数,map 和它的大小),就要用互斥锁。两次独立的原子操作之间,可能穿插着其他 goroutine 的操作。
sync.Once
sync.Once 保证一个函数只执行一次,无论有多少个 goroutine 同时调用它。所有调用 Do 的 goroutine 都会等到第一次调用完成。这是实现延迟初始化的标准方式:
每一行 "runs once" 都只出现一次。如果传给 Do 的函数发生 panic,Once 仍会认为它已经执行过,不会再重试。sync.OnceValues 对返回两个值的函数做同样的事,通常是一个值和一个 error。
Mutex、channel 还是 sync.Map
| 场景 | 使用 |
|---|---|
| 多个 goroutine 原地更新的 struct 或 map | struct 中的 sync.Mutex |
| 以读为主、偶尔写入,且读操作要做实际工作 | sync.RWMutex |
| 单个计数器或标志位 | sync/atomic |
| 一次性初始化 | sync.Once、sync.OnceValue |
| 把数据从一个 goroutine 交给另一个 | channel |
| 键只写一次、读很多次的缓存,或各个 goroutine 操作互不相交的键 | sync.Map |
sync.Map 并不能通用地替代加锁的 map。它没有类型参数,所以取出的值都是 any,而且只在表中这两种情况下才更快。先从互斥锁加普通 map 开始。
常见错误
- 复制互斥锁。 按值传递一个包含
sync.Mutex的 struct,或使用值接收者,都会复制这把锁。go vet会报告passes lock by value或copies lock value。 - 在同一个 goroutine 中加锁两次。 Go 的互斥锁不可重入。如果
Inc调用了Get,而两者都要加锁,Inc就会永远阻塞。让公开方法负责加锁,私有辅助函数假定锁已被持有。 - 提前返回时忘记解锁。 除非有理由不这么做,否则就用
defer。 - 以不同的顺序加锁。 如果一个 goroutine 先拿锁 A 再拿锁 B,而另一个先拿 B 再拿 A,两者可能永远互相等待。获取多把锁时,始终按同样的顺序。
- 暴露受保护的数据。 从方法里返回内部的 map,调用方就能不加锁地读写它。应返回一份副本(
maps.Clone,Go 1.21)或单个值。 - 只保护写操作。 不加锁的读与加锁的写并发执行,仍然是竞争。用
go test -race运行你的测试。
常见问题
Go 中的 mutex 是什么?
sync.Mutex 是一把锁,同一时刻只允许一个 goroutine 执行 mu.Lock() 和 mu.Unlock() 之间的代码。你用它来保护被多个 goroutine 同时读写的数据,比如 map 或 struct。它的零值就是一把未加锁的互斥锁,可以直接使用。
什么时候该用 RWMutex 而不是 Mutex?
当读操作远多于写操作,并且每次读持有锁的时间不算太短时。RLock 允许任意多个读者同时进入,而 Lock 要等待独占访问。对于很短的临界区,普通的 Mutex 往往一样快甚至更快,所以切换之前先测一测。
Go 的 map 能并发使用吗?
不能。并发读没有问题,但只要有一个写操作和其他读或写同时发生,就是数据竞争,运行时通常会检测到并崩溃,报 fatal error: concurrent map writes(或 concurrent map read and map write)。用 sync.Mutex 或 sync.RWMutex 保护 map,或者在适合的特定场景下使用 sync.Map。
Go 的 sync.Mutex 是可重入的吗?
不是。如果已经持有锁的 goroutine 再次调用 Lock,它会永远阻塞,等待它自己释放锁。组织代码时,让导出的方法负责加锁,再调用那些假定锁已被持有的未导出辅助函数。