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