golang-浅析mutex

背景-互斥锁

当程序中有一部分代码会出现并发访问或者修改的情况,需要采取特殊手段将这部分代码保护起来以免出现数据错乱的问题,这部分被保护起来的代码叫做临界区,例如对数据库的修改访问,对全局共享资源的访问修改 。如果多个线程(协程)同时访问临界区,有可能会造成意想不到的错误,为了防止这些错误的出现--互斥锁登场了,可以使临界区同时只有一个线程(协程)持有,这样别的线程(协程)想持有临界区的时候就会等待(失败)。

前菜

我们先不着急看mutex的具体实现,先来一段经典的代码

	1. var count = 0
	2. count++   //是否原子操作?

这里没有给出上下文,但是抛出了一个问题,第2行代码count++是不是原子操作,或者换个问法,是否是线程安全的,其实验证这个问题很简单,我们可以查看count++的汇编实现

go tool compile -s xx.go |grep xx.go:xx

        0x0021 00033 (main.go:7)        MOVQ    "".count(SB), AX
        0x0028 00040 (main.go:7)        INCQ    AX
        0x002b 00043 (main.go:7)        MOVQ    AX, "".count(SB)

这里通过go tool查看count++的汇编,可以发现count++的操作需要3步才能完成,先将值从内存加载到寄存器,执行自增操作,再把值从寄存器更新到内存。所以count++不是原子操作

tips:什么是原子操作?
简单的说cpu能在一个指令中完成的操作就可以称为原子操作,例如64位的操作系统,一次可以处理8byte的内容,如果一次赋值的内容小于等于8byte,那么可以认为这是一个 single machine word,是由系统底层保证操作的原子性。

既然我们看到了汇编指令,就引出了cpu的指令重排,cpu的设计者为了更加充分的利用CPU的性能,例如分支预测,流水线等,指令重排会将读写指令进行重排,但是会保证最后结果的正确,但是在多线程的环境下就会出问题,造成数据不正确。来看一看内存模型

我们知道CPU为了平衡内核,内存,硬板之间的速度差异,有了3级缓存的策略,从一级缓存到3级缓存速度由快到慢,离cpu越远,速度就越慢,主要目的还是为了减少CPU访问主内存的次数。那么,试想图中的两个线程同时 操作 A,B两个共享变量,他们先将各自操作的变量存储在store buffer,然后互相再去访问对方的变量,这个时候各自的变量还没有刷新到内存,结果拿到的都是互相的初始值,这个时候再去那这个初始值去操作就会出问题,所以在多线程的环境下CPU提供了 barrier指令,要求所有对内存的操作需要扩散到主内存之后,才能进行其他的内存的操作。

在了解了共享数据竞争和原子操作之后,那就要解决这个问题,那看一看上面的代码case怎么改

mutex.lock()	
 count++   //是否原子操作?
mutex.unlock()

简单粗暴,那很多时候其实总会漏掉一些场景导致出现问题,那么我们需要先主动找到问题。对此,Go提供了对于并发访问资源是否有问题的工具 -- race detector。

race detector 通过探测所有的内存访问,监视度这些内存地址的读写,当出现race的时候,就会打印出相关的告警日志。

可以在运行的时候发现race问题

go run -race xx.go

那么我们想知道它究竟干了什么,是如何检测的,可以在 compile 的时候 加上race参数。

go tool compile -race -S xx.go

看看结果

       0x002f 00047 (main.go:7)        LEAQ    "".count(SB), AX
        0x0036 00054 (main.go:7)        MOVQ    AX, (SP)
        0x003a 00058 (main.go:7)        CALL    runtime.raceread(SB)
        0x003f 00063 (main.go:7)        MOVQ    "".count(SB), AX
        0x0046 00070 (main.go:7)        MOVQ    AX, ""..autotmp_23+64(SP)
        0x004b 00075 (main.go:7)        LEAQ    "".count(SB), CX
        0x0052 00082 (main.go:7)        MOVQ    CX, (SP)
        0x0056 00086 (main.go:7)        CALL    runtime.racewrite(SB)
        0x005b 00091 (main.go:7)        MOVQ    ""..autotmp_23+64(SP), AX
        0x0060 00096 (main.go:7)        INCQ    AX
        0x0063 00099 (main.go:7)        MOVQ    AX, "".count(SB)

可以发现多了 runtime.raceread, runtime.racewrite,这些插入的检测race 指令,在运行的时候发现 race 问题。

但是我想提醒的是,- race 检测并不是万能的,他只会有在运行的时候确实发生了race才会检测出来,如果恰好在运行的时候没有发生race,是不会触发告警的。很多知名的项目,Docker,Kuberbetes 都因为data race的问题 修复了很多issue(所以还是要做好code review)

ok,既然我们知道如何找到问题,继续来解决问题。我们简单粗暴的使用了mutex的lockunlock方法解决了data race的问题,但是我们需要刨根问底,来看一看他的底层实现。

在go的sync包中定义了Locker接口,mutex就是实现了这个接口

// A Locker represents an object that can be locked and unlocked.
type Locker interface {
	Lock()
	Unlock()
}

加锁,解锁,简洁明了,Go的一贯风格。

来看看mutex是怎么实现这个接口的,这里以go1.15为例

Mutex结构体

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

可以看到Mutex中只有两个变量,关于state状态位的常量,在sync包中也定义了,具体变量如下。

State 常量

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

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

来看看Lock函数,分为两个部分, 快速路径,先通过CAS尝试直接获取锁,如果能获取到直接返回,否则进入慢路径的方法,这里的代码注释提到了内联

tips:方法内联
简单的说方法内联就是将被调用方函数代码“复制”到调用方函数中,减少函数调用开销,在2018年之前的go版本中,所有的逻辑都在Lock函数中,并没有拆出来,2018年之后Go开发者将slow path拆出来,当lock方法被频繁调用的时候,有两种情况,如果直接获得锁走的是fast path,这个时候内联就只有fast path 的代码,这样会减少方法调用的堆栈空间和时间的消耗 ,如果处于自旋,锁竞争的情况下,走的是slow path,这个时候才会把lock slow 的方法内联进来,这样方便了编译器做内联

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

如果认真读过一遍源码,应该会总结出来,Mutex会处于正常模式或者饥饿模式。

正常模式

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

饥饿模式

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

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

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

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

同样的,在unlock也有fastpath和slowpath,fastpath尝试解锁,解锁成功就返回,否则进入slowpath,slowpath分为正常模式的处理和饥饿模式的处理,饥饿模式直接将锁的控制权交给队列中等待的waiter,正常模式分两种情况 如果当前没有waiter,只有自己本身,直接解锁返回,如果有waiter,解锁后唤醒下个等待者。

ok,我们的主菜就上到这里,下面来一些饭后甜点。

甜点

我们在使用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++
	}

	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犯了什么错。

 LCOW: Graphdriver fix deadlock in hotRemoveVHDs by lowenna · Pull Request #36114 · moby/moby · GitHub

Prevent deadlock on azure zone fetch in presence of failure by cehoffman · Pull Request #45192 · kubernetes/kubernetes · GitHub

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值