Go-Mutex锁机制

Mutex
Go 语言的sync.Mutex由两个字段state和sema组成。其中state表示当前互斥锁的状态,而sema是用于控制锁状态的信号量。

type Mutex struct {
state int32
sema uint32
}
状态
state的最低三位表示当前锁的状态,默认情况下都是0

mutexLocked:表示是否上锁
mutexWoken:表示有协程被唤醒了
mutexStarving:表示当前锁是否处于饥饿状态
剩下的位表示了排队规模,即正在等待队列中的协程数量

正常模式和饥饿模式
正常模式:mutexStarving状态位如果为0表示处于正常模式,表明当前的锁竞争不严重,新进入的协程可以先不排队,直接尝试自选获得锁。
饥饿模式:mutexStarving状态位如果为1表示处于饥饿模式。当有协程等待时间超过1ms,就会进入饥饿模式,新的协程不会自旋获得锁,而是直接进入等待队列尾部排队。
正常模式主要是为了减少协程频繁挂起和唤醒的开销。而饥饿模式一方面是因为新唤醒的协程抢不赢自旋的协程,因为自旋的协程抢占了CPU并且还可能会有多个,从而 会造成饥饿,另一方面也是因为自旋也是对CPU资源的浪费,因此自旋时间不宜过久。

饥饿模式下,协程一定会直接到队尾排队,而正常模式下协程也不一定会进行自旋获取锁,还要考虑以下情况:

CPU只有一个或者GOMAXPROCS=1,这时候如果等待的协程占有了CPU,而运行中的协程却被挂起了是没有意义的。
已经尝试了4次自旋但仍未获得锁。这个好理解,因为不能一直自旋浪费CPU资源嘛,在Go中设置了4次的上限。
当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空。这种你即使获取到锁了,仍然要去P的运行队列去排队,那么抢先获取锁的意义是不大的。
因此,只有在正常模式,多核,自旋少于4次以及有一个运行中且队列为空的P才会尝试自旋获取锁。对应的源代码如下:

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

}
一旦进入了自旋状态,协程就会执行doSpin执行30此PAUSE指令,该指令指挥占用CPU和消耗CPU时间。

加锁
// Fast path: grab unlocked mutex.
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()
互斥锁的加锁是靠 sync.Mutex.Lock 完成的

当互斥锁的状态为0,即不存在锁的竞争,那么就会CAS原子操作尝试获取锁;如果互斥锁的状态不是 0 时就会调用 sync.Mutex.lockSlow 尝试通过自旋(Spinnig)等方式等待锁的释放,获取锁的过程大致分为以下几个部分:

判断当前 Goroutine 能否进入自旋;
通过自旋等待互斥锁的释放;
计算互斥锁的最新状态;
更新互斥锁的状态并获取锁;
在上面正常模式和饥饿模式我们分析了自旋的逻辑,下面我们看看状态更新的逻辑。

互斥锁会根据上下文计算当前互斥锁最新的状态。几个不同的条件分别会更新 state 字段中存储的不同信息 — mutexLocked、mutexStarving、mutexWoken 和 mutexWaiterShift:

	new := old
	if old&mutexStarving == 0 {
		new |= mutexLocked
	}
	if old&(mutexLocked|mutexStarving) != 0 {
		new += 1 << mutexWaiterShift
	}
	if starving && old&mutexLocked != 0 {
		new |= mutexStarving
	}
	if awoke {
		new &^= mutexWoken
	}

计算了状态的逻辑后,就会通过CAS获取锁

if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&(mutexLocked|mutexStarving) == 0 {
break // 通过 CAS 函数获取了锁
}

//没有获取到锁,保证信号量不会被两个goroutine获取
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
if old&mutexStarving != 0 {
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
}
在正常模式下,这段代码会设置唤醒和饥饿标记、重置迭代次数并重新执行获取锁的循环;
在饥饿模式下,当前 Goroutine 会获得互斥锁,如果等待队列中只存在当前 Goroutine,互斥锁还会从饥饿模式中退出;
解锁
该过程会先使用 sync/atomic.AddInt32 函数快速解锁,这时会发生下面的两种情况

如果该函数返回的新状态等于 0,当前 Goroutine 就成功解锁了互斥锁;
如果该函数返回的新状态不等于 0,这段代码会调用 sync.Mutex.unlockSlow 开始慢速解锁:
func (m *Mutex) Unlock() {
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}

