首页 > 编程笔记 > Go语言笔记 阅读:1

Go语言悲观锁的用法(附带实例)

Go 语言提供了基于锁(共享内存)的协程同步方案。协程在操作数据之前需要加锁,操作完数据之后再释放锁,而锁(互斥锁)只能被一个协程抢占,即在该协程释放锁之前其他协程都不能抢占到锁,当然也就不能操作数据了。

本节将为大家介绍悲观锁的用法以及实现原理。

顾名思义,悲观锁就是对并发问题持有悲观的态度,认为本次操作一定存在并发问题。悲观锁解决并发问题的方案是在操作数据之前加锁,操作完数据之后再释放锁。接下来主要介绍悲观锁的其中一种——互斥锁。

如何基于互斥锁解决多协程操作(累加)同一个变量产生的并发问题呢?可以参考下面的代码:
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 包含两个字段:
可以看到,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 用于获取信号量,注意该函数有可能会阻塞当前用户协程。

相关文章