Golang- Mutex锁源码分析

381 阅读6分钟

Golang- Mutex锁

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言

本文适合对锁有大概了解的程序员进行阅读,这里的线程和协程先泛指为线程能拿锁的对象!

临界区: 当程序中有一部分代码会出现并发访问或者修改的情况,需要采取特殊手段将这部分代码保护起来以免出现数据错乱的问题,这部分被保护起来的代码叫做临界区,比如:对全局共享资源的访问修改

互斥锁: 可以使临界区同时只有一个线程(协程)持有,这样别的线程(协程)想持有临界区的时候就会等待(失败)。

没有锁会怎么样?

最经典的两个线程count++,两个线程读取到的count可能都是0,并发的时候完全有可能都是1,也就是不会按照顺序第一个加完等于1之后,第二个线程再加上1等于2。

GO语言中的Mutex-源码分析

// mutex 是一种互斥锁
// mutex 的零值是未加锁的互斥锁
// mutex 在第一次使用之后不能进行复制
type Mutex struct {
	state int32 //状态位 
	sema  uint32 //信号量,用来控制等待的goroutine 的阻塞,休眠,唤醒
}

const (
	mutexLocked = 1 << iota // mutex is locked
	mutexWoken
	mutexStarving
	mutexWaiterShift = iota

//互斥公平。
//互斥对象可以有两种操作模式:正常和饥饿。
//在正常模式下,服务员按FIFO顺序排队,但一个唤醒的服务员
//不拥有互斥对象,并与新的到达的goroutines竞争
//所有权。新到的gorroutines有一个优势——它们是
//已经在CPU上运行,可以有很多,所以一个唤醒
//服务员输的机会很大。在这种情况下,它被排在前面
//等待队列。如果一个服务员在超过1ms的时间内没有获得互斥对象,
//它将互斥对象切换到饥饿模式
//在饥饿模式下,互斥对象的所有权被直接从
//解锁的goroutine在队列前面的服务员。
//新到达的goroutines不会尝试获取互斥,即使它出现
//解锁,不要试图旋转。相反,他们自己排队
//等待队列的尾部。
//如果一个侍者收到了互斥对象的所有权,并且看到了
//(1)它是队列中的最后一个服务员,或(2)它等待的时间少于1 ms,
//它将互斥锁切换回正常操作模式。
//普通模式有相当好的性能,因为goroutine可以获得
//即使有阻塞的等待者,互斥对象也会连续出现几次。
//饥饿模式对于预防病理性的尾潜伏期很重要。
	starvationThresholdNs = 1e6
)

上述注释的信息有点大,接下来整理一下:

// mutex锁里面所有的状态标记
const (
	mutexLocked = 1 << iota // 持有锁的标记
	mutexWoken    //唤醒标记
	mutexStarving // 饥饿标记
	mutexWaiterShift = iota //阻塞等待的数量
	starvationThresholdNs = 1e6 //饥饿阈值 1ms
)

大概意思是这个锁是互斥公平锁,会排队的!它为了提高效率和防止线程一直等待自旋什么的,就有了正常和饥饿两种模式!

正常模式 (公平竞争,先看看谁拳头硬)

正常模式下,大家正常排队等锁

正常模式下waiter都是先入先出,在队列中等待的waiter被唤醒后不会直接获取锁,因为要和新来的goroutine 进行竞争,新来的goroutine相对于被唤醒的waiter是具有优势的,新的goroutine 正在cpu上运行,被唤醒的waiter还要进行调度才能进入状态,所以在并发的情况下waiter大概率抢不过新来的goroutine,这个时候waiter会被放到队列的头部,如果等待的时间超过了1ms,这个时候Mutex就会进入饥饿模式

饥饿模式 (我拳头硬,新来的后面去)

当Mutex进入饥饿模式之后,锁的所有权会从解锁的goroutine移交给队列头部的goroutine,这几个时候新来的goroutine会直接放入队列的尾部,这样很好的解决了老的goroutine一直抢不到锁的场景。

