Go 互斥锁Mutex

Mutex是一个互斥锁,可以创建为其他结构体的字段;零值为解锁状态。Mutex类型的锁和线程无关,可以由不同的线程加锁和解锁。
互斥锁的作用是保证共享资源同一时刻只能被一个 Goroutine 占用,一个 Goroutine 占用了,其他的 Goroutine 则阻塞等待。

1、数据结构

type Mutex struct {
   state int32  // 表示当前互斥锁的状态
   sema  uint32  // 信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒
}

基于该数据结构,实现了两种方法,加锁、释放锁

type Locker interface {
   Lock()
   Unlock()
}

const (
    mutexLocked = 1 << iota // 1 001,表示是否被锁定(0未锁住,1已锁住)
    mutexWoken  // 2 010,表示是否有 goroutine 被唤醒(0未唤醒,1唤醒,正在加锁过程中)(释放锁时,如果正常模式下,不会唤醒其他 goroutine)
    mutexStarving  // 4 100,当前的互斥锁进入饥饿状态
    mutexWaiterShift = iota // 3 表示统计阻塞在该mutex上的goroutine数目需要移位的数值,1<<(32-3)个

    starvationThresholdNs = 1e6 // 1ms
)
// sema + 1,挂起 goroutine
// 1.不断调用尝试获取锁
// 2.休眠当前 goroutine
// 3.等待信号量,唤醒 goroutine
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// sema - 1,唤醒 sema 上等待的一个 goroutine
runtime_Semrelease(&m.sema, false, 1)

2、模式

2.1、正常模式

在正常模式下,等待的 goroutine 会按照 FIFO(先进先出)的顺序得到锁。刚被唤醒的 goroutine 与新创建的 goroutine 竞争时,因为新到达的 goroutine 已经在 CPU 上运行了,所以大概率无法获得锁,如 G1和 G2 竞争,此时 G1 已经占着 CPU 了,所以大概率拿到锁。

因为可以连续多次获得锁,所以性能更好。

如果被唤醒的 goroutine 超过 1ms,没有获取锁,就会将当前锁切换为饥饿模式。

2.2、饥饿模式

为了阻止尾部延迟现象,对于预防队列尾部 goroutine 都无法获得 mutex 锁的问题,1.9 版本引入了饥饿模式 commit

在饥饿模式下,互斥锁会直接交给等到队列最前面的 goroutine,新的 goroutine 在该状态下不能获取锁,也不能进入自旋,只能在队列末尾等待。

2.3、状态切换

正常模式下,

如果队列中只剩一个goroutine 获得了互斥锁或者它等待的时间少于 1ms,那么就会切换到正常模式。

3、加锁

// 如果 addr 指向的地址中的值和 old 一样,就把 addr 中的值改为 new 并返回 true,原子操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

1、Fast path

如果 m.state等于 0,说明当前的对象没有被锁住,进行 CAS 设置为 locked 状态,返回 true;

否则,说明对象已被其他 goroutine 锁住,不会进行 CAS,返回 false

// 如果 mutex 的 state 没有被锁,也没有等待或唤醒的 goroutine,锁处于正常状态,那么获得锁,返回
// 比如锁第一次被 goroutine 请求时,或者锁处于空闲时
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    // 竟态检测器,用于检测当前是否有其他操作同时操纵此Mutex对象,默认为 false
    if race.Enabled {
       race.Acquire(unsafe.Pointer(m))
   }
    return
}

2、Slow path

如果状态不是 0 ,就会尝试通过自旋等方式等待锁释放,大致分为:

  1. 判断当前 goroutine 能否进入自旋
  2. 通过自旋等待互斥锁的释放
  3. 计算互斥锁的最新状态
  4. 更新互斥锁的状态并获取锁
// 等待时间
var waitStartTime int64
// 饥饿标记
starving := false
// 唤醒标记
awoke := false
// 自旋次数
iter := 0
// 当前的锁的状态
old := m.state
for {
    // 步骤一
    // 判断是否已经加锁并处于正常模式
    // 原来锁的 state & (1|4),其中, 1|4 是检验 state 是处于 1 还是 4 的状态,还是两者都是,x0xx & 01 = 01(当前锁被使用)
    // 如果与 1 相等,说明此时处于 正常模式并已加锁,就走后面判断是否可以自旋
    
    // 1、state 已被锁,但不是饥饿,因为如果是饥饿,自旋没用,锁的拥有权直接给等待队列第一个
	// 2、可以自旋,多核,压力不大并再一定次数内可自旋
    // 满足这两个条件,不断自旋等待锁被释放,或进入接状态,或不能再自旋
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
        // 1、没有被唤醒
        // 2、old&mutexWoken == 0 再次确认是否被唤醒,xx0x & 0010 = 0
        // 3、old>>mutexWaiterShift != 0 查看是否有 goroutine 在排队
        // 4、将锁在老状态上追加唤醒状态,xx0x | 0010 = xx1x

        // 如果当前标识位 awoke 为未被唤醒,&& old 为为被唤醒 && 有正在等待的 goroutine && 修改 old 为被唤醒
        // 并修改标识位 awoke 为 true
        if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
        atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
        // 自旋
        runtime_doSpin()
        // 自旋次数加1
        iter++
        // 保存 mutex 即将被设置成的状态
        old = m.state
        continue
    }
