脑抽研究生Go并发-1-基本并发原语-上-Mutex、RWMutex、WaitGroup

59 阅读6分钟

基本并发原语

临界区:避免程序中并发访问或修改造成严重后果。

  • 数据库、共享数据结构、I/O 设备、连接池中的连接

同步原语

包含:互斥锁 Mutex、读写锁 RWMutex、并发编排 WaitGroup、条件变量 Cond、Channel 等

适用场景:

  • 共享资源
  • 任务编排:goroutine + WaitGroup/Channel
  • 消息传递:goroutine +Channel。

Mutex实现了Locker接口

image.png

race detector:

检测并发访问共享资源是否有问题的工具,检测data race

缺点:不能在编译时检测,而且只有出现了问题才能显示data race

执行方式:go run -race counter.go

mutex:

img

用法一:直接使用

用法二:结构体中嵌套使用

用法三:结构体嵌套 + 方法嵌套

CAS(compare-and-swap)(原子操作)

CAS 指令将给定的值和一个内存地址中的值进行比较,如果它们是同一个值,就使用新值替换内存地址中的值。

Mutex的危险性:

Go语言的互斥锁不记录是哪个 goroutine(线程)给它上的锁。

  • 导致:任何 goroutine 都能开锁,因为 Mutex 不知道是谁锁了它,所以任何一个 goroutine 都可以调用 Unlock 将其释放。

所以记得 Unlock

Mutex全貌

  • 基石: CAS 比较并交换

  • Lock :幸运则持有,拥堵则自旋一段时间,抢到则占有锁,没抢到则加入等待队列。

    • 正常模式:线程可以插队,抢夺本应是队头的锁。
    • 饥饿模式:队头等太久(超1ms),不让抢了,队列变空 / 等时间变短 会恢复正常模式。
  • Unlock :如果线程执行完临界区代码,有别人直接拎包入住,它就不用管,否则要主动唤醒别人。

常见的 4 种错误场景:

  • 错误场景一:Lock/Unlock 不是成对出现
  • 错误场景二:Copy 已使用的 Mutex (vet 工具可以检查)
  • 错误场景三:重入:mutex是不可重入的

mutex的不可重入

可重入锁的意义:防止在函数递归或嵌套调用中,同一个 goroutine 对同一个锁的重复加锁请求导致自我死锁

在Go极少被使用,通常被视为一个设计缺陷的标志

可重入mutex的实现⬇️

1️⃣如果是锁的持有者,就增加计数,直接放行(“可重入”)。如果不是,就自己搞个自己的锁。

2️⃣释放别人的锁,直接报错。

3️⃣当计数器为零时,才是真正释放锁。

TokenRecursiveMutex vs. RecursiveMutex

TokenRecursiveMutex是巨大升级和范式转变。

  • Goid 锁是 “认人(goroutine)不认理(任务)”
  • Token 锁是 “认理(token)不认人(goroutine)”
// Token方式的递归锁
type TokenRecursiveMutex struct {
    sync.Mutex
    token     int64
    recursion int32
}
​
// 请求锁,需要传入token
func (m *TokenRecursiveMutex) Lock(token int64) {
    if atomic.LoadInt64(&m.token) == token { //如果传入的token和持有锁的token一致,说明是递归调用
        m.recursion++
        return
    }
    m.Mutex.Lock() // 传入的token不一致,说明不是递归调用
    // 抢到锁之后记录这个token
    atomic.StoreInt64(&m.token, token)
    m.recursion = 1
}
​
// 释放锁
func (m *TokenRecursiveMutex) Unlock(token int64) {
    if atomic.LoadInt64(&m.token) != token { // 释放其它token持有的锁
        panic(fmt.Sprintf("wrong the owner(%d): %d!", m.token, token))
    }
    m.recursion-- // 当前持有这个锁的token释放锁
    if m.recursion != 0 { // 还没有回退到最初的递归调用
        return
    }
    atomic.StoreInt64(&m.token, 0) // 没有递归调用了,释放锁
    m.Mutex.Unlock()
}
  • 错误场景四:死锁(争夺资源而相互等待)

死锁的四个必要条件:互斥、持有和等待、不可剥夺、环路等待

异常检测手段

通过搜索日志、查看日志,我们能够知道程序有异常了,比如某个流程一直没有结束。

  • 通过 Go pprof 工具分析,block profiler 可以监控阻塞的 goroutine。
  • 查看全部的 goroutine 的堆栈信息,查看阻塞的 groutine 究竟阻塞在哪一行哪一个对象。

额外功能

锁是性能下降的“罪魁祸首”之一,所以,有效地降低锁的竞争,就能够很好地提高性能。因此,监控关键互斥锁上等待的 goroutine 的数量,是我们分析锁竞争的激烈程度的一个重要指标。

TryLock

原理:有则持有,没有也不会阻塞等待。

  • 场景一:执行降级或替代方案

    • 成功去更新缓存。
    • 失败不会等待,跳过更新缓存,返回旧一点的缓存数据。
  • 场景二:提高系统吞吐量

    • Worker 尝试 TryLock(A)。
    • 成功则处理高优先级任务A。
    • 失败则立即去尝试 TryLock(B),处理低优先级的任务。
  • 场景三:避免死锁

    • 协程1:Lock(A) -> 然后 TryLock(B)。
    • 如果 TryLock(B) 失败了,说明可能要发生死锁。协程1会主动释放已经持有的锁A (Unlock(A)),然后等待一小段时间,从头再来。
    • 通过这种“获取失败就主动放弃”的策略,打破了死锁的循环等待条件。

获取等待者的数量等指标

危险⚠️略过

Mutex 实现一个线程安全的队列

Mutex + 结构体 + 方法

RWMutex

img

使用场景:可以明确区分 reader 和 writer,且有大量的并发读、少量的并发写,并且有强烈的性能需求。

  • Lock/Unlock:写操作时调用的方法。
  • RLock/RUnlock:读操作时调用的方法。
  • RLocker:为读操作返回一个 Locker 接口的对象。它的 Lock / Unlock方法会调用 RWMutex 的 RLock / RUnlock 方法。

Read-preferring 和 Write-preferring

Go 标准库中的 RWMutex 设计是 写优先(Write-preferring) 方案

RWMutex 的 3 个踩坑点

  • 坑点 1:不可复制

  • 坑点 2:重入导致死锁

    • 1️⃣writer 重入调用 Lock
    • 2️⃣锁升级:Goroutine A(读者身份)等待 Goroutine A(作家身份)完成,而 Goroutine A(作家身份)在等待 Goroutine A(读者身份)释放。A -> A 的内部循环。
    • 3️⃣环形依赖:多个Goroutine形成一个等待环。作家等老读者,老读者等新读者,新读者又等作家。
  • 坑点 3:释放未加锁的 RWMutex

避免重入!!!

WaitGroup

img

基本方法:

func (wg *WaitGroup) Add(delta int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()
  • Add,用来设置 WaitGroup 的计数值;
  • Done,用来将 WaitGroup 的计数值减 1,其实就是调用了 Add(-1);
  • Wait,调用这个方法的 goroutine 会一直阻塞,直到 WaitGroup 的计数值变为 0。

WaitGroup 编排任务:需要启动多个 goroutine 执行任务,主 goroutine 需要等待子 goroutine 都完成后才继续执行。

使用 WaitGroup 时的常见错误

  • 常见问题一:计数器设置为负值
  • 常见问题二:没有等所有的 Add 方法调用之后再调用 Wait
  • 常见问题三:前一个 Wait 还没结束就重用 WaitGroup

WaitGroup的noCopy可以辅助 vet 检查