参考
- 小徐先生:mp.weixin.qq.com/s/5o0pR0RDa…
- go设计与实现:draven.co/golang/docs…
整体的思路
先尝试通过自旋 + CAS的方式加锁,如果:
- 4次尝试后都失败了 or
- CPU单核 或者 P的数量=1
- P的本地队列中仍然有G
则会有自旋 + CAS的方式,进入 阻塞 + 唤醒 的方式。这种方式底层依赖信号量。
同时Go语言的sync.Mutex还会基于 公平性 考虑。为了防止有些goroutine因为长时间获取不到锁而饥饿,Go提出了2种模式:
- 正常模式:被唤醒的g和新创建的g共同竞争锁(被唤醒的g占劣势,因为新创建的g持有CPU)
- 饥饿模式:新创建的g不参与锁的竞争,而是加入到阻塞队列尾部。
当有g阻塞了超过1ms还没有获取到锁,就会由正常模式进入到饥饿模式。如果阻塞队列空了,或者等待时间小于1ms了,则会切换回正常模式。
- 自旋 + CAS方式:短期来看操作较轻,不需要挂起协程,但是长期自旋却获取不到锁,则是占用CPU资源。适合锁竞争不大的场景。
- 阻塞 + 唤醒:相比于自旋不浪费CPU,但是需要将当前协程挂起,上下文切换开销较大。适合锁竞争较大的场景。
锁的数据结构
type Mutex struct {
state int32 // 锁状态
sema uint32 // 信号量
}
state有32位,最低3位分别表示:
- 是否加锁
- 是否有协程在抢锁
- 是否为饥饿模式
剩下的29位表示阻塞等待的协程个数。整体如图:
饥饿模式 vs 正常模式
- 正常模式:每次唤醒阻塞队列头部的goroutine,它和新来的goroutine抢锁。(大概率是新来的goroutine抢到)
- 饥饿模式:新创建的goroutine不参与锁的竞争,而是加入阻塞队列尾部。
如果有goroutine等待超过1ms,则进入饥饿模式。(这个时间是写死的)
加锁流程
Lucky -- 直接加锁
通过CAS(乐观锁)方式判断 state 字段的最低位 是否为 0, 如果是,则直接通过 CPU 提供的原子指令加锁(将最低位设置为1)。
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
m.lockSlow()
}
lockSlow
先解释一下这个函数的参数含义:atomic.CompareAndSwapInt32(&m.state, old, new)
只有当 参数1 == old 时,才将 new 赋值给 参数1。
尝试自旋
Goroutine 进入自旋的条件非常苛刻:
-
锁被别人占用 && 处于正常模式
-
runtime.sync_runtime_canSpin需要返回true:- 运行在多 CPU 的机器上;(多核是为了获得锁的那个大哥能有CPU执行,自旋的大哥也能有CPU)
- 当前 Goroutine 为了获取该锁进入自旋的次数小于四次;
- 当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空;
如果全部满足上述条件,当前goroutine就会自旋。[runtime.sync_runtime_doSpin] 和 [runtime.procyield] 并执行 30 次的 PAUSE 指令,该指令只会占用 CPU 并消耗 CPU 时间。
每一次自旋之后,都会尝试上锁,上锁成功直接break;
加锁失败,则转用阻塞的方式,通过信号量将当前goroutine挂起。
收到信号量后,当前goroutine被唤醒,如果是饥饿模式被唤醒,则成功获取锁; 如果是正常模式,则进入下一轮循环。
源码
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
// 锁被别人占用 && 处于正常模式 && 能自旋
// 自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
old = m.state
continue
}
// 设置锁的状态的,尝试加锁前的准备
new := old
if old&mutexStarving == 0 {
new |= mutexLocked
}
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
if awoke {
new &^= mutexWoken
}
// 尝试加锁
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 倘若旧值是未加锁状态且为正常模式
// 则意味着加锁标识位正是由当前 goroutine 完成的更新,说明加锁成功,返回即可;
if old&(mutexLocked|mutexStarving) == 0 {
break
}
// 加锁失败,转为悲观的方式:
// 请求获取一个信号量,并挂起。
// 有信号量了,会唤醒一个阻塞队列中的goroutine
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 收到信号量,被从阻塞队列中唤醒。
// 等待时间超过1ms,进入饥饿模式
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 {
// CAS失败,没抢到锁
old = m.state
}
}
}
解锁发生什么
- 尝试解锁,如果当前state字段减1后 == 0,则直接解锁成功
- 否则进入unlockSlow()
- 如果当前锁已经是unlock状态,则抛出fatal error(不能被捕获)
- 接下来分正常模式和饥饿模式解锁
- 正常模式 通过 CAS判断,如果有锁等待 并且 没有其他人在唤醒锁时,才调用
runtime_Semrelease解锁 - 饥饿模式直接调用
runtime_Semrelease解锁
func (m *Mutex) unlockSlow(new int32) {
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
if new&mutexStarving == 0 { // 正常模式
old := new
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
}
} else { // 饥饿模式
runtime_Semrelease(&m.sema, true, 1)
}
}
为什么atomic比锁快?
因为atomic底层依赖CPU提供的原子指令,这些指令在 硬件层面 保证了对某个内存地址的读-改-写操作是原子的,不会被中断或被其他 CPU 核心打断。
在 Go 源码里,atomic 函数大多数最终会调用到汇编实现,例如:
atomic.CompareAndSwapInt32→ 汇编里是LOCK CMPXCHG。atomic.AddInt32→ 汇编里是LOCK XADD
sync.Mutex在自旋 + CAS模式时,效率也很高,但是一旦进入 阻塞 + 唤醒模式,就涉及将当前goroutine挂起、上下文切换、调度。这些操作比CPU指令慢几个数量级。