------------------------------------------------------------------------------>
    // 步骤二(不自旋)
    /*
    state 的状态可能是:
    1、锁还没被释放,处于正常状态
    2、锁还没被释放,处于饥饿状态
    3、锁被释放,处于正常状态
    4、锁被释放,处于解状态
	*/
    // 此时可能锁变为饥饿状态或者已经解锁了,或者不符合自旋条件
    // 获取锁最新状态
    new := old

    // 如果当前是正常模式,尝试加锁。(饥饿状态下要让出竞争权利,不能加锁)
    if old&mutexStarving == 0 {
        // x0xx | 0001 = x0x1,将标识对象锁被锁住
    	new |= mutexLocked
    }
    // x1x1&(0001 | 0100)=x1x1&0101 != 0
    // 当前mutex被占用并处于饥饿模式,把自己放到等待队列,waiter加一,表示等待一个等待计数
    // 这块的状态,goroutine 只能等着,饥饿状态要让出竞争权利
    if old&(mutexLocked|mutexStarving) != 0 {
        // 更新阻塞 goroutine 数量,表示 mutex 等待 goroutine 数量+1
    	new += 1 << mutexWaiterShift
    }
    
    // 如果已经是饥饿状态(即 饥饿标识位starving为真),并且old 的锁是占用情况,更新状态改为饥饿状态
    // old&mutexLocked !=0 => xxx1&0001!=0;锁被占用
    if starving && old&mutexLocked != 0 {
        // 追加饥饿状态
    	new |= mutexStarving
    }
    
    // 如果awoke在上面自旋时设置成功,那么在这要消除标志位,因为该 goroutine 要么获得了锁,要么进入休眠,和唤醒状态没啥关系
    // 总之,state 的新状态不再是 woken 状态
    // 如果 goroutine 已被唤醒,需要在两种情况下重设标志位
    if awoke {
        // xx0x & 0010 == 0,如果唤醒标志位与awoke 的值相符,就抛异常
    	if new&mutexWoken == 0 {
    		throw("sync: inconsistent mutex state")
        }
        // 清除唤醒标志位
        // new & (^mutexWoken) => xxxx&(^0010) => xxx&1101 = xx0x
        // 设置唤醒状态为 0,即未唤醒,只是为了下次可被设置为 被唤醒,而不是休眠
        new &^= mutexWoken
    }
------------------------------------------------------------------------------>
    // 步骤三
    // 将新的状态赋值给 state
    if atomic.CompareAndSwapInt32(&m.state, old, new) {
        // 1.如果old state 的状态没有上锁,也没有饥饿,那么直接返回,表示获取到锁
        // x0x0 & 0101 = 0,表示可以获取锁
        if old&(mutexLocked|mutexStarving) == 0 {
            break // locked the mutex with CAS
        }
        
        // 2.到这里是没有获取到锁,判断一下等待时长是否不为0
        // 如果新的 goroutine 来抢占锁,会返回 false
    	// 如果不是新的,那么加入到队列头部
        // 保证等待最久的 goroutine 优先拿到锁
        queueLifo := waitStartTime != 0
        
        // 3.如果等待时间为0,那么初始化等待时间
        if waitStartTime == 0 {
            waitStartTime = runtime_nanotime()
        }
        
        // 4.既然未获取到锁,就阻塞这个 goroutine
        // 如果是新来的 goroutine,queueLifo = false,加入队列尾部,等待
        // 如果是唤醒的 goroutine,queueLifo = true,加入等待队列的头部
        runtime_SemacquireMutex(&m.sema, queueLifo, 1)
        
        // 5.上面方法将当前新进来争夺锁的 goroutine 挂起后,如果该 goroutine 被唤醒,往下走
        // 如果当前 饥饿||当前时间-开始等待时间 > 1ms 则都切换为饥饿状态表示
        // 判断该 goroutine 是否长时间没有获得锁,如果是,就是饥饿的 goroutine
        starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
        // 中转变量,需要重新获取一下当前锁的状态
        old = m.state
        
        // 6.判断是否已经处于饥饿状态,处于,直接获得锁,如果不处于直接跳出
        // 饥饿状态下,被唤醒的协程直接获得锁。
        if old&mutexStarving != 0 {
            // 饥饿状态下,被唤醒,发现锁没释放,唤醒值是 1,等待列表没有,报错
            if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                throw("sync: inconsistent mutex state")
            }
            delta := int32(mutexLocked - 1<<mutexWaiterShift)
            
            // 7.如果唤醒等待队列的 goroutine 不饥饿,或是等待队列中的最后一个 goroutine
            if !starving || old>>mutexWaiterShift == 1 {
                // 就从饥饿模式切换会正常模式
                delta -= mutexStarving
            }
            
            // 9.设置状态
            // 将锁状态设置为等待数量减1,同时设置为锁定,加锁成功
            atomic.AddInt32(&m.state, delta)
            break
        }
        // 当前 goroutine 是被系统唤醒的
        awoke = true
        // 重置自旋次数
        iter = 0
    } else {
        // 如果 CAS 失败,重新开始
        old = m.state
    }
}
if race.Enabled {
    race.Acquire(unsafe.Pointer(m))
}

