青训营笔记

71 阅读6分钟

Go中的Mutex函数

这是我参与【第五届青训营】伴学笔记创作活动的第14天

mutex 的数据结构:

type Mutex struct {
	state int32  // 互斥锁的状态:被g持有,空闲等
	sema  uint32 // 信号量,用于阻塞/唤醒 goroutine(协程)
}

锁的 state 是分为 4 部分使用的(通过位操作符做到的)

  • waiter(29 bit): 尝试获取当前锁而陷入阻塞的等待者们
  • starving(1 bit): 当前锁是 饥饿模式
  • woken(1 bit): 当前锁是 Woken 状态,有该锁的等待这被唤醒
  • locked(1 bit): 0 表示互斥锁是空闲的,1 表示互斥锁是被持有的

Mutex.Lock():

func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
	// 走这条路,就可以将 Lock()方法内联到调用者函数的代码中去(因为此时Lock()函数的代码少),减少了函数栈的开辟和释放,提高了性能
	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()
}

Mutex.lockSlow():

  • 刚刚是锁状态刚好为 0 的时候,若是锁被其他 g 所持有,那么将进入 lcokSlow 方法
结论:
    1. normal 模式下:
    2. 加入了竞争的情况,被唤醒的 g 可能会与刚到来的 g 一起竞争锁,但是很被唤醒的 g 很可能失败。因为 g 被唤醒就说明锁已经被释放了,那么自旋的很可能已经获得锁了
    3. 睡眠时间超过 1ms 的 g,被唤醒后想要将 mutex 切换为 starving 模式,切换后也会再次进入阻塞队列且排在队列头部,等待锁的释放别唤醒
    1. starving 模式下:
    2. 只有被唤醒的等待者才能加锁,其他的 g 全都进入 FIFO 阻塞队列
