go 并发锁 Mutex
Mutex的概念
- Go sync包提供了两种锁类型:互斥锁sync.Mutex 和 读写互斥锁sync.RWMutex,都属于悲观锁。
- Mutex是互斥锁,当一个 goroutine 获得了锁后,其他 goroutine 不能获取锁(只能存在一个写者或读者,不能同时读和写)
使用场景
多个线程同时访问临界区,为保证数据的安全,锁住一些共享资源, 以防止并发访问这些共享数据时可能导致的数据不一致问题
底层实现结构
互斥锁对应的是底层结构是sync.Mutex结构体,,位于 src/sync/mutex.go中
type Mutex struct { state int32 sema uint32 }
state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式
sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒
操作
锁的实现一般会依赖于原子操作、信号量,通过atomic 包中的一些原子操作来实现锁的锁定,通过信号量来实现线程的阻塞与唤醒
加锁
通过原子操作cas加锁,如果加锁不成功,根据不同的场景选择自旋重试加锁或者阻塞等待被唤醒后加锁
func (m *Mutex) Lock() { // Fast path: 幸运之路,一下就获取到了锁 if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { return } // Slow path:缓慢之路,尝试自旋或阻塞获取锁 m.lockSlow() }
解锁
通过原子操作add解锁,如果仍有goroutine在等待,唤醒等待的goroutine
func (m *Mutex) Unlock() { // Fast path: 幸运之路,解锁 new := atomic.AddInt32(&m.state, -mutexLocked) if new != 0 { // Slow path:如果有等待的goroutine,唤醒等待的goroutine m.unlockSlow() } }
注意
在 Lock() 之前使用 Unlock() 会导致 panic 异常
使用 Lock() 加锁后,再次 Lock() 会导致死锁(不支持重入),需Unlock()解锁后才能再加锁
锁定状态与 goroutine 没有关联,一个 goroutine 可以 Lock,另一个 goroutine 可以 Unlock
互斥锁允许自旋的条件
- 一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁也叫做自旋锁,它不用将线程阻塞起来, 适用于并发低且程序执行时间短的场景,缺点是cpu占用较高
- 另外一种处理方式就是把自己阻塞起来,会释放CPU给其他线程,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒该线程,适用于高并发场景,缺点是有线程上下文切换的开销
Go语言中的Mutex实现了自旋与阻塞两种场景,当满足不了自旋条件时,就会进入阻塞
允许自旋的条件
- 锁已被占用,并且锁不处于饥饿模式。
- 积累的自旋次数小于最大自旋次数(active_spin=4)。
- cpu 核数大于 1。
- 有空闲的 P。
- 当前 goroutine 所挂载的 P 下,本地待运行队列为空
可重入锁
可重入锁又称为递归锁,是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以。(通俗来说:可重入锁也是一种技术,任意线程在获取锁之后,能够再次获取锁,而不会被锁所排斥)可重入锁的意义之一在于防止死锁
Mutex不是可重入锁
Mutex 不是可重入的锁。Mutex 的实现中没有记录哪个 goroutine 拥有这把锁。理论上,任何 goroutine 都可以随意地 Unlock 这把锁,所以没办法计算重入条件,并且Mutex 重复Lock会导致死锁
下面这个例子就会报错:
package main import ( “fmt” “sync” ) var mu sync.Mutex var chain string func A(){ mu.Lock() defer mu.Unlock() chain = chain + “–>A” B() } func B(){ chain = chain + “–>B” C() } func C(){ mu.Lock() defer mu.Unlock() chain = chain + “–>C” } func main() { chain = “main” A() fmt.Println(chain) }
实现可重入锁
- 记住持有锁的线程
- 统计重入的次数
正常模式(非公平锁)
在刚开始的时候,是处于正常模式(Barging),也就是,当一个G1持有着一个锁的时候,G2会自旋的去尝试获取这个锁
当自旋超过4次还没有能获取到锁的时候,这个G2就会被加入到获取锁的等待队列里面,并阻塞等待唤醒
正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒的goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 竞争锁。新请求锁的 goroutine 具有优势:它正在 CPU 上执行,而且可能有好几个,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败,长时间获取不到锁,就会切换到饥饿模式
饥饿模式(公平锁)
当一个 goroutine 等待锁时间超过 1 毫秒时,它可能会遇到饥饿问题。 在版本1.9中,这种场景下Go Mutex 切换到饥饿模式(handoff),解决饥饿问题
如果一个G进入了饥饿模式,那也不可能说永远的保持一个饥饿的状态,总归会有吃饱的时候,也就是总有那么一刻Mutex会回归到正常模式,那么回归正常模式必须具备的条件有以下几种:
G的执行时间小于1ms
等待队列已经全部清空了
当满足上述两个条件的任意一个的时候,Mutex会切换回正常模式,而Go的抢锁的过程,就是在这个正常模式和饥饿模式中来回切换进行的
读写互斥锁RWMutex
读写互斥锁RWMutex,是对Mutex的一个扩展,当一个 goroutine 获得了读锁后,其他 goroutine可以获取读锁,但不能获取写锁;当一个 goroutine 获得了写锁后,其他 goroutine既不能获取读锁也不能获取写锁(只能存在一个写者或多个读者,可以同时读)
使用场景
读多于写的情况(既保证线程安全,又保证性能不太差)
注意点
读锁或写锁在 Lock() 之前使用 Unlock() 会导致 panic 异常
使用 Lock() 加锁后,再次 Lock() 会导致死锁(不支持重入),需Unlock()解锁后才能再加锁
锁定状态与 goroutine 没有关联,一个 goroutine 可以 RLock(Lock),另一个 goroutine 可以 RUnlock(Unlock)
互斥锁和读写锁的区别
- 读写锁区分读者和写者,而互斥锁不区分
- 互斥锁同一时间只允许一个线程访问该对象,无论读写;读写锁同一时间内只允许一个写者,但是允许多个读者同时读对象