基本并发原语
临界区:避免程序中并发访问或修改造成严重后果。
- 数据库、共享数据结构、I/O 设备、连接池中的连接
同步原语
包含:互斥锁 Mutex、读写锁 RWMutex、并发编排 WaitGroup、条件变量 Cond、Channel 等
适用场景:
- 共享资源
- 任务编排:goroutine + WaitGroup/Channel
- 消息传递:goroutine +Channel。
Mutex实现了Locker接口
race detector:
检测并发访问共享资源是否有问题的工具,检测data race
缺点:不能在编译时检测,而且只有出现了问题才能显示data race
执行方式:go run -race counter.go
mutex:
用法一:直接使用
用法二:结构体中嵌套使用
用法三:结构体嵌套 + 方法嵌套
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
使用场景:可以明确区分 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
基本方法:
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