golang sync.Mutex解读

1,056 阅读13分钟

golang sync.Mutex解读

Mutex 的结构体定义如下:

type Mutex struct {
    state int32
    sema  uint32
}
const (
    mutexLocked = 1 << iota // mutex is locked
    mutexWoken
    mutexStarving
    mutexWaiterShift = iota
    starvationThresholdNs = 1e6
)
  • mutexLocked :值为 1,第一位为 1,表示 mutex 已经被加锁。根据 mutex.state & mutexLocked 的结果来判断 mutex 的状态:该位为 1 表示已加锁,0 表示未加锁。
  • mutexWoken:值为 2(二进制:10),第二位为 1,表示 mutex 是否被唤醒。根据 mutex.state & mutexWoken 的结果判断 mutex 是否被唤醒:该位为 1 表示已被唤醒,0 表示未被唤醒。
  • mutexStarving:值为 4(二进制:100),第三位为 1,表示 mutex 是否处于饥饿模式。根据 mutex.state & mutexWoken 的结果判断 mutex 是否处于饥饿模式:该位为 1 表示处于饥饿模式,0 表示正常模式。
  • mutexWaiterShift:值为 3,表示 mutex.state 右移 3 位后即为等待的 goroutine 的数量,也即表示统计阻塞在该mutex上的goroutine数目需要移位的数值。根据 mutex.state >> mutexWaiterShift 得到当前等待的 goroutine 数目
  • starvationThresholdNs:值为 1000000 纳秒,即 1ms,表示将 mutex 切换到饥饿模式的等待时间阈值。这个常量在源码中有大篇幅的注释,理解这段注释对理解程序逻辑至关重要

state

1111 1111 ...... 1111 1 1 1 1
|_________29__________| ↓ ↓ ↓
          ↓             ↓ ↓ \ 表示当前 mutex 是否加锁
          ↓             ↓ \ 表示当前 mutex 是否被唤醒
          ↓             \ 表示 mutex 当前是否处于饥饿状态
          ↓
  存储等待 goroutine 数量

饥饿模式和正常模式

  • 在正常模式下,是按照FIFO(先来先的)的顺序获取锁的,这样一个刚换醒的goroutine将会和新请求的锁进行竞争,并且大概率会失败,这个时候他会被放入一个等待队列中的头部,如果他等待了1ms还是没有获取锁,那么他会将锁转为饥饿模式
  • 饥饿模式下,锁会从unlock的goroutine直接转移到饥饿队列里面的第一个,并从饥饿队列中开始排序

如果一个等待的goroutine获取了锁,并且满足一以下其中的任何一个条件:(1)它是等待队列中的最后一个;(2)它等待的时候小于1ms。它会将锁的状态转换为正常状态。

相比于饥饿模式,正常模式有更好地性能,因为一个 goroutine 可以连续获得好几次 mutex,即使有阻塞的等待者。而饥饿模式可以有效防止出现位于等待队列尾部的等待者一直无法获取到 mutex 的情况。

加锁

1.判断 Goroutine 的状态,看是否能够进行自旋等锁;

func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    // 查看 state 是否为0,如果是则表示可以加锁,将其状态转换为1,当前 goroutine 加锁成功,函数返回
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    // Slow path (outlined so that the fast path can be inlined)
    m.lockSlow()
}

看看这把锁是不是空闲状态,如果是的话,直接原子性地修改一下 state 为已被获取就行了。大概了解一下,知道该方法是原子性的即可。

如果不是空闲状态:

