阅读 135

Golang锁资源原理以及源码解读(一)

一、锁资源介绍

1、乐观锁

乐观锁操作数据的时候很乐观,认为没有人同时修改数据,因此乐观锁不会上锁,在执行更新的时候判断在次期间别人是否修改了数据,如果修改了则进行回滚。使用版本号机制或者CAS算法实现。通常读多写少使用乐观锁。通常使用版本号机制和CAS算法实现。

版本号机制:修改数据的时候获取版本,更新操作进行的时候带上版本,若版本一致才会进行修改

CAS算法:包括3个操作数。 需要读写内存位置(V),进行比较的预期值(A),拟写入的新值(B)

操作流程如下:如果内存位置V的值与预期A值相等,则将A值改为B值。否则,不做任何操作。CAS一般是自旋的,如果操作不成功,稍后重试,直到成功为止。值得注意的是CAS是原子操作,虽然是2个步骤,但这是硬件支持的。

CAS的缺点:

1)ABA问题,也就是2个线程依次读内存的值。例如线程1和2,1线程将内存值改为X,2线程将内存值改为Y,2线程将内存值改为X,1线程继续操作。对于1线程看起来这个值没有被动过,但事实上已经被修改。解决的办法就是再带上版本号。

2)开销问题,CAS一直处于自旋状态,消耗CPU。

3)只能保证一个共享变量的原子操作。

乐观锁加锁吗?不加锁,只是修改之前比较内存的值。

2、悲观锁

总是觉得有人会修改数据,所以在修改数据之前先加锁。一旦加锁,只允许一个线程访问共享数据。通常写多读少使用悲观锁。Mysql的读锁、写锁、行锁都是使用悲观锁。

3、自旋锁

与互斥锁类似,在获取不到锁的时候是处于循环忙等,而不是休眠。

4、读写锁

当共享变量被加了写锁,其他线程对该锁加读锁和写锁都是阻塞的;

当共享变量被加了读锁,其他线程对该锁加写锁会阻塞,加读锁会成功。

所以读写锁是一种读共享,写独占的锁,适用与读多写少的情景。

二、mutex互斥锁源码解析

1、mutex工作模式

主要分为正常模式与饥饿模式

一个尝试加锁的goroutine会先自选几次,尝试通过原子操作获得锁,若自旋几次仍不能获得锁,则会通过信号量排队等待,(这种模式有更高的吞吐量,因为频繁的挂起和唤醒goroutine会带来较多的开销但不能无限制的自旋)所有的等待者都会按照先进先出的顺序排队,但是当锁被释放后,第一个等待者被唤醒后,并不会直接拥有锁,而是需要后来者竞争,也就是那些处于自旋阶段,尚未排队等待的goroutine。这种情况下,后来者更有优势,一方面他们正在CPU上运行,自然比刚被唤醒的goroutine更有优势;另一方面处于自旋状态的goroutine可以有很多,而被唤醒的goroutine每次只有1个,所以被唤醒的goroutine有很大的概率拿不到锁。这种情况下会被重新插入到队列的头部,而不是尾部。

当goroutine加速等待时间超过1ms之后,会把当前mutex从正常模式切换到饥饿模式。在饥饿模式下,mutex的所有权从执行unlock的goroutine直接传递给等待队列头部的goutine。后来者不会自旋也不会尝试获得锁,即使mutex处于unlock的状态,他们会直接到队列的尾部排队等待。当一个等待者获得锁之后,它会在以下2种情况由饥饿模式切换到正常模式。1、等待时间小于1ms,刚来不久。2、是最后一个等待者,等待队列已经空了。后面自然就没有饥饿的goroutine了。所以在正常模式下,自旋和排队是同时存在的。饥饿模式下,没有自旋,都去排队,严格的先来后到。

2、源码分析
2.1 mutex结构体
// mutex.go
type Mutex struct {
	state int32 // 互斥锁的状态,加锁和解锁,都是通过atomic包提供原子性的操作该字段
	sema  uint32 // 信号量,主要用作等待队列。用来唤醒goroutine
}

// mutexLocked 锁状态标识,置为1表示已加锁,
// mutexWoken是否已有goroutine被唤醒,置为1表示被唤醒,
// mutexStarving表示mutex的工作模式,0正常,1饥饿模式。
// state其他位(29位)用来记录有多少个goroutine在排队
复制代码

state是一个32位的bit,每一位都有其意义。

image.png

2.2 加锁