3、小结

 

4、自旋

自旋是一种多线程同步机制,当前的进程在进入自旋的过程中会一直保持 CPU 的占用,持续检查某个条件是否为真。

4.1、canSpin

runtime_canSpin(iter)

它的实现方法链接到了sync_runtime_canSpin

一种比较保守的自旋,不会一直自旋下去,需要满足下面某一条件:

  • CPU核数要大于1,否则自旋没有意义,因为此时不可能有其他协程释放锁
  • 当前Goroutine为了获取该锁进入自旋的次数 iter 小于四次
  • 当前机器上至少存在一个正在运行 Process
  • 处理的运行 G 队列为空,否则会延迟调度

4.2、doSpin

runtime_doSpin()

func sync_runtime_doSpin() {
	procyield(active_spin_cnt)
}

TEXT runtime·procyield(SB),NOSPLIT,$0-0
	MOVL	cycles+0(FP), AX
again:
	PAUSE
	SUBL	$1, AX
	JNZ	again
	RET

它的实现方法链接到了 sync_runtime_doSpin,及对应 procyield 函数在 procyield

函数内部会调用 PAUSE指令,CPU 对该指令什么都不做,一般为 30 个时钟周期,每 PAUSE 执行一次再检查是否可以加锁,循环进行。该过程中,进程仍是执行状态,不是睡眠状态。

4.3、优势

更充分的利用CPU,尽量避免 goroutine 切换。因为当前申请加锁的 goroutine 拥有CPU,如果经过短时间的自旋可以获得锁,当前协程可以继续运行,不必进入阻塞状态。

对于新来进程一直进行自旋加锁,排队中的进程长时间无法拿到锁,则设置饥饿状态,该状态下不允许自旋。

5、解锁

// 如果在解锁时,锁是没有被锁定的,会报运行时错误
// 加锁和解锁可以不是同一个 goroutine
func (m *Mutex) Unlock() {
    // 默认 fasle
	if race.Enabled {
		_ = m.state
		race.Release(unsafe.Pointer(m))
	}

	// Fast path: drop lock bit.
    // 不是饥饿状态,没有等待协程,没有唤醒,直接解锁完成
	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) {
	if (new+mutexLocked)&mutexLocked == 0 {
		fatal("sync: unlock of unlocked mutex")
	}
    // 如果是正常模式
	if new&mutexStarving == 0 {
		old := new
		for {
			// 等待列表没有 goroutine 了,或者已有唤醒的 goroutine,直接 return,没必要唤醒其他的
			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
				return
			}
			// waiter 数减 1,mutexWoken 标志设置上,通过 CAS 更新 state 的值
			new = (old - 1<<mutexWaiterShift) | mutexWoken
			if atomic.CompareAndSwapInt32(&m.state, old, new) {
                // 唤醒等待队列中的 waiter
				runtime_Semrelease(&m.sema, false, 1)
				return
			}
			old = m.state
		}
	} else {
		// 饥饿模式,直接将锁交给等待队列第一个 goroutine
        // 注意:锁的 locked 位mutexLocked没有被设置,唤醒的 goroutine 后面会设置
        // 尽管没有设置 locked 位,但在饥饿模式下,新来的 goroutine 也没法获取锁
		runtime_Semrelease(&m.sema, true, 1)
	}
}

6、小结

6.1、加锁

  1. 上来先一个 CAS ,如果锁正空闲,并且没人抢,那么加锁成功;
  2. 否则,自旋几次,如果成功,也不用加入队列;
  3. 否则,加入队列;
  4. 从队列中被唤醒:
    1. 正常模式:和新来的一起抢锁,大概率失败
    2. 饥饿模式:肯定拿到锁

6.2、解锁

 

posted @   等会儿我呀  阅读(499)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· .NET Core 中如何实现缓存的预热?
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 如何调用 DeepSeek 的自然语言处理 API 接口并集成到在线客服系统
· 【译】Visual Studio 中新的强大生产力特性
点击右上角即可分享
微信分享提示