func (m *Mutex) lockSlow() {
    var waitStartTime int64 // 用来存当前goroutine等待的时间
    starving := false // 用来存当前goroutine是否饥饿
    awoke := false // 用来存当前goroutine是否已唤醒
    iter := 0 // 用来存当前goroutine的循环次数
    old := m.state // 复制一下当前锁的状态
for {
    // 进入到这个循环的,有两种角色goroutine,一种是新来的goroutine。另一种是被唤醒的goroutine;
    // 所以它们可能在这个地方再一起竞争锁,如果新来的goroutine抢成功了,那另一个只能再阻塞着等待,
    // 但超过1ms后,锁会转换成饥饿模式。在这个模式下,所有新来的goroutine必须排在队伍的后面,没有抢锁资格。也即:
    // 如果是饥饿情况之下,就不要自旋了,因为锁会直接交给队列头部的goroutine
    // 如果锁是被获取状态,并且满足自旋条件(canSpin见后文分析),那么就自旋等锁
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
        // 通过了上面的检测,这时进行自旋是有意义的。
        // 通过把 mutexWoken 标识为 true,以通知 Unlock 方法就先不要叫醒其它阻塞着的 goroutine 了
        if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
        atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
            awoke = true
        }
        // 将当前 goroutine 标识为唤醒状态后,执行自旋操作,计数器加一,将当前状态记录到 old,继续循环等待
        runtime_doSpin()
        iter++
        old = m.state
        continue
    }
...

总的来说需要注意如果是饥饿模式则不进行自旋,因为锁的所有权会直接交给队列头部的goroutine,所以在这个饥饿状态下,无论如何都无法获得mutex

需要了解的是自旋是一种多线程同步机制,当前的进程在进入自旋的过程中会一直保持 CPU 的占用,持续检查某个条件是否为真。在多核的 CPU 上,自旋可以避免 Goroutine 的切换,使用恰当会对性能带来很大的增益,但是使用的不恰当就会拖慢整个程序,所以 Goroutine 进入自旋的条件非常苛刻:

源码注释认为因为 sync.Mutex 是协作的,所以对于 Spin 我们应该要保守一些使用,使用 Spin 的条件还挺严苛,看看其需要满足什么条件:

  1. 旋次数小于active_spin(这里是4)次;
  2. 然后应该运行在多内核的机器上,且GOMAXPROCS的数目应该要大于1;(如果GOMAXPROCS不了解,可以看看 Goroutine相对应的 GMP 模型)
  3. 还有当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空;

2.通过自旋等待互斥锁的释放;

//go:linkname sync_runtime_doSpin sync.runtime_doSpin
//go:nosplit
func sync_runtime_doSpin() {
	procyield(active_spin_cnt)
}

3.计算互斥锁的最新状态

如果此时 Goroutine 不能进行自旋操作,则会进入剩余的代码逻辑;到了这一步, state的状态可能是:

  1. 锁还没有被释放,锁处于正常状态;
  2. 锁还没有被释放, 锁处于饥饿状态;
  3. 锁已经被释放, 锁处于正常状态;
  4. 锁已经被释放, 锁处于饥饿状态;
// new 复制 state的当前状态, 用来设置新的状态
// old 是锁当前的状态
new := old

// 如果old state状态不是饥饿状态, new state 设置锁, 尝试通过CAS获取锁,
// 如果old state状态是饥饿状态, 则不设置new state的锁,因为饥饿状态下锁直接转给等待队列的第一个。
if old&mutexStarving == 0 {
    // 非饥饿模式下,可以抢锁,将 new 的第一位设置为1,即加锁
    new |= mutexLocked
}

// 当 mutex 处于加锁状态或饥饿状态的时候,新到来的 goroutine 进入等待队列,将等待队列的等待者的数量加1。
// old & 0101 != 0,那么 old 的第一位和第三位至少有一个为 1,即 mutex 已加锁或处于饥饿模式。
if old&(mutexLocked|mutexStarving) != 0 {
    new += 1 << mutexWaiterShift
}

// 如果当前goroutine已经处于饥饿状态, 并且old state的已被加锁,
// 将new state的状态标记为饥饿状态, 将锁转变为饥饿状态。
// 但如果当前 mutex 未加锁,则不需要切换,Unlock 操作希望饥饿模式存在等待者
if starving && old&mutexLocked != 0 {
    new |= mutexStarving
}

// 如果说当前goroutine是被唤醒状态,我们需要reset这个状态,
// 因为goroutine要么是拿到锁了,要么是进入sleep了。
if awoke {
    // new设置为非唤醒状态
    new &^= mutexWoken
}
  • 如果是正常模式,直接加锁
  • 如果是已经被加锁||是进入饥饿模式,那么就将当前的goroutine放入等待队列
  • 如果已经被加锁&&进入饥饿模式,那么将新的锁设置进入饥饿模式
  • 如果当前goroutine已经被唤醒,设置为非唤醒状态