分为2种情况:

1)锁处于空闲状态,可以直接加锁。比如第一次goroutine抢占的时候

2)当有goroutine占用锁的时候,开始等待

while(0) {
    if 当前mutex = 正常模式 {
			自旋等待  // 注意自旋是有条件的
		} 
    if 当前mutex = 饥饿模式 {
			排队到最后一个乖乖等待 
		} 
	通过信号量挨排队等待 // P申请一个资源
	if gouroutine等待时间>1ms
		设置模式 = 饥饿模式
	if gouroutine等待时间< 1ms || 当前goroutine是最后一个
		设置模式 = 正常模式
}
复制代码

精彩的部分就看源码吧!

func (m *Mutex) Lock() {
	// 若锁处于正常状态,该锁没有被抢占。则直接加锁。比如第一次被goroutine抢占的时候,或者锁处于空闲的时候,也是这种状态。
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	// 当有人占有锁的时候,自旋等待
	m.lockSlow()
}

func (m *Mutex) lockSlow() {
	var waitStartTime int64
	// 不是饥饿模式,即正常模式
	starving := false
	// 未被唤醒
	awoke := false
	iter := 0 // 自旋次数
	// 记录当前的状态
	old := m.state
	for {
		// 若mutex已经锁且属于正常模式,尝试自旋。饥饿状态不会自旋
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
			// runtime_canSpin返回正常有如下条件
			//当前Goroutine为了获取该锁进入自旋的次数小于四次;
			//当前机器CPU核数大于1;
			//当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空

			// 正常模式。满足以上条件才可以自旋。
			// 将自己的状态以及锁的状态设置为唤醒,这样当Unlock的时候就不会去唤醒其它被阻塞的goroutine了
			if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
				atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                    // 这个字段就说明自旋更容易获得锁
				awoke = true
			}
			runtime_doSpin()
			iter++ // 自旋次数++
			old = m.state // 更新状态
			continue
		}
		// 自旋没有获得锁
		// 到了这一步, state的状态可能是:
		// 1. 锁已获取,锁处于正常状态
		// 2. 锁已获取,锁处于饥饿状态
		// 3. 锁空闲, 锁处于正常状态
		// 4. 锁空闲, 锁处于饥饿状态

		new := old // 当前最新状态
		// 若正常状态下,新的goroutine设置锁,尝试通过CAS获得锁;因为是饥饿状态,锁直接给队列的第一个。
		// 正常状态,期望获取锁
		if old&mutexStarving == 0 {
			new |= mutexLocked
		}
		// 饥饿状态,等待队列数量++
		if old&(mutexLocked|mutexStarving) != 0 {
			new += 1 << mutexWaiterShift
		}
		// 如果当前 goroutine处于饥饿状态(等待时间超过1ms),且锁被其他goroutine获取
		if starving && old&mutexLocked != 0 {
			// 则把锁从正常模式改为饥饿状态
			new |= mutexStarving
		}
		// 如果本goroutine已经设置为唤醒状态, 需要清除new state的唤醒标记, 因为本goroutine要么获得了锁,要么进入休眠,
		// 总之state的新状态不再是woken状态.
		if awoke {
			// The goroutine has been woken from sleep,
			// so we need to reset the flag in either case.
			if new&mutexWoken == 0 {
				throw("sync: inconsistent mutex state")
			}
			new &^= mutexWoken
		}
		// 通过CAS操作更新锁的状态
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			// 如果说old状态不是饥饿状态也不是被获取状态
			// 那么代表当前goroutine已经通过CAS成功获取了锁
			if old&(mutexLocked|mutexStarving) == 0 {
				break // locked the mutex with CAS
			}
			// If we were already waiting before, queue at the front of the queue.
			// 如果之前等待过,就要放到队列头。
			queueLifo := waitStartTime != 0
			// 若是新来的,放在队尾
			if waitStartTime == 0 {
				// 计算最新的等待时间
				waitStartTime = runtime_nanotime()
			}
			// 竞争失败,使用runtime_SemacquireMutex信号量,保证不会有2个goutine获取
			// 既然未能获取到锁, 那么就使用sleep原语阻塞本goroutine
			// 如果是新来的goroutine,queueLifo=false, 加入到等待队列的尾部,耐心等待
			// 如果是唤醒的goroutine, queueLifo=true, 加入到等待队列的头部
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)
			//  如果当前goroutine已经是饥饿状态了
			//  或者当前goroutine已经等待了1ms(在上面定义常量)以上
			//  就把当前goroutine的状态设置为饥饿
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
			// 再次获取一下锁现在的状态
			old = m.state
			// 如果说锁现在是饥饿状态,就代表现在锁是被释放的状态,当前goroutine是被信号量所唤醒的
			// 也就是说,锁被直接交给了当前goroutine
			if old&mutexStarving != 0 {
				if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
					throw("sync: inconsistent mutex state")
				}
				// 当前goroutine获得锁,并将等待的goroutine数减1
				delta := int32(mutexLocked - 1<<mutexWaiterShift)
				// 如果本goroutine是最后一个等待者,或者它并不处于饥饿状态,
				// 那么我们需要把锁的state状态设置为正常模式.
				if !starving || old>>mutexWaiterShift == 1 {
					// 退出饥饿模式
					delta -= mutexStarving
				}
				// // 原子性地加上改动的状态
				atomic.AddInt32(&m.state, delta)
				break
			}
			// 如果锁不是饥饿模式,就把当前的goroutine设为被唤醒
			// 并且重置iter(重置spin)
			awoke = true
			iter = 0
		} else {
			// 如果CAS不成功,也就是说没能成功获得锁,锁被别的goroutine获得了或者锁一直没被释放
			// 那么就更新状态,重新开始循环尝试拿锁
			old = m.state
		}
	}

	if race.Enabled {
		race.Acquire(unsafe.Pointer(m))
	}
}
复制代码
2.3 解锁

