Loading

Golang sync.Mutex 解析

观前提示,有能力可以直接尝试看源码,更清晰~

环境:

Darwin Kernel, arm64

go version go1.16.3 darwin/arm64

正文

同步原语 mutex 也是在 Go 并发控制中耳熟能详的存在了。小小的 mutex 让我产生了大大的疑惑。让我们进入源码一探究竟~

Mutex data structure

首先是 mutex 的数据结构,在 $GOROOT/src/sync/mutex.go 文件中:

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

这里的 state 字段是 int32 类型,但是它被分为了4 部分操作,为了使用更少的内存去表达锁的各个状态。

— 晁岳攀

锁的 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()
}

atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)这个是原子操作

所谓原子操作就是: 虽然有 cache 的存在,但是单个核上的单个指令进行原子操作的时候,能确保其它处理器或者核不访问此原子操作的地址,或者是确保其它处理器或者核总是访问原子操作之后的最新的值。

— 晁岳攀

其次由于缓存的存在,在多核情况下,会出现各个 cpu 读取数据不一致的问题(因为总是先从缓存中读取数据),为了解决这个问题,使用了一种叫做内存屏障的方法。一个写内存屏障会告诉 cpu,必须要要等到它(内存屏障)管道中的未完成数据都刷新到内存中,再进行操作。而且此操作会让相关的 cpu 的缓存行失效(cpu 缓存一致性协议)。

Golang atomic包中实现了内存屏障的功能。所以 atomic 包中的方法,1.实现了原子操作,2.有内存屏障的功能。两者确保了 cpu 使用该方法操作的值,其他 cpu 总是能读到最新值。当然这个不能常用,毕竟废掉了缓存,以我的见解,也只有并发的时候才用到。


回到上述代码,atomic.CompareAndSwapInt32 方法判断这一个互斥锁的状态,若是 0,则表示未被 g 持有。那么将其状态置为 mutexLocked,是一个常量,值为1。 race.enable 默认为false,所以接下来直接返回。此时 mutex 被 g 所持有。

刚刚是锁状态刚好为 0 的时候,若是锁被其他 g 所持有,那么将进入 lcokSlow 方法。

为什么拿不到锁不直接进行睡眠呢💤?以前的版本的确是这样设计的:每次都把拿不到锁的 g 加入队列,然后持有锁的 g 释放后会唤醒队列中的 g。但是出于性能的考虑,释放锁时将 g 交给正在占用 cpu 时间的 g 将能提高性能,这个 g 也就是刚好来请求锁的 g。但是想要直接交给这个 g 的条件也是十分苛刻的。

同时锁因为允许新来的 g 与醒来的 g 进行竞争,可能导致醒来的 g 获取不到锁,而导致阻塞的 g 可能长时间阻塞。这就是锁饥饿问题,毕竟一个刚醒来的 g 与 多个请求锁的 g 斗争 ,1 v n,胜率不大,所以引入锁的饥饿模式,即醒来的 g 让新来的 g 都乖乖加入阻塞队列去,不要与自己竞争。直到阻塞队列中的最后一个 g 发现自己是最后使用锁的 g 了,就解除锁的饥饿模式。


// 该方法可能被并发执行
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))
	}
}

经过上面的翻译(也就是我在代码上打的注释)

我们能得出以下的结论:

  1. normal 模式下:
    1. 加入了竞争的情况,被唤醒的 g 可能会与刚到来的 g 一起竞争锁,但是很被唤醒的 g 很可能失败。因为 g 被唤醒就说明锁已经被释放了,那么自旋的很可能已经获得锁了
    2. 睡眠时间超过 1ms 的 g,被唤醒后想要将 mutex 切换为 starving 模式,切换后也会再次进入阻塞队列且排在队列头部,等待锁的释放别唤醒
  2. starving 模式下:
    1. 只有被唤醒的等待者才能加锁,其他的 g 全都进入 FIFO 阻塞队列

谨记:

在原子操作处,所有的 g 都会是串行化的

Mutex.UnLock()

解锁逻辑相对于简单一些:

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)
	}
}

注意:

Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的 goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的 goroutine 的信息,所以,Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至今。

— 晁岳攀

所以尽量做到谁 Lock 谁 UnLock,做到谁申请,就由谁释放。一般都是在同一个方法中使用。

还有 runtime_SemacquireMutex 这个请求信号量函数底层是用 gopark 函数实现的,而 gopark 函数:

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
	if reason != waitReasonSleep {
		checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
	}
	// 请求获取当前协程的 M
	mp := acquirem()
	// 获取 M 关联的 G
	gp := mp.curg
	status := readgstatus(gp)
	if status != _Grunning && status != _Gscanrunning {
		throw("gopark: bad g status")
	}
	mp.waitlock = lock
	mp.waitunlockf = unlockf
	gp.waitreason = reason
	mp.waittraceev = traceEv
	mp.waittraceskip = traceskip
	releasem(mp)
	// can't do anything that might move the G between Ms here.
	mcall(park_m)
}

该函数主要作用有三大点:

  • 调用 acquirem 函数:
    • 获取当前 goroutine 所绑定的 m,设置各类所需数据。
    • 调用 releasem 函数将当前 goroutine 和其 m 的绑定关系解除
  • 调用 park_m 函数:
    • 将当前 goroutine 的状态从 _Grunning 切换为 _Gwaiting,也就是等待状态。
    • 删除 m 和当前 goroutine m->curg(简称gp)之间的关联。
  • 调用 mcall 函数,仅会在需要进行 goroutiine 切换时会被调用:
    • 切换当前线程的堆栈,从 g 的堆栈切换到 g0 的堆栈并调用 fn(g) 函数。
    • 将 g 的当前 PC/SP 保存在 g->sched 中,以便后续调用 goready 函数时可以恢复运行现场。

-- 脑子进煎鱼了

mcall函数的实现是在 arm_arm.s 汇编文件中的:

// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall(SB),NOSPLIT|NOFRAME,$0-4
	// Save caller state in g->sched.
	MOVW	R13, (g_sched+gobuf_sp)(g)
	MOVW	LR, (g_sched+gobuf_pc)(g)
	MOVW	$0, R11
	MOVW	R11, (g_sched+gobuf_lr)(g)
	MOVW	g, (g_sched+gobuf_g)(g)

	// Switch to m->g0 & its stack, call fn.
	MOVW	g, R1
	MOVW	g_m(g), R8
	MOVW	m_g0(R8), R0
	BL	setg<>(SB)
	CMP	g, R1
	B.NE	2(PC)
	B	runtime·badmcall(SB)
	MOVB	runtime·iscgo(SB), R11
	CMP	$0, R11
	BL.NE	runtime·save_g(SB)
	MOVW	fn+0(FP), R0
	MOVW	(g_sched+gobuf_sp)(g), R13
	SUB	$8, R13
	MOVW	R1, 4(R13)
	MOVW	R0, R7
	MOVW	0(R0), R0
	BL	(R0)
	B	runtime·badmcall2(SB)
	RET

其中的 race 包是通过 go -race 开启检测数据竞争,信号量机制具体实现也还没弄懂,但不碍事儿。

Mutex 互斥锁在同步中是复用最多的锁了,channel,RWMutex,Once等

收获满满~ 加油~

参考资料

  1. 《Go 专家编程》作者:任洪彩 - 第五章 并发控制 - 书籍
  2. 《Go 并发编程实战课》作者:晁岳攀 - 基本并发原语 - 极客时间
posted @ 2021-08-21 23:09  zhixlin  阅读(326)  评论(0编辑  收藏  举报