3.通过CAS来尝试设置互斥锁的状态

// 调用 CAS 更新 state 状态
if atomic.CompareAndSwapInt32(&m.state, old, new) {
    // 如果说old状态不是饥饿状态也不是被获取状态
    // 那么代表当前goroutine已经通过CAS成功获取了锁
    // (能进入这个代码块表示状态已改变,也就是说状态是从空闲到被获取)
    if old&(mutexLocked|mutexStarving) == 0 {
        // 成功上锁
        break // locked the mutex with CAS
    }
    // 如果之前已经等待过了,那么就要放到队列头
    queueLifo := waitStartTime != 0
    // 如果说之前没有等待过,就初始化设置现在的等待时间
    if waitStartTime == 0 {
        waitStartTime = runtime_nanotime()
    }
    // 既然获取锁失败了,就使用sleep原语来阻塞当前goroutine
    // 通过信号量来排队获取锁
    // 如果是新来的goroutine,就放到队列尾部
    // 如果是被唤醒的等待锁的goroutine,就放到队列头部
    runtime_SemacquireMutex(&m.sema, queueLifo, 1)
    
    // 这里sleep完了,被唤醒

    // 如果当前 goroutine 等待时间超过starvationThresholdNs,mutex 进入饥饿模式
    starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
    
    // 再次获取一下锁现在的状态
    old = m.state
    // 如果说锁现在是饥饿状态,就代表现在锁是被释放的状态,当前goroutine是被信号量所唤醒的,
    // 也就是说,锁被直接交给了当前goroutine
    if old&mutexStarving != 0 {
        // 如果说当前锁的状态是被唤醒状态或者被获取状态,或者说等待的队列为空
        // 那么state是一个非法状态,因为当前状态肯定应该有等待的队列,锁也一定是被释放状态且未唤醒
        if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
            throw("sync: inconsistent mutex state")
        }
        // 当前的goroutine获得了锁,那么就把等待队列-1
        delta := int32(mutexLocked - 1<<mutexWaiterShift)
        // 如果不是饥饿模式或只剩一个等待者了,退出饥饿模式
        if !starving || old>>mutexWaiterShift == 1 {
            // 把状态设置为正常
            delta -= mutexStarving
        }
        // 设置新state, 因为已经获得了锁,退出、返回
        atomic.AddInt32(&m.state, delta)
        break
    }
    // 如果锁不是饥饿模式,就把当前的goroutine设为被唤醒,并且重置iter(重置spin)
    awoke = true
    iter = 0
} else {
    // 如果CAS不成功,重新获取锁的state, 从for循环开始处重新开始
    old = m.state
} 

加锁过程总结

  1. 首先,它检查互斥锁的状态 m.state 是否为 0,即是否没有被锁住。如果是,则表示可以尝试加锁,使用 atomic.CompareAndSwapInt32 原子操作尝试将状态从 0 转换为 mutexLocked(1)。如果这一步成功,表示当前 goroutine 已成功获得锁,可以立即返回。
  2. 如果上述快速路径失败,即互斥锁的状态不是 0,或者 CAS 操作失败,那么会进入慢路径,调用 m.lockSlow() 方法来进一步尝试获得锁。
  3. m.lockSlow() 方法中的主要部分是一个 for 循环,它用来处理互斥锁的各种状态和竞争情况。
  4. 首先,它会检查锁的状态 m.state,并尝试在锁被获取或处于饥饿状态的情况下自旋等待锁。这是为了避免不必要的等待和系统调用。
  5. 如果自旋等待条件满足,并且之前没有唤醒过其他等待的 goroutine,那么当前 goroutine 将设置 mutexWoken 标志为 true,以通知 Unlock 方法不要唤醒其他等待的 goroutines。接着,它会执行自旋操作,继续尝试获取锁。
  6. 如果自旋操作失败,它会更新迭代计数器 iter,复制当前锁的状态到 old,然后再次循环进行尝试。这个迭代计数器有助于限制自旋等待的次数。
  7. 如果上述条件不满足,它会计算新的状态 new,有四种,尝试通过 CAS 更新state状态
  8. 如果之前已等待过,将当前 goroutine 放入等待队列的头部(LIFO),否则初始化等待的起始时间。
  9. 使用系统调用(sleep)阻塞当前 goroutine,通过信号量来排队等待获取锁。新到来的 goroutine 放在队列的尾部,被唤醒的等待锁的 goroutine 放在队列的头部。
  10. 如果等待时间超过 starvationThresholdNs,锁进入饥饿模式,新到来的 goroutines 不再有机会竞争锁。
  11. 如果当前 goroutine被唤醒后,再次尝试获取锁并检查锁的状态,如果是饥饿模式,直接把锁交给队头,当前的goroutine获得了锁,那么就把等待队列-1,设置新的state
  12. 如果 CAS 操作不成功,重新获取当前锁的状态,重新开始循环

