作者简介:C/C++ 、Golang 领域耕耘者,创作者
个人主页:作者主页
专栏地址: 从原理解析go语言
刷题专栏:leetcode专栏
如果感觉博主的文章还不错的话,还请关注➕ 、点赞👍 、收藏🧡三连支持一下博主哦~~~
🧡 0. 前言
go 中的锁的底层理解是比较复杂的,但在go很多数据结构中都使用到了锁。如果深入了解并知道go 底层的锁是如何实现,不同的锁有啥区别,对于后面在项目中也很有帮助。
这一章节主要讲解一下四种锁
- sync.Mutex : 互斥锁
- sync.RWMutex : 读写锁
- sync.WaitGroup : 等待锁
- sync.Once : 初始化
💛 1. 锁的基础是什么
1.1 atomic 操作(原子操作)
1.1.1 不使用atomic
的场景
多协程累加经典例子
package main
import (
"fmt"
"sync/atomic"
"time"
)
func addP(p *int32) {
*p++
}
func main() {
c := int32(0)
// 并发实现对c的累加
for i := 0; i < 1000; i++ {
go addP(&c)
}
time.Sleep(time.Second * 1)
fmt.Println(c)
}
下面是随机运行的两个结果,可见此代码是充满随机性的,原因就是因为addP
函数不是原子操作,在项目中我们显然不是要的这个结果
1.1.2 使用atomic场景,保护并发变量
由于上面的例子不是我们想要的,现在我们来使用atomic 修改一下
package main
import (
"fmt"
"sync/atomic"
"time"
)
func addP(p *int32) {
//*p++ // 修改成下面一行
atomic.AddInt32(p, 1)
}
func main() {
c := int32(0)
// 并发实现对c的累加
for i := 0; i < 1000; i++ {
go addP(&c)
}
time.Sleep(time.Second * 1)
fmt.Println(c)
}
运行结果: 可以看到每次运行结果都是符合预定结果的
1.1.3 两个线程操作未保护的变量
这里是两个线程操作未保护变量的例子,在上面的代码还中有1000个并发执行,所以说,像这样的代码充满了随机性
使用atomic 修改后的代码,查看汇编的过程,会发现里面加了一个锁
- 我们发现汇编中加法前,有一个锁,这个锁是不同于我们使用的锁
- 这是一种硬件锁(由硬件实现的锁,不可更改,单纯实现原子操作)
1.1.4 原子操作机制
- 原子操作是一种硬件层面加锁的机制 (最最底层的锁)
- 保证操作一个变量的时候,其他协程、线程无法访问
- 只能用于简单变量的简单操作 (这里只是锁int类型,如果锁一个结构体,业务中的逻辑还是不行的)
1.1.5 atomic 其他功能
在上面我们使用了atomic 的累加功能,其实atomic还有一些功能,比如说下面的比较交换(CAS)、装载功能(load)等
CAS
他的功能就是将原先的值比较是不是old 值,如果是,换成100。 是将非原子操作换成原子操作
atomic.CompareAndSwapInt32(&c, 10, 100)
// 等于上面的一行代码
if c == 10 {
c = 100
}
load 功能
加载超过系统字长的值,可能需要多次,防止读取高位时,低位被改,就需要使用这个
使用atomic 中的这个功能,就能不受其他协程操作影响了
atomic.LoadInt64()
atomic 主要是将多个指令变成原子操作,防止其他协程操作的影响,在项目中,十分重要。
1.2 sema 锁
1.2.1 sema 锁的认识
- 也叫做信号量锁/信号锁
- 核心是一个uint32值,含义是同时可并发的数量
- 每一个sema 锁都对应一个SemaRoot 结构体
- SemaRoot 中有一个平衡二叉树用于协程排队
1.2.2 sema 锁底层示意图
下面是获取锁的函数
1.2.3 sema 操作(uint32 > 0)
获取锁: uint32 减1, 获取成功
释放锁: uint32 加1, 释放成功
1.2.4 sema 操作(uint32 == 0)
获取锁: 协程休眠,进入堆树(就是上面提到的平衡二叉树)等待
释放锁: 从堆树中取出一个协程, 唤醒
sema 锁退化成一个专用休眠队列
1.3 小结
- 原子操作是一种硬件层面加锁的机制
- 数据类型和操作类型有限制(只能简单的加锁类型)
- sema 锁是runtime 的常用工具
- sema 经常被用作休眠队列
💚 2. 互斥锁解决了什么问题以及如何工作的
2.1 sync.Mutex
- sync.Mutex是Go的互斥锁
- 在Go中用于并发保护最常见方案
2.2 使用场景分析
下面是模拟三个人给小伙person 升职加薪
package main
import (
"fmt"
"sync"
"time"
)
type Person struct {
mtx sync.Mutex
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 main() {
p := Person{level: 1, salary: 10000}
// 模拟三个人给小伙p 升职加薪
go p.promote()
go p.promote()
go p.promote()
time.Sleep(time.Second)
}
/**不加锁
13000
4
11000
12000
4
4
加锁
11000
2
12000
3
13000
4
*/
2.3 mutex 数据结构及图标展示
2.4 正常模式的加锁
2.4.1 加锁的步骤
- 尝试CAS 直接加锁
- 若无法直接获取,进行多次尝试自旋尝试
- 多次尝试失败,进入sema 队列休眠
2.4.2 状态分析
- 初始状态,两个协程在竞争同一把锁时
- 两个协程会竞争一个锁,得不到锁资源的就会一直自旋(等一下在去尝试获取锁)如果多次没有获取锁,就会休眠, 转过来就获取
sema == 0
就是休眠, 跟前面的章节对应上了
- 如果锁一直被占用, 另外两个协程竞争不到锁,就会到右边的等待队列中, waiterShift 字段就
为2, 有几个协程在等待就等于几
2.5 正常模式的解锁
2.5.1 加锁的步骤
- 尝试CAS 直接解锁
- 若发现有协程在sema 中休眠,唤醒一个协程
2.5.2 状态分析
- 如果现有的协程解锁后,内部会先检查有没有等待的协程,然后唤醒协程
- 已经指向唤醒协程
3. 这里也不是直接放出来后就能抢占到锁,会出现上面的情况
4. 然后这里就会跟上面的情况一样了
2.6 小结
- mutex 正常模式: 自旋加锁 + sema 休眠等待
- mutex正常模式下,可能有锁饥饿的问题
💙 3. 发生锁饥饿了怎么办
在上节说了,在大量竞争的情况下,有锁饥饿的情况
3.1 锁饥饿的状态是什么
先看两幅图
上面两个截图,表示两种状态
- 在正常情况下,现在在已经占领锁的协程在释放锁的时候会在等待(休眠)队列中去唤醒一个协程,让调度器重新的调度协程
- 那把这个协程唤醒之后,这个协程就要给这个锁加锁(由0置为1),如果这个锁存在严重的竞争情况,在这个协程放出来之前就有其他协程在竞争这把锁,所以说他虽然在等待队列里面等了很久,它被放出来也要和新来的协程竞争,它胜的概率也不大,失败之后又会被放到等待队列中去,时间长了就会出现锁饥饿情况
- 出现锁饥饿的情况,就会出现跟这个相关的业务就会出现一直卡住的情况,显然不是我们想要的
所以在源码中出现了一个字段state ,描述饥饿模式
3.2 mutex 饥饿模式
3.2.1 理解
- 指当前协程等到锁的时间超过了1ms, 切换到饥饿模式
- 饥饿模式中,不自旋,新来的协程直接sema 休眠 (就是没有跑一些空语句,在尝试,跑一些空语句,在尝试的过程)
- 饥饿模式中,被唤醒的协程直接获取锁
- 没有协程在队列中继续等待时,回到正常模式
3.2.2 步骤
- 这个G 是刚刚结束的协程,正在去等待队列中唤醒新的协程
- 饥饿模式下: 其他新来的协程(空白的协程)直接进入休眠队列,被唤醒的协程直接获取锁
- 这就叫饥饿模式
就是等待队列中协程已经空了, 就回到了正常模式
3.3 饥饿模式的意义
- 减少了自旋 的竞争(也就是说在高竞争的情况下,就不要竞争了,保证性能)
- 保证公平性,协程已经等了很长时间了。
3.4 互斥锁使用经验
- 减少锁的使用时间(在业务函数中只用锁住关键的代码)
- 善用defer 确保锁的释放
3.5 小结
- 锁竞争严重时,互斥锁进入饥饿模式
- 饥饿模式没有自旋等待,有利于公平
推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习
如果此篇博客对你有帮助的话,可以动一动你的小手~~~
👍 点赞,你的认可是我创作的动力!
🧡 收藏,你的青睐是我努力的方向!
✏️ 评论,你的意见是我进步的财富!