Go 中的 sync.Mutex 是如何实现的?

509 阅读3分钟

Go源码分析:sync.Mutex

概览

sync.Mutex 是 go 原生提供的互斥锁实现,也是最基本的同步原语了
合理利用锁即可避免并发编程中由于竞争引发的一些逻辑错误

使用

sync.Mutex 对外暴露的接口有三个

  1. sync.Mutex.Lock 请求锁,如果锁忙,则阻塞。

  2. sync.Mutex.TryLock 请求锁,如果锁忙则,则返回 false。这是非阻塞的获取锁的方式。

  3. sync.Mutex.Unlock 释放锁

一个简单的例子:

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var m sync.Mutex
	cnt := 0

	for i := 0; i < 10; i++ {
		go func() {
			m.Lock()
			cnt++
			m.Unlock()
		}()
	}

	time.Sleep(time.Second) // 保证所有协程执行完
	fmt.Println(cnt)
}

源码分析

sync.Mutex 定义

type Mutex struct {
	state int32
	sema  uint32
}
  • state 字段用来表示当前锁的状态

    state 的低三位分别表示:锁是空闲还是忙(mutexLocked)、现在有无使用该锁的 goroutine 正在被唤醒(mutexWoken)、是正常模式还是饥饿模式(mutexStarving),剩余 29位 用于表示当前互斥锁上的 goroutine 的数目(waiterCount)

    state 这个字段可以说是很省了,是一个 4 合 1 的字段

  • sema 字段用于控制由于锁而挂起、唤醒的 goroutine 的信号量

Lock 方法

func (m *Mutex) Lock() {
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	
	m.lockSlow()
}

处理流程:

  1. 判断 mutexLocked 是否为0,如果为0锁空闲,就直接置成1表示占有锁,然后直接返回(快速拿锁)
  2. 如果锁忙,则进入 lockSlow 的逻辑,等待拿锁(慢速拿锁)

这个 lockSlow 的逻辑还是比较复杂的,大体的逻辑如下:

  1. 判断当前情况下能否进入自旋
    正常模式
    runtime_canSpin(iter)返回值为 true (其中一个条件是自旋次数最多为4)

  2. 如果能进入自旋,则通过自旋的方式等待拿锁,否则直接跳过这一阶段

  3. 计算互斥锁的新状态

  4. 尝试更新互斥锁的状态 如果此时锁空闲,更新这一步就相当于用 CAS 尝试获得锁,如果成功直接返回,失败获取现在 mutex 的状态并回到1

这里列出源码并添加了详细的注释:

func (m *Mutex) lockSlow() {
	var waitStartTime int64 // 等待时间
	starving := false       // 是否为饥饿模式
	awoke := false          // 是否被重新唤醒
	iter := 0               // 自旋次数
	old := m.state
	for {
		// 尝试自旋
                // 能自旋的条件:正常模式 + runtime_canSpin(iter)返回 true
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
			// 如果没有其他 goroutine 在等待这个互斥锁,设置 mutexWaiter 相应位为1
			if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
				atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
				awoke = true
			}
			runtime_doSpin()
			iter++            // 每次自旋结束后会累计次数,会影响下一轮runtime_canSpin(iter)的返回值
			old = m.state    // 更新状态
			continue
		}
		new := old
		
                // 计算新状态
		if old&mutexStarving == 0 {  // 如果不是饥饿并且原来锁是空闲的,就直接拿锁,标记mutexLocked
			new |= mutexLocked
		}
                // 如果原来锁忙或者是饥饿状态,则让 waiterCount 加1,表示这个 goroutine 在等这个互斥锁
		if old&(mutexLocked|mutexStarving) != 0 {  
			new += 1 << mutexWaiterShift
		}
		if starving && old&mutexLocked != 0 {  // 设置了饥饿状态,并且上锁了,新状态一定是饥饿状态
			new |= mutexStarving
		}
		if awoke {  // 当前 goroutine 刚结束自旋
			if new&mutexWoken == 0 {
				throw("sync: inconsistent mutex state")
			}
                        // 之前自旋的时候设置了 mutexWoken 避免其他阻塞的 goroutinue 被唤醒,现在结束了自旋,要清除 mutexWoken 位
			new &^= mutexWoken
		}
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
                        // 更新状态成功并且 old 中锁闲 new 中锁忙,锁被当前 goroutine 拿到,直接退出
			if old&(mutexLocked|mutexStarving) == 0 {
				break
			}
			
                        // 等待时间不为0则把当前 goroutinue 放入等待队列头部
			queueLifo := waitStartTime != 0
                        // 等待时间为0说明是新请求拿锁的 gouroutine,初始化等待开始时间,放入队列尾部
			if waitStartTime == 0 {
				waitStartTime = runtime_nanotime()
			}
                        // 挂起
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)
                        // 醒来之后判断一下是否等待超过了 1ms、原来是不是饥饿模式,依据此决定接下来要不要切换为饥饿模式
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
			old = m.state
                        // 如果现在不是饥饿状态,直接进入下一次循环去尝试抢锁
			if old&mutexStarving != 0 {
				if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
					throw("sync: inconsistent mutex state")
				}
                                // 这个位运算还是很骚的,一步完成了 置mutexLocked为1 和 waitCount - 1
				delta := int32(mutexLocked - 1<<mutexWaiterShift)
				if !starving || old>>mutexWaiterShift == 1 {
					// 如果除了当前 goroutine 以外没有其他请求锁的 goroutine,退出饥饿模式
					delta -= mutexStarving
				}
				atomic.AddInt32(&m.state, delta)
				break
			}
			awoke = true
			iter = 0
		} else {
                        // 自己尝试更新锁的状态没成功就说明此时有其他人更新了,获取一下新的锁状态
			old = m.state
		}
	}

	if race.Enabled {
		race.Acquire(unsafe.Pointer(m))
	}
}