对于两种模式,正常模式下的性能是最好的,goroutine可以连续多次获取锁,饥饿模式解决了取锁公平的问题,但是性能会下降,其实是性能和公平的一个平衡模式。所以在lock的源码里面,当队列只剩本省goroutine一个并且等待时间没有超过1ms,这个时候Mutex会重新恢复到正常模式


Lock函数

接下来我们查看常使用的Lock方法:

// 加锁
// 如果锁已经被使用,调用goroutine阻塞,直到锁可用
func (m *Mutex) Lock() {
	// 快速路径:没有竞争直接获取到锁,修改状态位为加锁
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    // 开启-race之后会进行判断,正常情况可忽略
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	// 慢路径(以便快速路径可以内联)
	m.lockSlow()
}

快速路径:可以看到正常情况直接通过CAS修改锁标记位进行拿锁!

慢路径:m.lockSlow()

拿的到锁走快路径,拿不到就走慢的!

lockSlow函数

源码如下:

func (m *Mutex) lockSlow() {
	var waitStartTime int64 //记录请求锁的初始时间
	starving := false //饥饿标记
	awoke := false //唤醒标记
	iter := 0 //自旋次数
	old := m.state  //当前所的状态
	for {
		//锁处于正常模式还没有释放的时候,尝试自旋
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {		
			//在临界区耗时很短的情况下提高性能
                      if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
				atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
				awoke = true
			}
			runtime_doSpin()
			iter++
                        //更新锁的状态
			old = m.state
			continue
		}
		new := old
		// 非饥饿装进行加锁
		if old&mutexStarving == 0 {
			new |= mutexLocked
		}
                // 等待着数量+1
		if old&(mutexLocked|mutexStarving) != 0 {
			new += 1 << mutexWaiterShift
		}
		
		// 加锁的情况下切换为饥饿模式
		if starving && old&mutexLocked != 0 {
			new |= mutexStarving
		}
                //goroutine 唤醒的时候进行重置标志
		if awoke {
			if new&mutexWoken == 0 {
				throw("sync: inconsistent mutex state")
			}
			new &^= mutexWoken
		}
                
                 //设置新的状态
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			if old&(mutexLocked|mutexStarving) == 0 {
				break 
			}
                        //判断是不是第一次加入队列
			// 如果之前就在队列里面等待了,加入到对头
			queueLifo := waitStartTime != 0
			if waitStartTime == 0 {
				waitStartTime = runtime_nanotime()
			}
                        //阻塞等待
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)
                        // 检查锁是否处于饥饿状态
			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")
				}
                                //设置标志,进行加锁并且waiter-1
				delta := int32(mutexLocked - 1<<mutexWaiterShift)
				//如果是最后一个的话清除饥饿标志
                                 if !starving || old>>mutexWaiterShift == 1 {
                                        //退出饥饿模式				
					delta -= mutexStarving
				}
				atomic.AddInt32(&m.state, delta)
				break
			}
			awoke = true
			iter = 0
		} else {
			old = m.state
		}
	}
        // -race开启检测冲突,可以忽略
	if race.Enabled {
		race.Acquire(unsafe.Pointer(m))
	}
}

这个函数主要就是走拿不到锁之后线程的逻辑,主要就是判断当前是什么模式

**正常模式:**会不停自旋除非模式变饥饿或者锁的人释放了可以抢锁了,然后就走上面正常模式那个概念了

饥饿模式:走队列后面排队,上面饥饿模式的概念!

Unlock函数

//如果对没有lock 的Mutex进行unlock会报错
//unlock和goroutine是没有绑定的,对于一个Mutex,可以一个goroutine加锁,另一个goroutine进行解锁
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)
	}
}

利用原子类atomic解锁,主要看看解锁失败,会走unlockSlow方法

unlockSlow函数