func (m *Mutex) unlockSlow(new int32) {
if (new+mutexLocked)&mutexLocked == 0 {
throw(“sync: unlock of unlocked mutex”)
}
if new&mutexStarving == 0 { // 正常模式
old := new
for {
//如果互斥锁不存在等待者或者互斥锁的 mutexLocked、mutexStarving、mutexWoken 状态不都为 0,那么当前方法可以直接返回,不需要唤醒其他等待者;即饥饿模式下,且已经有协程被唤醒了,因此不需要唤醒其他的。
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
new = (old - 1<<mutexWaiterShift) | mutexWoken
//如果互斥锁存在等待者,会通过 sync.runtime_Semrelease 唤醒等待者并移交锁的所有权;
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else { // 饥饿模式
在饥饿模式下,上述代码会直接调用 sync.runtime_Semrelease 将当前锁交给下一个正在尝试获取锁的等待者,等待者被唤醒后会得到锁,在这时互斥锁还不会退出饥饿状态;
runtime_Semrelease(&m.sema, true, 1)
}
}

RWMutex
读写锁的实现和Java是类似的。读读操作是并发的,其他操作都是互斥的。

结构体
sync.RWMutex 中总共包含以下 5 个字段:

type RWMutex struct {
w Mutex //互斥锁
writerSem uint32 //写等待读
readerSem uint32 //读等待写
readerCount int32 //并发读的数量
readerWait int32 //被阻塞的读等待的数量
}
写操作使用 sync.RWMutex.Lock 和 sync.RWMutex.Unlock 方法;
读操作使用 sync.RWMutex.RLock 和 sync.RWMutex.RUnlock 方法;
写锁
加锁的过程如下:

func (rw *RWMutex) Lock() {
rw.w.Lock()
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
}

1.调用Lock加锁,并阻塞后续写操作。

2.加锁成功后调用 sync/atomic.AddInt32 函数阻塞后续的读操作:

3.如果还有读锁存在,该 Goroutine 会调用 runtime.sync_runtime_SemacquireMutex 进入休眠状态等待所有读锁所有者执行结束后释放 writerSem 信号量将当前协程唤醒;

解锁的过程如下:

func (rw *RWMutex) Unlock() {
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
throw(“sync: Unlock of unlocked RWMutex”)
}
for i := 0; i < int®; i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
rw.w.Unlock()
}

1.将readerCount变回正数,释放读锁。

2.通过for循环释放等待的读锁。

3.Unlock解锁。

读锁
加锁:

func (rw *RWMutex) RLock() {
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
}

1.调用atomic.AddInt32将readerCount+1,如果返回负数,表明有其他协程获取了写锁,则进入随眠状态等待唤醒。

2.如果为非负数,没有协程获取写锁,则成功加锁,返回。

解锁:

func (rw *RWMutex) RUnlock() {
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
rw.rUnlockSlow®
}
}
1.调用atomic.AddInt32将readerCount-1,如果大于0,读锁直接解锁成功。

2.如果小于0,表明当前有写锁,调用rUnlockSlow()唤醒写锁的协程。

总结
可以看到,写锁通过lock互斥锁,是读写和写写都阻塞。而写锁通过一个全局变量readerCount来控制,不需要Lock上锁和解锁,从而减少了上锁的解锁的开销。因此,读写锁的性能比互斥锁的性能要高,项目中有读的情况尽量使用读写锁提高效率。

WaitGroup
WaitGroup可以等待一组 Goroutine 的返回,在返回之前,调用WaitGroup.wait()的协程会阻塞。主要是通过一个全局的变量state来控制的,当state大于0,线程会阻塞,当一个协程调用wg.done(),会将计数器-1,直到等于0,调用wg.wait()的协程才会继续执行下去。

Once
Once可以以保证在 Go 程序运行期间的某段代码只会执行一次。实现与单例模式中双重检查所是一致的,不过是通过了一个原子变量替代了单例模式中的实例。实现如下:

func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}

func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

可重入锁
在Java中的锁是可重入的,即同一个线程需要再次获取锁时,不需要等待锁的释放,而是自动获取锁。实现是通过保存了当前锁获取的线程是哪个。获取之前,如果需要获取锁的线程是拥有锁的线程,那么直接进入,并将同步状态state加1,退出时,state减1,当等于0,才释放锁。

而在Go中,锁是不可重入的,下面这种情况,Java的话是不会有问题的,锁直接重入。而在Go中,会出现死锁。

func F() {
mu.Lock()
//… do some stuff …
F()
//… do some more stuff …
mu.Unlock()
}
为什么要这么设计呢,作者认为(https://stackoverflow.com/questions/14670979/recursive-locking-in-go#14671462),如果当你的代码需要重入锁时,那就说明你的代码有问题了,我们正常写代码时,从入口函数开始,执行的层次都是一层层往下的,如果有一个锁需要共享给几个函数,那么就在调用这几个函数的上面,直接加上互斥锁就好了,不需要在每一个函数里面都添加锁,再去释放锁。上面的代码则可以改成如下:

func call(){
mu.lock()
F()
mu.unlock()
}

func F() {
//… do some stuff …
F()
//… do some more stuff …
}
这样就避免了死锁,并防止了重复加锁。另外,合理的设计还能使代码解耦和分层。如下例子

func F() {
mu.Lock()
//… do some stuff …
G()
//… do some more stuff …
mu.Unlock()
}

func G() {
mu.Lock()
//… do some stuff …
mu.Unlock()
}
同样,这种情况下,Java可重入并不会出错,但是在Go中,F()和G()循环等待,会出现死锁。但是,合理的设计完全可以避免,我们对代码进行改造:

func call(){
F()
G()
}

func F() {
mu.Lock()
… do some stuff
mu.Unlock()
}

func G() {
mu.Lock()
… do some stuff …
mu.Unlock()
}
这样不仅避免了死锁,还对代码进行了解耦。这样的代码按照作用范围进行了分层,就像金字塔一样,上层调用下层的函数,越往上作用范围越大;各层有自己的锁。

总结:完全没有必要实现可重入锁,如果发现我们的代码需要可重入锁了,一定是我们代码有问题,立马进行重构。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值