分为2种情况

1)若没有等待锁资源的goroutine,成功释放锁

2)有等待的goroutine,把锁给其他goroutine

// 不能给未加锁的锁解锁
if 状态 = 正常状态 {
    使用信号量V释放一个资源 // 若有等待的goroutine
}
if 状态 = 饥饿状态 {
    直接唤醒等待队列头部的goroutine
}
复制代码

精彩部分看源码

// 这里的释放锁的意思就是如果还有等待该资源的goroutine,会把该锁资源继续让给另外的goroutine(也就是加锁)
func (m *Mutex) Unlock() {
	if race.Enabled {
		_ = m.state
		race.Release(unsafe.Pointer(m))
	}

	// Fast path: drop lock bit.
	// 去除加锁状态
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 { // 存在其他等待的goroutine,把锁给其他goroutine
		m.unlockSlow(new)
	}
}

func (m *Mutex) unlockSlow(new int32) {
	// 如果说锁不是处于locked状态,那么对锁执行Unlock会导致panic
	if (new+mutexLocked)&mutexLocked == 0 {
		throw("sync: unlock of unlocked mutex")
	}
	// 如果锁的状态是正常状态(可以存在自旋锁与唤醒锁的抢占)
	if new&mutexStarving == 0 {
		old := new
		for {
			//没有等待的goroutine,或者当前goroutine已经抢到锁,已经被唤醒。即没有任何处于等待的goroutine。什么都不做
			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
				return
			}
			// 走到这一步的时候,说明锁目前还是空闲状态,并且没有goroutine被唤醒且队列中有goroutine等待拿锁
			// 那么我们就要把锁的状态设置为被唤醒,等待队列-1
			new = (old - 1<<mutexWaiterShift) | mutexWoken
			if atomic.CompareAndSwapInt32(&m.state, old, new) {
				// 如果状态设置成功了,我们就通过信号量去唤醒goroutine
				// V释放一个资源。唤醒一个阻塞的goroutine 但不是第一个等待者,可能有自旋的
				runtime_Semrelease(&m.sema, false, 1)
				return
			}
			// 循环结束的时候,更新一下状态,因为有可能在执行的过程中,状态被修改了(比如被Lock改为了饥饿状态)
			old = m.state
		}
	} else {
		// 饥饿状态,那么我们就直接把锁的所有权通过信号量移交给队列头的goroutine就好了
		// handoff = true表示直接把锁交给队列头部的goroutine
		// V释放一个资源,唤醒等待的goroutine
		runtime_Semrelease(&m.sema, true, 1)
	}
}
复制代码

后续文章还继续对以下内容进行分析

1、信号量的PV操作

func runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int)
func runtime_Semrelease(s *uint32, handoff bool, skipframes int)
复制代码

2、自旋的操作

func runtime_canSpin(i int) bool
func runtime_doSpin()
复制代码

3、Rwmutex 读写互斥锁

引用

文章分类
后端
文章标签