Go 高并发下的锁:原理、应用与异常排查

164 阅读5分钟

Go 高并发下的锁:原理、应用与异常排查

引言

在 Go 语言的高并发编程中,锁是保障数据一致性和程序正确性的关键工具。本文将深入探讨 Go 语言中锁的基础、互斥锁、读写锁、WaitGroup 的使用以及锁异常的排查方法。

锁的基础

atomic(原子操作)

atomic 是原子操作,通过汇编代码实现,利用 CPU 级别的内存锁(如 LOCK 指令)保证操作的原子性。在操作一个变量时,其他协程或线程无法访问,确保并发安全。不过,它只能用于变量的简单操作。

示例代码:

package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var num int32 = 0
    // 原子操作加1
    atomic.AddInt32(&num, 1)
    fmt.Println(num) 
}

sema(信号量锁)

sema 的核心是一个 uint32,每个 sema 锁对应一个 semaRoot 结构体。semaRoot 结构体包含一个互斥锁 lock、一个 sudog 指针 treap(用于协程排队的平衡二叉树的根节点)和一个原子计数器 nwait(等待的协程数量)。

操作规则如下:

  • uint32 > 0 时,获取锁时 uint32 减 1,释放锁时 uint32 加 1。
  • uint32 == 0 时,获取锁的协程会休眠,进入堆树等待;释放锁时,从堆树中取出一个协程并唤醒。

互斥锁

解决的问题

sync.Mutex 是 Go 语言中的互斥锁,用于保证同一时间只有一个协程可以操作共享资源,避免多个协程同时操作导致的数据不一致问题。

工作原理

  • 状态表示sync.Mutex 结构体包含一个 state(32 位整数)和一个 sema(信号量)。state 的最后一位表示是否被锁,倒数第二位表示是否被唤醒,倒数第三位表示是否处于饥饿模式,剩余位数表示等待的协程数量。
  • 加锁过程
    • 快速路径:尝试使用原子操作将 state 从 0 改为 1,如果成功则加锁成功并返回。
    • 慢速路径:如果快速路径失败,进入 lockSlow 方法,在该方法中会不断尝试获取锁,可能会进行自旋操作,自旋多次失败后会进入休眠状态,进入 semaRoot 中的平衡二叉树等待。
  • 解锁过程:使用原子操作将 state 减去 mutexLocked,如果结果不为 0,进入 unlockSlow 方法,该方法会判断是否有多余的协程在等待,如果有则从 sema 树中释放一个协程。

锁饥饿

当协程等待锁超过 1ms 时,会进入饥饿模式。进入饥饿模式后,其他协程不再自旋,直接进入 sema 中的平衡二叉树等待;饥饿模式中的协程被唤醒后直接获取锁,不会和其他协程竞争。当 sema 队列清空时,会退出饥饿模式。这种模式减少了自旋次数,降低 CPU 消耗,同时保证了公平性,让等待时间长的协程优先获取锁。

使用注意事项

  • 尽量减少锁的使用时间,避免长时间持有锁影响性能。
  • 善用 defer 确保锁的释放,防止出现死锁。

读写锁

原理

读写锁是互斥锁的扩展,分为读锁和写锁。写锁互斥,没有加写锁时,多个协程可以并发加读锁;加了写锁时,无法加读锁,读协程排队等待;加了读锁,写锁排队等待。

结构体定义

sync.RWMutex 结构体包含一个互斥锁 w、两个信号量 writerSemreaderSem、两个原子计数器 readerCountreaderWait

  • w:用于写锁,有等待的写协程时持有。
  • writerSem:写协程等待队列。
  • readerSem:读协程等待队列。
  • readerCount:等待中的读协程数量,正值表示正在读的协程,负值表示加了写锁。
  • readerWait:写锁生效前要等待多少读协程释放。

加解锁过程

  • 加写锁
    • 没有读协程:先加互斥锁 w,然后将 readerCount 减去 rwmutexMaxReaders
    • 有读协程:先获取写锁 w,检查 readerWait,若不为 0 则等待读协程释放,将 readerCount 减去 rwmutexMaxReaders,写协程进入 writerSem 队列等待。
  • 解写锁:将 readerCount 变为正值,允许读锁的获取;释放在 readerSem 中等待的读协程;解锁互斥锁 w
  • 加读锁
    • readerCount > 0:直接将 readerCount 加 1。
    • readerCount < 0:将 readerCount 加 1,读协程进入 readerSem 队列等待。
  • 解读锁
    • readerCount > 0:直接将 readerCount 减 1。
    • readerCount < 0:将 readerCount 减 1,如果自己是 readerWait 最后一个,唤醒写协程。

WaitGroup

作用

WaitGroup 用于等待一组协程的结束。

结构体定义

WaitGroup 结构体包含一个 noCopy(防止被复制)、一个原子 uint64 类型的 state(高 32 位是计数器,低 32 位是等待的协程数量)和一个信号量 sema(等待协程的队列)。

方法

  • Add(delta int):给计数器加 delta,如果计数器变为 0,释放所有等待的协程;如果计数器为负,会触发 panic
  • Done():相当于 Add(-1),表示一个协程结束。
  • Wait():阻塞直到计数器为 0。

排查锁异常

检测锁拷贝问题

使用 go vet main.go 命令检测代码中是否存在锁拷贝问题。

竞争检测

使用 go build -race main.go 命令进行竞争检测,运行生成的可执行文件 ./main,可以发现隐含的数据竞争问题。

死锁检测

使用第三方工具 go - deadlock,该包继承了 sync.Mutex,可以检测死锁。

总结

在 Go 语言的高并发编程中,合理使用锁是至关重要的。互斥锁、读写锁和 WaitGroup 为我们提供了不同场景下的并发控制手段。同时,掌握锁异常的排查方法,可以帮助我们及时发现和解决潜在的问题,确保程序的稳定性和性能。希望本文能对大家在 Go 语言的高并发编程中有所帮助。