func (m *Mutex) unlockSlow(new int32) {
        //解锁一个未加锁的Mutex会报错(可以想想为什么,Mutex使用状态位进行标记锁的状态的)
	if (new+mutexLocked)&mutexLocked == 0 {
		throw("sync: unlock of unlocked mutex")
	}
	if new&mutexStarving == 0 {
		old := new
		for {
			//正常模式下,没有waiter或者在处理事情的情况下直接返回
			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
				return
			}
			//如果有等待者,设置mutexWoken标志,waiter-1,更新state
			new = (old - 1<<mutexWaiterShift) | mutexWoken
			if atomic.CompareAndSwapInt32(&m.state, old, new) {
				runtime_Semrelease(&m.sema, false, 1)
				return
			}
			old = m.state
		}
	} else {
		// 饥饿模式下会直接将mutex交给下一个等待的waiter,让出时间片,以便waiter执行
		runtime_Semrelease(&m.sema, true, 1)
	}
}

这里分为正常模式的处理和饥饿模式的处理,

饥饿模式:直接将锁的控制权交给队列中等待的waiter,

正常模式分两种情况:如果当前没有waiter,只有自己本身,直接解锁返回,如果有waiter,解锁后唤醒下个等待者。

最后总结

我们在使用Mutex的时候需要注意一些坑,例如 lock和unlock不在同一代码段,确切的说没有一起出现例如

 var mutex sync.Mutex
func  main (){
     mutex.Lock()
     b()
}
fun b(){
 xxxxx
 xxxxx 
 mutex.Unlock
}

如果b在执行中出现了什么意外,或者随着时间的增加代码变得越来越复杂,导致unlock失败,或者重复unlock,就会造成死锁或者painc的风险。所以我们需要这样做:

 var mutex sync.Mutex
func  main (){
     mutex.Lock()
     defer mutex.Unlock
     b() 
}

fun b(){
 xxxxx
 xxxxx 
}

或者在使用锁的过程中复制了锁,例如函数的代码调用,当做参数传过去,重新进行加锁,解锁就会造成意想不到的结果,因为锁是有状态的,复制锁的时候会将锁的状态一起复制过去。对于这种复制锁造成的问题,可以使用go vet 来检查代码中的锁复制问题

tips: go vet 是怎么实现的 go vet 是采用copylock静态分析实现,只要是实现了Locker/UnLocker 的接口都会被分析函数的调用,rang遍历,有无锁的copy。

还有一个问题就是,锁的重入,也就是同一个goroutine多次去获取锁,当然在go的标准库下是没有重入锁的实现,从源码也能看出来,如果多次重复获取锁,会造成死锁的问题,那么这里就上最后一道菜,我们来实现一个重入锁,这样可以带来的另一个好处是**解铃还须系铃人,**也就是哪个goroutine加的锁就只能哪个goroutine解锁。(其实写过java的同学都知道,在java的标准库里面已经有重入锁的实现了)

重入锁实现demo

package main

import (
	"fmt"
	"github.com/petermattis/goid"
	"sync"
	"sync/atomic"
)

//重入锁结构体
type ReentryMutex struct {
	 sync.Mutex
	owner int64 //当前锁持有者(go routine id)
	reentry int32 //重入次数
}


//重入锁加锁
func (m *ReentryMutex) Lock() {
	//获取当前go id
	gid := goid.Get()
	//如果当前持有锁的go routine就是调用者,重入次数+1
	if atomic.LoadInt64(&m.owner) == gid {
		m.reentry++
    return
	}

	m.Mutex.Lock()
	//第一次调用,记录锁的所属者
	atomic.StoreInt64(&m.owner,gid)
	//初始化重入次数
	m.reentry =1
}

func (m *ReentryMutex) Unlock() {
	gid := goid.Get()

	//解锁的人不是当前锁持有者直接panic
	if atomic.LoadInt64(&m.owner) != gid{
		panic(fmt.Sprintf("wrong the owner(%d): %d!",m.owner,gid))
	}

	//调用次数减一
	m.reentry--
	if m.reentry != 0{
		return
	}

	//最后一次调用需要释放锁
	atomic.StoreInt64(&m.owner,-1)
	m.Mutex.Unlock()

}

重入多少次,就需要解锁多少次,其实很多同学会问,为啥需要重入锁,有什么场景会需要?这里留一个悬念,留给阅读文章的各位,去看看go的明星项目Docker和Kubenetes都因为Mutex犯了什么错。