Go语言修炼(十):Go语言的互斥锁是怎么工作的?
前言
互斥锁,顾名思义,是提供互斥(Mutual Exclusion)能力的锁机制,确保同一时间只有一个goroutine能够访问某个特定的资源或代码段。在Go的标准库中,sync
包为我们提供了强大的同步原语,其中sync.Mutex
是实现互斥锁功能的关键。掌握sync.Mutex
的工作原理和使用方法,对于编写高效、安全的并发程序至关重要。
接下来,我们将深入探讨Go语言中互斥锁的实现细节,包括它的基本使用方法、内部机制、以及如何正确地应用它来解决并发编程中常见的竞争条件和数据不一致问题。让我们一同揭开Go语言互斥锁的神秘面纱,看看它是如何保护我们的程序免受并发访问的困扰的。
互斥锁的结构
在业务开发中,一般我们会在结构体中加一个sync.Mutex
对象,然后在需要并发的场景下对业务代码包含mu.Lock()
和mu.Unlock()
,以此来达到加锁的目的。
其实从实现加锁的效果来讲,我们好像也可以设置一个int变量,然后用CAS
来设置0
或者1
。然而,多个协程去执行CAS往往会卡住,在高并发竞争激烈的情况下不可取。
我们进入sync
包,查看sync.Mutex
的数据结构:
type Mutex struct {
state int32
sema uint32
}
我们可以看到sync.Mutex
主要包含两个字段:
state
是int32的值,其不同的二进制位具有不同的含义。最后三个二进制位从后往前,依次是:lock位置,表示是否被锁;Woken位,表示是否被唤醒;Starving位,表示是否为饥饿模式。sema
:上一篇文章有介绍,这里sync.Mutex
的sema
初值是0,所以这里sema
表示一个协程休眠队列。
我们可以用一张图来表示这个数据结构:
互斥锁的工作方式:正常模式
正常模式 加锁
正常模式下,互斥锁的Lock
的工作方式可以总结如下:
- 首先尝试CAS直接加锁
- 如果加锁失败,进行多次自旋尝试
- 自旋达到一定次数仍然加锁失败,会将协程加入到休眠队列
我们可以查看一下Lock
方法的源码,下面代码只写了关键逻辑,有详细的注释:
func (m *Mutex) Lock() {
// 直接尝试使用CAS加锁:也就是将lock位从0修改为1
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
// 如果加锁成功,直接返回
return
}
// CAS加锁失败,继续往下调用
m.lockSlow()
}
func (m *Mutex) lockSlow() {
// 忽略其它逻辑
for {
// 锁被其它协程占用了,且当前状态没有处于饥饿模式,且可以继续自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 继续自旋获取锁
runtime_doSpin()
iter++
old = m.state
continue
}
// 自旋次数超过上限,跳出来,再次尝试获取锁。
if old&mutexStarving == 0 {
new |= mutexLocked
}
// 如果锁还是被其它协程占用,或者处于饥饿状态,准备将协程加入休眠队列,先将等待数+1
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
// 将状态写回state
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 将协程加入休眠队列
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 被释放后执行下方代码,主要是饥饿模式的逻辑
// 非饥饿模式会直接重新跳到for开头
}
}
}
正常模式 解锁
正常模式下,互斥锁的UnLock
的工作方式可以总结如下:
- 尝试CAS直接解锁
- 若发现有协程在sema中休眠,唤醒一个协程
我们可以查看一下UnLock
方法的源码,下面代码只写了关键逻辑,有详细的注释:
func (m *Mutex) Unlock() {
// 尝试解锁,将state将状态最后一位减1
new := atomic.AddInt32(&m.state, -mutexLocked)
// 如果new = 0表示解锁成功
// 解锁不成功进入下方的逻辑
if new != 0 {
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
// 锁目前不是饥饿状态
if new&mutexStarving == 0 {
for {
// 如果休眠队列没有多余的线程,直接返回
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// 否则,从休眠队列释放一个协程
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
}
}
值得注意的是,在正常模式下尽管某个协程从休眠队列里面释放出来,如果此时有许多协程同时来竞争锁,那么这个被放出来的协程仍然可能竞争不到锁,即出现了锁饥饿现象。
此时就需要饥饿模式的工作方式了。
互斥锁的工作方式:饥饿模式
锁饥饿问题:正常模式下,尽管某个协程从休眠队列里面释放出来,如果此时有许多协程同时来竞争锁,那么这个被放出来的协程仍然可能竞争不到锁。
饥饿模式的工作方式,可以总结如下:
- 何时切换到饥饿模式:当协程等待锁的时间超过1ms,切换到饥饿模式。
- 饥饿模式中,不自旋,新来的协程直接送入sema的休眠队列挂起。
- 饥饿模式中,被唤醒的协程直接获取锁
- 如果没有协程在休眠队列中继续等待,回到正常模式。
Lock()
方法中,在协程从休眠队列里面被释放后,会有这么一段逻辑:
// 计算是否协程等待锁时间超过1ms,如果是则为饥饿模式
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
// 此时会跳到for的开头重新执行
下面我们直接回到slowLock()
方法for里面的逻辑来查看:
// 省略部分逻辑
for {
// 如果饥饿模式,会将协程直接加入等待队列,此时等待数量会先+1
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
// 通过CAS将starving写入state
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 新来的协程直接进入休眠
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
// 饥饿模式下,如果有协程被唤醒
if old&mutexStarving != 0 {
// 被唤醒的协程可以直接对锁进行操作
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
break
}
awoke = true
iter = 0
} else {
old = m.state
}
}
简单小结一下饥饿模式:
- 当锁竞争严重时,互斥锁进入饥饿模式
- 饥饿模式没有自旋等待,有利于公平
小结
通过对Go语言互斥锁深入细致的学习,我们不难发现,互斥锁是并发编程中不可或缺的工具,sync.Mutex
以其简洁的API和高效的实现,为Go开发者提供了一种灵活而强大的方式来管理并发访问。
然而,值得注意的是,互斥锁并非解决所有并发问题的万能钥匙。过度使用互斥锁,特别是在细粒度或高并发的场景下,可能会导致性能瓶颈,甚至死锁。因此,在使用互斥锁时,我们需要权衡其带来的保护效果和可能引入的性能开销,结合具体场景合理选择并发控制策略。
互斥锁的实践使用经验有:
- 减少锁的使用时间,锁的粒度尽量小
- 使用
defer
来释放锁,防止业务中间panic导致没释放锁
- 感谢你赐予我前进的力量