Go语言悲观锁的用法(附带实例)
Go 语言提供了基于锁(共享内存)的协程同步方案。协程在操作数据之前需要加锁,操作完数据之后再释放锁,而锁(互斥锁)只能被一个协程抢占,即在该协程释放锁之前其他协程都不能抢占到锁,当然也就不能操作数据了。
本节将为大家介绍悲观锁的用法以及实现原理。
顾名思义,悲观锁就是对并发问题持有悲观的态度,认为本次操作一定存在并发问题。悲观锁解决并发问题的方案是在操作数据之前加锁,操作完数据之后再释放锁。接下来主要介绍悲观锁的其中一种——互斥锁。
如何基于互斥锁解决多协程操作(累加)同一个变量产生的并发问题呢?可以参考下面的代码:
互斥锁的使用还是比较简单的,只需要在可能产生并发问题的操作之前加锁,操作之后再释放锁就行了。然而可能发生并不代表着一定会发生,实际操作时也有可能不会发生冲突。那如果并发度比较低,也就意味着发生冲突的可能性比较低,此时是不是乐观锁的性能更好一些?
理论上是的。所以 Go 语言对互斥锁也做了一些优化,加锁时先尝试通过乐观锁实现,如果乐观锁加锁失败,再执行其他加锁方案,代码如下所示:
可以看到,Lock 方法第一步就是通过乐观锁实现加锁,如果成功直接返回,否则调用方法 lockSlow 继续尝试加锁,通过该方法的名称就能够猜到,后续的加锁流程执行会比较慢。
那么 lockSlow 方法的大概逻辑会是怎么样的呢?直接阻塞用户协程吗?那会不会出现这种情况:协程刚被阻塞,其他协程就释放锁了呢?
Go 语言对此也做了一些优化,在阻塞当前协程之前,先让当前协程尝试自旋(循环执行一些无意义的指令,避免阻塞协程以及协程切换),自旋结束后再次尝试获取锁。当然自旋也不是无条件的,比如当逻辑处理器 P 的本地可运行协程队列不为空时,那就不应该自旋,白白浪费 CPU 时间。
lockSlow 方法的核心代码如下所示:
本节将为大家介绍悲观锁的用法以及实现原理。
顾名思义,悲观锁就是对并发问题持有悲观的态度,认为本次操作一定存在并发问题。悲观锁解决并发问题的方案是在操作数据之前加锁,操作完数据之后再释放锁。接下来主要介绍悲观锁的其中一种——互斥锁。
如何基于互斥锁解决多协程操作(累加)同一个变量产生的并发问题呢?可以参考下面的代码:
package main import ( "fmt" "sync" "time" ) func main() { sum := 0 lock := sync.Mutex{} for i := 0; i < 10; i++ { go func() { for j := 0; j < 1000; j++ { lock.Lock() sum++ lock.Unlock() } }() } time.Sleep(time.Second * 3) fmt.Println(sum) }参考上面的代码,sync.Mutex 是一种互斥锁,只能被一个协程抢占,也就是说在该协程释放锁之前其他协程都不能抢占到锁。在执行 sum++ 之前加锁,之后再释放锁,这样只有当协程抢占到锁才能执行 sum++,即同时只能有一个协程操作变量 sum,当然也就不会发生冲突了。
互斥锁的使用还是比较简单的,只需要在可能产生并发问题的操作之前加锁,操作之后再释放锁就行了。然而可能发生并不代表着一定会发生,实际操作时也有可能不会发生冲突。那如果并发度比较低,也就意味着发生冲突的可能性比较低,此时是不是乐观锁的性能更好一些?
理论上是的。所以 Go 语言对互斥锁也做了一些优化,加锁时先尝试通过乐观锁实现,如果乐观锁加锁失败,再执行其他加锁方案,代码如下所示:
type Mutex struct { state int32 // 锁状态,0 表示没有被任何协程抢占 sema uint32 // 信号量 } func (m *Mutex) Lock() { // 乐观锁加锁 if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { return } m.lockSlow() // 其他方案加锁 }在上面的代码中,互斥锁 sync.Mutex 包含两个字段:
- state 表示锁状态,0 表示当前锁没有被任何协程抢占,常量 mutexLocked 表示当前锁已经被某一个协程抢占;
- sema 表示信号量,Go 语言参考了 Linux 信号量实现的互斥锁。
可以看到,Lock 方法第一步就是通过乐观锁实现加锁,如果成功直接返回,否则调用方法 lockSlow 继续尝试加锁,通过该方法的名称就能够猜到,后续的加锁流程执行会比较慢。
那么 lockSlow 方法的大概逻辑会是怎么样的呢?直接阻塞用户协程吗?那会不会出现这种情况:协程刚被阻塞,其他协程就释放锁了呢?
Go 语言对此也做了一些优化,在阻塞当前协程之前,先让当前协程尝试自旋(循环执行一些无意义的指令,避免阻塞协程以及协程切换),自旋结束后再次尝试获取锁。当然自旋也不是无条件的,比如当逻辑处理器 P 的本地可运行协程队列不为空时,那就不应该自旋,白白浪费 CPU 时间。
lockSlow 方法的核心代码如下所示:
func (m *Mutex) lockSlow() { // 循环获取锁 for { // 判断是否可以自旋 if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) { // 循环执行空指令 runtime_doSpin() continue } ...... // 如果设置新状态成功,尝试获取信号量 if atomic.CompareAndSwapInt32(&m.state, old, new) { runtime_SemacquireMutex(&m.sema, queueLifo, 1) ...... } } }参考上面的代码,lockSlow 方法的核心逻辑是利用一个循环先判断是否可以自旋,该判断逻辑由函数 sync.runtime_canSpin 实现,而函数 sync.runtime_doSpin 就是简单的循环执行空指令。最后,函数 runtime_SemacquireMutex 用于获取信号量,注意该函数有可能会阻塞当前用户协程。