作者简介:C/C++ 、Golang 领域耕耘者,创作者
个人主页:作者主页
专栏地址: 从原理解析go语言
刷题专栏:leetcode专栏
如果感觉博主的文章还不错的话,还请关注➕ 、点赞👍 、收藏🧡三连支持一下博主哦~~~
这一节紧跟这 上一节内容的继续写
💜 4. 只让看不让改的锁背后的原理
4.1 由来
当我们在多个协程同时只读时,只用互斥锁会很费很多性能,这时就有了读写锁,主要在下面三个情况用到
- 只读时,让其他人不能修改即可
- 只读时,多协程可以共享读
- 只读时,不需要互斥锁
4.2 读写锁步骤原理分析
- 一般来说,会有两把锁
读锁: 共享锁(多个协程或者线程可以共享)
写锁: 是互斥锁
- 多个协程进行读的时候,可以进行共享,如果来了写的协程,就不能获取锁,只能在等待队列中
- 当读的协程全部执行结束后,写的协程被唤醒,这时只能一个写协程能获取到锁,其他的只能在
等待队列中
- 只能等待
- 如果这时来了一个读锁协程呢, 这是读的协程也是得不到锁的,因为读锁和写锁是不兼容的
- 这时就会等待,所以读写锁一般有两个等待队列,一个是读等待队列,一个是写等待队列
4.3 读写锁需求
- 每个锁分为读锁和写锁,写锁互斥
- 没有加写锁时,多个协程都可以加读锁
- 加了写锁时,无法加读锁,读协程排队等待
- 加了读锁,写锁排队等待
4.4 读写锁数据结构解释
源码
w : 互斥锁作为写锁
writerSem : 作为写协程队列
readerSem : 作为读协程队列
readerCount: 正值: 正在读的协程 负值: 加了写锁
readerWait : 写锁应该等待读协程个数(还有等待几个读协程释放)
数据结构示意图:
4.5 加写锁
4.5.1 流程步骤
- 首先这个锁是初始状态时
写锁: 首先是加上w 互斥锁
- 这样才算加写锁成功了
- 有读协程时,表示有3个协程加了读锁
- 先加上互斥锁,这里需要减去 rwmutexMaxReaders , 变成负数,阻塞读锁的获取,然后下面有标记需要等待3个读锁协程释放,然后协程放入等待队列
- 等待的协程在等待队列里,需要等待
readerWait
个协程释放锁资源,才能被唤醒readerWait
记录了下一个写协程什么时候唤醒
4.5.2 流程总结
- 先加mutex 写锁, 若已经被加写锁会阻塞等待
- 将readerCount 变为负值,阻塞读锁的获取
- 计算需要等待多少个读协程释放
- 如果需要等待读协程释放,陷入writerSem (写协程队列等待)
【注】:代码在RWMutex 中Lock函数
4.6 解写锁
4.6.1 流程步骤
-
初始状态
-
会加上一个数, 然后把读协程放出来进去读取,之后锁释放
- 释放锁
4.6.2 流程总结
- 将readerCount 变为正值, 允许读锁的获取
- 释放在readerSem 中等待的读协程
- 解锁mutex
4.7 加读锁(readerCount > 0)步骤
- 已经有2的读协程,没有写协程干预, 这个情况简单,直接加上就行了
- 把2 变为3 就行了
4.8 加读锁(readerCount < 0)步骤
- 有写锁在, 里面是负值
- 这时候会进入到等待队列中
4.9 加读锁(总结)
- 将给readerCount 无脑加1
- 如果readerCount 是正数, 加锁成功
- 如果readerCount 是负数,说明被加了写锁,陷入readerSem
4.10 解读锁
4.10.1 readerCount > 0
很简单, 直接readerCount 减 1
4.10.2 readerCount < 0
- 表示有写协程等待, 这里要等3个读协程的释放
-
这里两边都减 1 ,给写协程维护值
-
直到减为1, 就说明写协程可以释放了
4.10.3 小结
- 给readerCount 无脑减 1
- 如果readerCount 是正数,解锁成功
- 如果readerCount 是负数,有写锁在排队(如果自己是readerWait 的最后一个,唤醒写协程)
4.11 使用经验
- RW 锁适合读多写少的场景,减少锁冲突
4.12 小结
- Mutex 用来写协程之间互斥等到
- 读协程使用readerSem 等待写锁的释放
- 写协程使用writeSem 等待读锁的释放
- readerCount 记录读协程个数
- readerWait 记录写协程之前的读协程个数
🤎 5. 如何通过WaitGroup 互相等待?
5.1 WaitGroup例子
还是上面场景
package main
import (
"fmt"
"sync"
)
type Person struct {
mtx sync.RWMutex
salary int
level int
}
func (p *Person) promote() {
p.mtx.Lock()
p.salary += 1000
p.level += 1
fmt.Println(p.salary)
fmt.Println(p.level)
p.mtx.Unlock()
}
func (p *Person) print(w *sync.WaitGroup) {
p.mtx.RLock()
defer p.mtx.RUnlock()
fmt.Println(p.salary)
fmt.Println(p.level)
w.Done() // 不止适用于主协程
}
func main() {
p := Person{level: 1, salary: 10000}
wg := sync.WaitGroup{}
wg.Add(3) // 有多少任务
// 模拟三个人给小伙p 升职加薪
go p.print(&wg)
go p.print(&wg)
go p.print(&wg)
wg.Wait() // 等不到3个任务执行的Done 就会卡在这里
// 在主协程中用这个防止退出,如果嫌弃太low, 可换为waitgroup
//time.Sleep(time.Second)
}
/**
不加锁
11000
4
13000
12000
4
4
加锁
11000
2
12000
3
13000
4
*/
5.2 一个协程需要等待另一个协程完成的场景
现在有一个需求: 实际业务中,一个(组)协程需要等待另一组协程完成
如果想要实现等待组,分析得出,需要记录下面3个东西
5.3 sync.WaitGroup
5.3.1 数据结构
5.3.2 Wait 函数
- 如果被等待的协程没了,直接返回
- 否则,waiter 加1, 陷入sema
5.3.3 Done 函数
- 被等待协程做完,给count 减1
- 通过Add(-1) 实现
5.3.4 Add 函数
- add counter
- 被等待协程没做完,或者没人在等待,返回
- 被等待协程都做完(减到0),且有人在等待,唤醒所有sema 中的协程
5.4 小结
- WaitGroup实现了一组协程等待另一组协程
- 等待的协程陷入sem 并记录个数
- 被等待的协程计数归零时,唤醒所有sema 中的协程
🖤 6. 让一段代码只执行一次,如何去实现?
6.1 需求
- 整个程序运行过程中,代码只执行一次
- 用来进行一些初始化操作
6.2 代码
package main
import (
"fmt"
"sync"
"time"
)
type Person struct {
mtx sync.RWMutex
salary int
level int
}
func (p *Person) promote() {
p.mtx.Lock()
p.salary += 1000
p.level += 1
fmt.Println(p.salary)
fmt.Println(p.level)
p.mtx.Unlock()
}
func (p *Person) print(w *sync.WaitGroup) {
p.mtx.RLock()
defer p.mtx.RUnlock()
fmt.Println(p.salary)
fmt.Println(p.level)
w.Done() // 不止适用于主协程
}
func main() {
p := Person{level: 1, salary: 10000}
once := sync.Once{}
go once.Do(p.promote)
go once.Do(p.promote)
go once.Do(p.promote)
time.Sleep(time.Second) // 不加这行不会打印
}
6.3 思路
找一个变量记录一下,从0变成1 就不在做了
6.4 思路实现1:atomic
做法: 通过CAS 改值,成功就做
优点: 算法非常简单
问题: 多个协程竞争CAS 改值会造成性能问题
func main() {
p := Person{level: 1, salary: 10000}
once := sync.Once{}
o := int32(0)
atomic.CompareAndSwapInt32(&o, 0, 1)
// 然后到实现的函数中判断这个值是否为1
go once.Do(p.promote)
go once.Do(p.promote)
go once.Do(p.promote)
time.Sleep(time.Second)
}
6.5 思路实现2:mutex(参考源码)
- 争抢一个mutex, 抢不到的陷入sema 休眠
- 抢到的执行代码,改值,释放锁
- 其他协程唤醒后判断值已经修改,直接返回
6.5.1 步骤
- 参考里面的两个变量
- 就一个Do 函数, 先判断做没做
- 获取到这把锁之后再次判断
6.6 sync.Once 使用
- 先判断是否已经改值
- 没改,尝试获取锁
- 获取到锁的协程执行业务,改值,解锁
- 冲突协程唤醒后直接返回
6.7 小结
- sync.Once 实现了一段代码只执行一次
- 使用标志+mutex 实现了并发冲突的优化
🤍 7. 实战如何排查锁异常问题
7.1 锁拷贝问题
7.1.1 什么是锁拷贝
m := sync.Mutex{}
m.Lock()
/
n := m
m.Unlock()
//在下面是锁不上的,n 是复制上面的那个状态
n.Lock()
注意事项:
永远不要拷贝锁, 直接新建一个锁, n := sync.Mutex{}
上面的例子看似简单,但在实际中,可能出现拷贝结构体, 结构体中有锁的情况,如果直接进行
拷贝就会出现上面的情况;
这种情况也不好发现, 例如下面的代码
p := Person{level: 1, salary: 10000}
p1 := p
7.1.2 使用vet 工具可以检测锁拷贝问题
在程序中可以执行这条命令go vet main.go
,如果出现所拷贝情况, 则会报下面错误
【注】: vet 还能检测可能的bug 或者可疑的构造
7.2 RACE 竞争检测
7.2.1 什么是RACE 竞争
不好用语言描述,用一个程序来进行表达
代码如下:
package main
import (
"fmt"
"sync"
)
type Person struct {
mtx sync.RWMutex
salary int
level int
}
func (p *Person) promote() {
p.salary += 1000
p.level += 1
fmt.Println(p.salary)
fmt.Println(p.level)
}
func main() {
p := Person{level: 1, salary: 10000}
for i := 0; i < 200; i++ {
go p.promote()
}
}
将上面的代码执行这条命令 go build -race main.go
,然后执行生成的文件, 这就是race 竞争检测
7.2.2 意义
- 发现隐含的数据竞争问题
- 可能是加锁的建议
- 可能是bug 的提醒
7.3 go-deadlock 检测
这有一个开源的三方工具,地址如下https://github.com/sasha-s/go-deadlock
7.3.1 使用方法
直接用这个包, 这个包是继承sync.Lock 的,
但是在代码中出现死锁可以提醒
7.3.2 工具分析
- 检测可能的死锁
- 实际是检测获取锁的等待时间
- 用来排查bug 和性能问题
7.4 小结
- go vet 检测bug 或者可疑的构造
- race 发现隐含的数据竞争问题
- go-deadlock 检测可能的死锁
8. 总结
8.1 atomic 机制
- 原子操作是一种硬件层面加锁的机制
- 将多个操作集中到一个原子指令中
- 数据类型和操作类型有限制
8.2 sema
sema 这个东西往往的被作为的等待队列,贯穿整章
(极少自己使用,往往配合其他东西用)
- 一种锁机制,以一个uint32 值作为计数器
- uint32 值代表同时可并发的数量
- uint32 = 0时,sema 锁退化成一个专用休眠队列
8.3 Mutex 互斥锁
- 协程获取不到互斥锁会自旋重试
- 重试多次会使用sema 机制休眠排队
- 长时间获取不到会饥饿
- 饥饿时不自旋,严格按照sema 队列排序
8.4 RW 锁
- RW 锁适合读多写少的场景,减少锁冲突
- RW 底层使用了一个Mutex 和两个sema
8.5 WaitGroup
- WaitGroup 实现了一组协程等待另一组协程
- 等待的协程陷入sema 并记录个数
- 被等待的协程计数归零时,唤醒所有sema 中的协程
8.6 once
- sync.Once 实现了一段代码只执行一次
- 使用标志 + mutex 实现了并发冲突的优化
8.7 如何排查锁异常问题
- go vet 检测bug 或者可疑的构造
- race 发现隐含的数据竞争问题
- go-deadlock 检测可能的死锁
推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习
如果此篇博客对你有帮助的话,可以动一动你的小手~~~
👍 点赞,你的认可是我创作的动力!
🧡 收藏,你的青睐是我努力的方向!
✏️ 评论,你的意见是我进步的财富!