// 该方法可能被并发执行
func (m *Mutex) lockSlow() {
	var waitStartTime int64
	starving := false 
	awoke := false // 当前 g 是否从队列中醒来过,主要用来清除锁的 Woken 状态
	iter := 0 // 标记自旋次数
	old := m.state // 先获取当前锁的状态,且 old 是局部变量
	for {
		// 检测当前 g 是否可自旋,自旋主要查看锁是否被释放了,没释放我就要自旋,看多等一小会儿,能否看到锁释放而在接下来的逻辑中请求到锁
		// 如果当前锁为被持有状态且不处于饥饿模式 同时 允许自旋
		// runtime_canSpin(iter),iter 是传入自旋次数进行检测,同时还会检测其他条件
		// 比如 cpu 核数大于 1、逻辑处理器 P 大于 1、当前运行队列为空
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
			// 主动自旋是有意义的,有可能抢到锁而提升 mutex 锁性能
		  // 尝试设置 mutexWaken 标志,通知 UnLock 方法不要唤醒其他已经被阻塞的 goroutine,我马上就拿到锁了,这就算是抢锁成功了
			// 如果释放锁的 g 一直咩有调用 UnLock 方法,那么同样在接下来的逻辑中陷入阻塞
			// 当前锁不是 Woken 状态,awoke 是 false,锁有等待者,cas 成功的话 -> 将当前锁置为 Woken模式,且 awoke 置为 ture
			if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
				atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
				// 在自旋状态下,把自己设置为刚从队列中醒来的状态,且让锁为 Woken 状态
				awoke = true
			}
			// cpu 自旋,对应 cpu的pause 指令,即cpu 单纯在空转什么都没干
			runtime_doSpin()
			iter++
			old = m.state // 自旋后再次获取锁的状态
			continue
		}
		new := old // 准备设置新锁的状态
		// 不要尝试获取饥饿模式下的 mutex,
		// 尝试获取此模式下的 mutex,都直接加入等待队列
		// 判断此时锁是否是饥饿模式
		if old&mutexStarving == 0 {
			// 不是饥饿模式,新状态加锁(此前 old 当前锁状态可能是释放或者未释放都没关系)
			new |= mutexLocked
		}
		// 如果获得的当前锁状态处于有锁状态或者饥饿模式,让 准备设置的新锁等待者 +1
		if old&(mutexLocked|mutexStarving) != 0 {
			// 锁的等待者 + 1
			new += 1 << mutexWaiterShift
		}
		// 当前 goroutine 将 mutex 切换到饥饿模式
		// 但是如果互斥锁当前未被持有,不进行切换
		// unlock 方法期望 饥饿模式的 mutex 的等待队列有 g,但在此情况下不是这样
		// 如果当前 g 是睡眠超过 1 ms 且当前锁被持有
		if starving && old&mutexLocked != 0 {
			// 准备设置的新锁设置为饥饿模式
			new |= mutexStarving
		}
		// 如果该 g 是在被队列中唤醒的
		if awoke {
			// awoke = true 但是 new 的 mutexWoken 为 fales 出错了
			// 所以一个在该锁中活跃的 g 的 awoke 状态与当前锁的 woken 状态一致
			// 判断锁的状态是否正确
			if new&mutexWoken == 0 {
				throw("sync: inconsistent mutex state")
			}
			// g 被唤醒,准备设置的新状态锁应当清除 锁的 Woken 状态
			// 锁的 Woken 状态表示释放锁的 g 不要唤醒其他的阻塞者,自己都醒来表示 Woken 也算是失效了
			// a &^= b 叫 清位操作,b 中所有为1的位置,对应到 a 中的位置都将被置为 0 
			// 也就是清零a中,ab都为1的位
			new &^= mutexWoken
		}
		// 再次尝试 cas 设置为 new 值(更新当前 g 状态在锁上,基于内存中锁的最新值更新,失败则继续重复上述的所有步骤,上面的设置都是假设 g 自己能基于内存中最新值更新)
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			// 如果当前锁的状态是 空闲且不是饥饿模式,算抢锁成功
			if old&(mutexLocked|mutexStarving) == 0 {
				break // 使用 cas 持有锁
			}
			// 处理锁饥饿状态下的情况
			// waiStratTime != 0 -> 如果该协程之前已经等待过了,此次排在队伍首位
			queueLifo := waitStartTime != 0
			if waitStartTime == 0 {
				waitStartTime = runtime_nanotime()
			}
			// 阻塞,根据 queueLifo 排阻塞队列的队头(True)还是队尾
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)
			// 从阻塞队列中被唤醒,醒来后判断自己休眠时候是否超过 1 ms,只要超过 1 ms,准备将锁置为饥饿模式(不允许自旋)
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
			old = m.state // 获取当前锁的状态
			// 如果当前锁是饥饿模式
			if old&mutexStarving != 0 {
				// 如果当前锁被持有且是 Woken 状态 或者 锁无等待者
				// 判断锁的状态是否正确 - 饥饿模式 与 Woken 状态不会同时出现
				if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
					throw("sync: inconsistent mutex state")
				}
				// 当前 g 获得锁且等待者 - 1
				delta := int32(mutexLocked - 1<<mutexWaiterShift)
				// 当前 g 不是饥饿状态 or 当前锁的等待者只有一个 g 了也就是自己(饥饿模式下,锁释放的时候,不会将等待者数量 - 1)
				if !starving || old>>mutexWaiterShift == 1 {
					// 切换锁的饥饿模式为普通模式
					delta -= mutexStarving
				}
				// 更新互斥锁最新状态,因为新来的 g 在饥饿模式下只能加入阻塞队列,不会抢锁
				// 所以这里直接更新锁状态是没有问题的
				atomic.AddInt32(&m.state, delta)
				break
			}
			awoke = true // 代表当前 g 已经从阻塞到醒来
			iter = 0 // g 醒来后,自旋次数清 0
		} else {
			old = m.state
		}
	}

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

Mutex.UnLock():

  • Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的 goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的 goroutine 的信息,所以,Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至今。
func (m *Mutex) Unlock() {
	if race.Enabled {
		_ = m.state
		race.Release(unsafe.Pointer(m))
	}

	// 先将锁给释放掉
	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) {
	// 如果锁的 locked 部分为负数
	if (new+mutexLocked)&mutexLocked == 0 {
		throw("sync: unlock of unlocked mutex")
	}
	// 如果当前锁不是饥饿模式
	if new&mutexStarving == 0 {
		old := new // 得到当前的锁
		for {
			// 如果锁没有等待者 或者 此时锁的状态是被持有 or 处于 Woken 状态(有 g 被唤醒 - g 自旋的效果) or 处于 饥饿模式
			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
				// 不继续唤醒等待者,直接返回
				return
			}
			// 等待者 - 1 且 将锁置为 Woken 状态
			new = (old - 1<<mutexWaiterShift) | mutexWoken
			if atomic.CompareAndSwapInt32(&m.state, old, new) {
				// 唤醒阻塞队列的其中一个等待者
				runtime_Semrelease(&m.sema, false, 1)
				return
			}
			old = m.state
		}
	} else {
		// 饥饿模式下,直接将锁移交给阻塞队列的第一个等待者
		// 注意:锁的 locked 部分是 0,由被唤醒的等待者设置,而且也没有更新阻塞的等待者数量
		//      如果锁的饥饿模式被设置,且 mutex 又是被持有的,那么新到来的 g 无法得到锁
		runtime_Semrelease(&m.sema, true, 1)
	}
}