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、两个信号量 writerSem 和 readerSem、两个原子计数器 readerCount 和 readerWait。
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 语言的高并发编程中有所帮助。