这个部分应该就是 mutex 里最复杂的部分了
额外总结一下 mutex 的饥饿模式和正常模式:

  • 饥饿模式是为了避免大量新的请求互斥锁的 goroutine 的出现导致大量自旋,让进入等待队列的 goroutine 一直抢不到锁引发饥饿。
  • 正常模式下,新的请求互斥锁的 goroutine 可以通过自旋等待锁的释放,如此可以避免 goroutine 的切换来提高总体的执行效率
  • 饥饿模式下,新的请求互斥锁的 goroutine 不允许自旋,直接加入等待队列
  • 等待队列中 goroutine 的唤醒是严格按照 FIFO 来唤醒挂起的
  • 进入饥饿模式的条件为:请求锁的等待时间超过 1ms
  • 退出饥饿模式的条件为:等待队列为空 或 请求锁的等待时间小于 1ms

Trylock 方法

相比上一个方法这个方法就简单多了,功能是非阻塞地请求锁

  1. 进来判断了一下锁是不是空闲并且为正常模式,如果是,则尝试拿锁
    除了要判断是不是空闲还要判断是不是正常模式是因为如果是饥饿模式就完全按照请求锁的先后顺序来分配锁了,Trylock 这里是新请求拿锁的 goroutine 所以一定拿不到锁
  2. 拿到锁了返回 true, 没拿到锁就返回 false, 不会造成阻塞

源码+注释:

func (m *Mutex) TryLock() bool {
	old := m.state
        // 看看有没有拿锁的条件
	if old&(mutexLocked|mutexStarving) != 0 {
		return false
	}

        // 尝试拿锁
	if !atomic.CompareAndSwapInt32(&m.state, old, old|mutexLocked) {
		return false
	}

	if race.Enabled {
		race.Acquire(unsafe.Pointer(m))
	}
	return true
}

Unlock 方法

这个方法用于释放锁,主干仍然是十分简单,细节都在 unlockSlow 这个辅助函数里

  1. atomic.AddInt32(&m.state, -mutexLocked) 尝试快速解锁
  2. 快速解锁失败则调用 m.unlockSlow(new) 慢速解锁
func (m *Mutex) Unlock() {
	if race.Enabled {
		_ = m.state
		race.Release(unsafe.Pointer(m))
	}

        // 快速解锁
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
                // 慢速解锁
		m.unlockSlow(new)
	}
}

下面就进入 unlockSlow 部分了,这个相对而言也并不复杂

正常模式:

  • 如果没有等待者,或者state低三位不全为0,那就不用唤醒等待队列中的 goroutine
  • 如果有等待者,就 饥饿模式:
  • 直接唤醒等待队列中的下一个 goroutine 即可
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
			}
			
                        // 有等待者唤醒队头 goroutine 移交锁的所有权
			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)
	}
}