[Golang 修仙之路] Go基础:Mutex

112 阅读5分钟

参考

整体的思路

先尝试通过自旋 + 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位表示阻塞等待的协程个数。整体如图:

image.png

饥饿模式 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 进入自旋的条件非常苛刻:

  1. 锁被别人占用 && 处于正常模式

  2. runtime.sync_runtime_canSpin需要返回 true

    1. 运行在多 CPU 的机器上;(多核是为了获得锁的那个大哥能有CPU执行,自旋的大哥也能有CPU)
    2. 当前 Goroutine 为了获取该锁进入自旋的次数小于四次;
    3. 当前机器上至少存在一个正在运行的处理器 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
            }
    }
}

解锁发生什么

  1. 尝试解锁,如果当前state字段减1后 == 0,则直接解锁成功
  2. 否则进入unlockSlow()
  3. 如果当前锁已经是unlock状态,则抛出fatal error(不能被捕获)
  4. 接下来分正常模式和饥饿模式解锁
  5. 正常模式 通过 CAS判断,如果有锁等待 并且 没有其他人在唤醒锁时,才调用runtime_Semrelease解锁
  6. 饥饿模式直接调用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指令慢几个数量级。