解锁

过程会先使用 AddInt32 函数快速解锁,这时会发生下面的两种情况:

  • 如果该函数返回的新状态等于 0,当前 Goroutine 就成功解锁了互斥锁;
  • 如果该函数返回的新状态不等于 0,这段代码会调用 sync.Mutex.unlockSlow 方法开始慢速解锁:

其源码片段如下:

func (m *Mutex) Unlock() {
    if race.Enabled {
        _ = m.state
        race.Release(unsafe.Pointer(m))
    }

	// mutex 的 state 减去1,加锁状态 -> 未加锁,并保存到 new
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        // Outlined slow path to allow inlining the fast path.
        // To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
        m.unlockSlow(new)
    }
}
func (m *Mutex) unlockSlow(new int32) {
    // 如果state不是处于锁的状态, 那么就是Unlock根本没有加锁的mutex, panic
    if (new+mutexLocked)&mutexLocked == 0 {
        throw("sync: unlock of unlocked mutex")
    }
    // 非饥饿模式,也即正常模式
    if new&mutexStarving == 0 {
        old := new
        for {
            // 没有被阻塞的goroutine。直接返回
            // 有阻塞的goroutine,但处于woken模式,直接返回
            // 有阻塞的goroutine,但被上锁了,可能发生在此for循环内,第一次CAS不成功。因为CAS前可能被新的goroutine抢到锁。直接返回
            // 有阻塞的goroutine,但锁处于饥饿模式,可能发生在被阻塞的goroutine不是被唤醒调度的,而是被正常调度运行的。直接返回
            if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                return
            }
            // 等待者数量减 1,并将唤醒位改成 1
            new = (old - 1<<mutexWaiterShift) | mutexWoken
            // 设置新的state, 这里通过信号量会唤醒一个阻塞的goroutine去获取锁.
            if atomic.CompareAndSwapInt32(&m.state, old, new) {
                // 唤醒一个阻塞的 goroutine,但不是唤醒第一个等待者
                runtime_Semrelease(&m.sema, false, 1)
                return
            }
            old = m.state
        }
    } else {
        // 饥饿模式:将 mutex 所有权传递给下个等待者。
        // 注意:mutexLocked 没有设置,等待者将在被唤醒后设置它。
        // 在此期间,如果有新的 goroutine来请求锁, 因为 mutex 处于饥饿状态,mutex还是被认为处于锁状态,
        // 新来的 goroutine 不会把锁抢过去.
        runtime_Semrelease(&m.sema, true, 1)
    }
}

解锁总结

  • 当互斥锁已经被解锁时,那么调用 sync.Mutex.Unlock 会直接抛出异常;
  • 当互斥锁处于饥饿模式时,会直接将锁的所有权交给队列中的下一个等待者,等待者会负责设置 mutexLocked 标志位,等待队列中的 goroutine 将负责互斥锁的解锁操作。这样,互斥锁的解锁操作并不需要显式的执行者(unlocker),因为等待队列中的下一个等待者会负责释放锁的状态。
  • 当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,就会直接返回;在其他情况下会通过csa更新state状态,然后 sync.runtime_Semrelease 唤醒对应的 Goroutine;

参考

dongxiem.github.io/2020/06/05/…