我们上次讲到的内存模型
我们也了解到Happens Before的一个语义
我们这一块要开始了解Go标准库里面最重要的sync包
syc包会包含像互斥锁 atomic
还有甚至一些高级的技巧类似像errgroup
errgroup属于Go标准库的一个子包
这些我都会系统去介绍
因为我们在微服务甚至像BFF网关这种场景里面
它就会用到类似像errgroup去并行的向下游发多个请求
进行数据的一些组装
Go语言最重要的一个特性其实就是并发
或者是我们所谓的channel
这一块因为有一些场景里面我们也会使用到
类似像Mutex或者是atomic这种方式
解决一些像配置加载的一些问题
我们这章详细的介绍一下
我们先看一下
"Share Memery By Communicating"
这句话其实是Rob Pike经常说的一句话
就是所谓的一个内存数据的一个共享
我们是要通过通信的方式来做的
其实也是Go非常重要的一个特性
我们知道Go里面的并发的原语就是goroutine和channel
这两个构造并发软件提供了一种优雅而独特的一个方法
Go没有一个显示的使用锁来协调对共享数据的一个访问
没有显示使用锁
我们在大部分场景里面
不会直接使用类似像sync包的互斥锁或者是atomic来解决
我们更加的是鼓励使用类似像channel
在两个goroutine之间去传递对数据的引用
这种方法确保在给定的时间只有一个goroutine可以访问数据
因为你是通过数据传递到另外一个goroutine进行数据的一个访问
我们任何时候在辩证的时候
都应该想到就是
"Don't communicate by sharing memory; instead. share memory by communicating"
这句话其实是非常经典的一句话
它其实也是区分其他语言
类似像Java C++和Python程序时
经常要考虑的所谓的线程之间的一个通讯
需要使用共享内存
共享数据如果你要满足Happens Before的原语
那么必须要用锁来保护
其实给我们代码写起来不是特别方便
我们这个地方来展示一个简单的例子
我们这个地方展示了一个小的demo
这个是我从Go官方的一个blog里面捞出来的
它这个demo
我们看一下它这个函数是要干什么呢
从一些原始的资源信息类似像url
可能是爬虫
类似像url里面去拖一些数据出来
看我们标准如果用其他的语言或者用锁的方式
去并发的交给多个线程
去拖那个数据我们一般会怎么做
我们先看一下Resource的结构体
它定义了一个url
然后定义了一个是否被pulling
pulling是否正在爬取
然后你上一次爬取资源的一个时间
它大概有这三个字段
它还定了一个结构体叫Resources struct
我们这个地方因为它是要爬很多url
所以它是用数组来构建了一个data字段
然后最终这个数据应该需要用一个lock
用一个互斥锁来保护
比方说我们Poller函数
我们看一下
Poller函数可以被很多个goroutine同时来访问使用
我们既然要保证满足Happens Before
我必须要使用所这个地方
所以我们先会去加锁
就是data的数组里面某一个具体的Resource的话
我们在用for循环把它展开
展开以后我们先看如果它是正在被爬取
我们就跳过
另外就是说
如果它的爬取的时间
这个地方我们看一下
如果它扒取的时间这之前爬过的
找一个爬的时间比较小的一个资源
然后我们找到它
而这个地方正在爬
你肯定要把它状态polling等于True
相当于前面是做一些过滤
前面是找资源和过滤
那后面的话
正在爬取
这个地方我其实省略了一些代码
理论上这个地方应该要do something
就做一些逻辑
然后我们再把它状态改掉
可能这个是在后面的
然后再这个地方再解锁
解锁完之后
如果没有找到任何资源
我们再跳出这个循环
我在后面其实是标记的
这个地方有 pull the URL
然后之后的话我们再来加锁
加锁的话
因为你url已经爬过了
我就把它状态更新为False
然后并且把lastPolled
就是它最后的一个更新时间
把它刷成一个当前时间
然后再解锁
那么Poller的函数可以被多个人同时去访问
达到一个什么效果
就是可以一类似一个任务分发
就是这些资源被分发到各个goroutine
它们同时来进行一个url的爬取
但是我们知道用这种互斥锁的方式
写起来的确是非常啰嗦
非常麻烦
如果我们用channel的方式怎么解决呢
非常简单我们只需要定义一个in和out的一个变量
然后我们只需要for循环这个in
就是你的输入
你的Resource的输入
就是我接收一些什么样的任务
接收完之后我这个地方
从channel里面拿到一个之后
我就进行一个URL的爬取
它去完之后它被爬过的发到out的另外一个channel
其实就非常简单
代码也会写的非常少
相比互斥锁来说
因为你chan内部的话
我们在讲Go runtime的时候
其实也会也会讲到chan的内部实现
虽然chan内部它也是用互斥锁来实现的
但是因为通过这样的一个包装
你是用一个Go原生提供的一个基础类型去做这件事情
所以你写代码就会非常简单
就像我们PPT里面说到的
就是你最终共享内存数据是通过通讯的方式
利用channel的话做一个
任务的承接和分发都是非常方便的
那chan其实在很多地方都会使用
我们在下一节也会重点去介绍一下chan一个使用
这个demo我们就演示到这里
Go在某一些情况下
就比方说我们类似要做一个很简单的自增
i++这种
value++这种
不太可能会去使用chan
chan来说对于这种任务分发
多goroutine去发任务
这种场景非常合适
用起来也比较简单
因为它的整个每个任务你要做的事情非常多
相对来说工作也比较重一些
但是如果你是简单的一些基础类型的
一些数据处理的话
你不太可能去使用chan
这样因为相对来说还是非常重的
我们时候呢就会涉及到
使用Go原生提供的像Mutex互斥锁这种方式
data race实际上是两个或者多个goroutine访问同一个资源
某一个全局变量或者是某一个局部变量
然后它因为被两个goroutine去共享
然后你尝试对资源进行一些读写
你又不考虑其他的人
就比方说有一个a在读b在写这种情况
这种情况下可能就会导致
你会遇到很多很常见的或者是很随机的
很随机很疯狂的一些bug
并且你需要花大量的日志记录
并且有时候需要一些运气
你才能定位到这些问题
我举一个非常非常典型的一个案例
在我们实际在业务代码开发过程中
我就发现我们有段时间
现上的一个服务它时不时会报一个JSON的Marshal
报一个data race
当时其实查这个问题也查花了一些时间
它这个case也非常简单
做数据读写的时候
我们的dao层返回的模型
那个model
返回到我们的业务层的时候
业务层因为对它那个model可能做了一些异步开了个goroutine
或者分发到另外一个
所谓的worker的一个goroutine里面去做一些处理
相当于你拿到一个资源
这个资源被投送到
另外一个异步的goroutine里面去做一些逻辑处理
然后再把这个对象往上层抛给它什么
我们的展现层
也就是API层
API层最终要做序列化
这个时候你在做序列化的时候
相当于产生了一个读行为
同时你异步的goroutine
或者worker的goroutine
它做的事情是什么
既有读然后也有一些变更
从json库里面报一个所谓的data race的错误
相当于并发访问map
其实这个问题为什么不好找
因为你去看了具体报错的时候
它报的是json的序列化报错
报的是有data race的存在
或者并行访问一个map
但是你就会觉得很奇怪
其实你的真正的根因的产生的地方是在哪
是在你worker的goroutine里面
对这个对象进行了一些读写
那么导致了一些很随机的一些bug
其实这种问题不太好找
对象的一个读写行为的话其实是非常困难找到
所以我也是要强调一点
当然了Go本身
也就是很早以前Go1.1的时候
很早期的Go1.0发布之后没多久
应该是当年
我忘记多少年了
6月份出了一个新的工具叫race detector
就是它专门用来做竞赛检测的
它可以直接把竞赛检测的功能打到你的代码里面
它会非常方便
我自己在线上我虽然用的会非常少
因为线上go build -race的话
对线上的性能会稍微有一些影响
但是我们在写一些单元测试的时候
我们经常会用go test -race
因为有些库它是goroutine safe的
它是goroutine安全的
这个时候我们可能会去构建一些benchmark
并且它是parallel的benchmark
它是很多个goroutine去模拟一个并行行为
模拟一个并发的行为
然后看看是否存在一个data race的情况
所以这两个工具也是非常方便
我们用起来的时候通过go build -race
或者go test -race来使用
我这个地方在演示一个案例
我们看一下在案例里面
我们先通过go build -race
我们这个是叫8.go一个文件
我们直接去跑的话
因为我们是go build -race
这个标记已经在编译的时候指定了这个参数
所以我们最终去运行这个程序的时候
如果产生了data race
其实线上我是不太建议
生产环境不太建议这样去玩
如果是你发现线上有一些类似的bug
你可以在我们的非生产环境里面去
通过go build -race
然后构建一个这样的测试环境
然后去压或者去回放一些数据
回放一些接口的请求
然后去模拟看看到底是什么地方出现的这种data race
你像我们这个地方
如果你生产环境出现data race的话
它整个进程会直接stop掉
对你生产线上的话可能影响会非常大
所以不建议在生产环境去使用
我们这个地方go build -race以后
我们看一下直接运行的话
它就会直接告诉你在哪一个goroutine
哪一个内存地址产生读行为
这个地方非常方便
它会告诉你代码的函数哪一行
这个地方显示的是27行
我们看一下在这个地方在这个地方产生了一个读行为
然后它会又标记出你是在哪个地方产生了一个写行为
它显示的是在30行
注意30行有个赋值操作
对Counter这个对象
这个地方产生了一个读
这个地方产生了一个写
我开了好多个goroutine
同时去调Routine方法
同时产生一个读写行为
所以这个时候它就会发现data race
然后它会把整个进程给退出
所以这个工具本身是非常方便的
当然了如果我用go test -race的话也会非常方便
我们这个地方没有写单元测试
这个是一个
这个代码我们看一下它
为什么会产生一个所谓的data race
因为很多的data race大家可能会不太容易想到
因为就像我刚开始参加工作的时候
我记得当时我的leader就问我
就是i++赋值是不是原子的
因为刚工作时间不长的时候其实不太清楚
可能觉得i++就执行了一条指令
它不应该是原子的
然后并且赋值从1加到2应该是一个原子的过程
因为你是自增到2
然后赋值感觉也是一条指令搞定的
实际上你真正去展开看它底层汇编的时候
它可能会变成三条指令
我后面有一个也会把i++
我们把它-s
就是go build -s的一个工具
可以把它汇编给打出来
你可以看到它其实是有几条多条指令组成的
如果是多条指令的话
那么如果任何一个并发行为
在任何一个指令
因为你有多条指令
你在任何一个指令之间
那么它其实就不满足原子
它可能就会产生破坏的
这个地方我们看一下它这个代码
非常简单
首先有一个Counter变量
Counter变量是一个
注意这个地方是个全局变量
然后它会先赋值
赋值给这个value
这个地方是产生个读行为
因为你要读Counter
然后赋值给value
然后这个地方我再把它进行一个++
把赋给一个局部变量做一个++
局部变量++之后赋值给Counter
看上去这个代码其实非常简单的
就是从全局变量读取
赋值一个局部变量
局部变量做个++
然后再赋值回去
核心就是这个地方是读
这个地方是一个写行为
为什么我们开了两个goroutine
我们for循环两次
理论上应该输出的结果等于4
我们看一下我们先把sleep注释掉
我们重新编译一下
结果等于4
我们甚至可以跑很多次
我们可以看跑很多次它基本都等于4
看上去好像突然出现了一次等于2
从结果你看大部分情况都是正常的
偶尔会报一次错
为什么
它结果是错误
我刚刚讲了它是产生了一次读行为
并且产生了一次写行为
那有没有可能是第一个goroutine
value局部变量已经等于2了
我们再看到代码
已经把value=2了
然后准备赋值给它的时候
2赋值给它的时候
另外一个goroutine这个时候启动了
另外一个goroutine
goroutine2启动的时候
它从这读出来
也就是说它继承了之前看到的值
所以说最终等于4
有没有可能value在赋值的时候
另外一个goroutine它其实已经读过了这个值
那么这个value就它读出来就等于0
那么你做两次迭代的时候
它最终就有可能会打印等于2
所以它会出现一个data race的情况
最终数据它就不满足你的期望
好
这个其实是一个非常典型的一个case
我这个地方我们再看看再加个sleep
因为我们刚刚运行了很多次才会出现2
我们加个sleep看看它是不是有什么区别
你看这个地方我们加了sleep之后
就非常容易出现
是为什么
其实也非常好理解
第一种情况
goroutine1它在做累加的时候等于2了
这个地方产生的赋值
那么这个时候Counter全局变量一开始等于0
迭代一次循环等于1
这个时候等于2
这个时候没加sleep前
那么goroutine2可能启动比较慢
我这个时候再去读到value的局部变量的时候
它读出来就等于2
我再进行迭代的时候
就迭代两次就等于3等于4
那对
所以数据是结果正确
但是我一旦加完这个sleep
在goroutine1启动
我做了一个短暂的暂停
那么goroutine2这个时候已经被拉起
这个时候再去跑
结果它就有比较高的概率会出现这个值等于2
所以这个就是个典型的一个data race
我们通过go build -race
甚至我们在单元测试通过go test -race
很方便去找到它data race
我们在对待data race的时候一定要非常的严谨
我们有些同学可能会知道
所谓的single machine word
叫一个单一的机器字节
我们一般64bit的CPU一个机器码是多少
一个是64bit就是8个字节
那么我们知道8字节赋值
计算机的内存复制8字节赋值是原子的
很多同学可能会利用这种黑科技
有些事情在我后面也会讲
其实有一些场景它也是不安全的
我们后面举一些小case
那就像这个地方i++
同学可能会觉得
你赋值给一个value一个局部变量
我这个代码是不是可以把它改一下
我这个地方红框(就是)框出来了
我把它Counter变量直接做一个Counter=Counter+1或者Counter++
实际上你这样的改法看上去好像解决问题
但是也没有
我刚刚讲了Counter++
或者Counter=Counter+1
它翻译过来底层的汇编的话其实就是三条指令
先把原始的Counter的值拷贝到BX寄存器
然后再调INCQ就是increment
用BX寄存器把它值加1
然后再把寄存器BX move到
就Move the new value to Counter
就是把BX寄存器的值赋值给Counter
内部是有三行汇编代码在执行以增加计数器
因为在这三行汇编代码之间运行的任何一个时间
都有可能会产生一个另外一个goroutine的上下文切换
但假设它这个时候被调度到
有可能就会产生一个data race
那么从技术上讲这个bug是仍然存在的
所以我们的Go代码看起来像是在安全的访问资源
实际底层的程序是不安全的
我们知道
我们上一节讲过Go的内存模型
要解决Happens Before
满足Happens Before就要有两个同步语义
我们最经典的对于i++这种场景
里面用互斥锁或者是原子自增
Mutex或atomic这两个
这个场景里面我们当然也可以用channel去解决
但channel来说相对会重一些
我们如果后面讲runtime的时候讲到它源码的时候也会去讲
它肯定是操作会比直接使用Mutex或atomic肯定要重一些的
对所以它并不是说我们Go的模型里面是鼓励你用通讯的方式解决
像任务分发这种比较重的一些并行的行为
并发的一些行为
但是一些底层的数据结构的一些赋值
我觉得如果从性能角度考虑的话
也可能会使用Mutext或者是atomic
这也是没办法的
好
然后这个地方举的有意思的例子是什么呢
我们看一下demo
这个地方我们定义的叫IceCreamMaker
就是造冰淇淋的一个人
然后他一般看到顾客
他一般现在会say hello
就给你打个招呼
所以我们定义一个interface叫Hello
这个时候我们定义了两个人
一个叫Ben一个叫Jerry
Ben这个人它实现了interface定义的Hello方法
Jerry也是一样的也实现这个方法
我们可能有些同学熟悉底层操作系统的一些原理或者计算机的一些原理
我们知道一般来说
single machine word它赋值是原子的
只有一条汇编指令去赋值
我们是有没有想过你定义一个
比方我在这个地方我初始了两个对象
var一个ben
var一个jerry
然后我们做一个字段的一个初始化
然后我又定义了一个maker
一个IceCreamMaker的一个变量
一个局部变量
请问maker变量赋值是原子的吗
感觉上它像是一个对象
然后看上去maker能是一个原子赋值
就是我们第一直感觉interface啊
感觉是应该是个指针
你去赋值一个8字节应该是原子的
我们这个地方构建了一个场景
有两个loop
一个loop0一个loop1
loop0的话会先把ben值给maker再开一个loop1
loop1是一个函数指针
这个函数指针是以匿名的一个方式
它做的方法就是把jerry又赋值给maker
因为他们俩都实现了IceCreamMaker的interface
我再go loop0再把它迭代回来
其实就是loop0拉起loop1 loop1执行完了再拉回loop0
它就是相互两个人交叉的去a调b b调a类似这样的
之后我在这个main函数里面
再把它打印出来
非常简单的一个demo
这个代码我们实际在运行的时候
它有可能会出现一个情况
我们看一下它Hello的方法
为什么我这个地方是Ben says. "Hello my name is Ben"
它会出现一个Ben says. "Hello my name is Jerry"
它竟然会出现这种情况
很奇怪为什么他会被跟他打招呼的是另外一个人
其实它就是跟interface的底层原理实现有关系
这个是我们写的一个demo
它原始的应该是这样的
我们可以简单跑一下
我们小改
我们就把这个地方小改一下
我们这个地方是一直在输出
当然这个也是一个运气的情况
有可能会出现这种交错
有可能它不一定100%出现
但是它有可能会出现我刚刚说的这种case
为什么会出现这种case
那是因为从底层的interface的实现来看
我们感觉上直觉上它是一个指针
但实际上它内部interface
我们可以理解为它是有两个指针的
我注意它这个地方是有uintptr
uintptr实际上就是8字节的
64bit的CPU下它是8字节的
有一个Type有个Data
那么Type表示的是
因为interface你肯定是要知道
你赋值的时候它具体的一个类型是什么
Data就是原始的值
那么Type指的是实现了个接口的这个struct
就是指向它具体类型
假设我们正常情况下
对于一个赋值语句万var maker IceCreamMaker=ben
理论上Type就指向Ben struct
那么Data就指向我们局部变量
因为我们在main函数里面声明的这样的变量
并且初始化的一个值
但是如果你是交替
当loop1在执行maker=jerry的时候
因为必须要同时更新两个
Type和Data这两个字段
也就是说有可能更新完Type
Data还没来得及更新的时候
我们有个死循环在输出
我们有个死循环在打印Hello方法
那么它就可能产生一个交错的行为
这个地方注意它有可能会出现这种case
尽量不要对Go原生的一些这种像interface map slice去做一些假设
有可能就会踩坑
我们尽量还是按照Go memory model
就是Go的内存模型里面提到过
写入单个machine word它是原子的
但是interface如果你不了解它的底层原理
你就容易踩坑
interface内部实际上是两个machine word的值
也就是说另外一个goroutine
可能在更改接口值时观测到它的内容
是一个type指向a Data就指向的是b
这种情况可能是存在的
我们这地方其实还做了一个bad case
就是我们把Ban的id我们加一个字段
因为两个类型它如果不能完全匹配
Ben和Jerry这个struct的内存布局如果长的不一样
它会直接panic
也是不一定会触发
我们再看一下demo
它其实不一定触发
但是这个就完全看运气
比方我们这个地方加一个模拟
我们跟它改成一样的
我们把它加一个id
它不一定触发
我们这回运气比较好
它报错了
因为底层两个结构体它其实是长得不太一样的
对吧
所以它会报错
取字段的时候他要根据这个结构体反射去找字段
可能就报错了
所以千万要注意是很容易踩这种坑的
那我这地方其实还做一个更夸张的case
我们刚刚是加了一个id对吧
我们把它注释掉
其实Jerry还有一种写法
对它还有这样的一个写法
而这个写法刚好不报错
为什么
这个跟
其实是个黑科技属于一个
如果你用这种写法的话它会刚好不报错
因为这是
为什么刚好是这样一个5字节的一个数组一个array呢
是因为刚好name
string类型底层就是这么来描述表示的
所以它是凑巧刚好不会报错
所以interface我们不是一个single machine word
一定要记住
我以前刚写Go的时候
我们包括像slice
slice我以前也觉得它
一般我们要初始化一个slice应该是make它
它是不是返回的是个指针
实际上也不是
slice它底层是一个叫SliceHeader的对象
它也不是一个single machine word
它有长度信息有Len有Cap信息有元素信息等等
它其实不是想象中的是一个数组的指针
它是一个SliceHeader
所以slice的这种在多goroutine访问的时候
不能去原子赋值的
就是一个变量
a=b它不能直接去赋值
但是目前的Go的runtime
对于map runtime里面它会返回一个hmap的指针
那么如果你的map在两个goroutine里面同时去赋值
注意map赋值给某一个全局变量
比方a=map1
同时在另外一个goroutine a=map2
这样的赋值
你现在是比较走运它是一个原子赋值
但是data race
它仍然是存在data race
虽然它可以这样去赋值
不代表它是合理的
我记得我以前在跟Dava
有一次问过他
就是这个问题
他认为没有所谓的安全的data race
没有安全的data race指的是safe data race
你的程序看上去不会报错
不会有什么太大的副作用
但是这个操作要么有data race
要么没有data race
不存在安全的data race
为什么这个地方我们即便是一个map这个对象
你make完之后就是赋值给全局变量
两个goroutine可以同时赋值
你去用go test去跑的话
它仍然会报data race
为什么
对虽然它是安全的
因为data race它是不仅仅是要满足一个原子赋值的这么一个特性
还要满足可见性的特性
也就是说有没有可能
我goroutine有一个全局变量a=map1
另外一个人b=map2
我这时候再去访问这个全局变量a的时候
是不是等于最后一次赋值
比方等于map2
不一定
所以它不一定满足可见性
因为有cache line的存在
我们可能访问的是一个局部的CPU的cache line
它认为你的内存没有过期
没有invalied
所以说你这个时候a=map1
或者a=map2
不一定会返回哪一个
这个是不确定的
即便你最后一次赋值只是a=map2
也不代表任何一个goroutine来访问全局变量的时候
它都能拿到map2
有可能有些人拿到map2
有可能有些人拿到map1
所以这个操作是未定义的
所以不存在所谓的安全的safe的data race
所以我们以前
相当于我在公司的时候会跟我们同学说
我们有时候需要用一些local cache
用一些local cache去做程序内部的一些加速
我们一般要做这种local cache
我们肯定是要用map去存一些对象
因为你可能有读写的产生
那么这个时候是读非常多写极少
因为local cache更新可能很低频
但是你的读可能很高频
我以前会跟我的团队的同学说
你因为map是原子赋值的
你可以去这样用它
但是到现在为止我是不建议这样做的
因为后来Go的标准库出了一个atomic.Value的对象
这个对象可以解决指针赋值原子的一个(特性)
满足原子和可见性
两个都满足
这个是非常重要的一个点
以前我是利用黑科技去解决
不加锁去访问map
我相当于是把这个map直接替换掉
我不是去动态更改里面的值
我是把整个全局变量指向的
或者是一个局部变量
某一个就是local cache的存到一个变量里面去
我以前是把变量指向这个值
是换成一个新的map
我们也把这种技术叫Copy-On-Write
Cow
把老的对象拷一遍
然后把新的值加进去
利用原子赋值替换这个map
但是我现在仍然建议大家用atomic.Value
而不是说因为哪天如果runtime改了这个行为
你就可能会踩个坑
所以地方一定要注意
我接下来就是要讲这个sync.atomic
在这个里面有一个我定义了一个Config对象
Config的对象里面就是个int数组
然后我们开了一个goroutine
把它进行一个i++ i+1+2+3+4+5
这种就是一个简单的加法
然后这个时候我会开4个goroutine
去疯狂的打印Config
一共打100次
其实它这个地方也是会存在一个data race
注意这个地方我有4个人在读有1个人在写
最终打出来的值它一定会存在是不连续的
因为看上去是012345
下一轮就是6789 10
但是它有可能会中间不连续
因为你在做加法的时候
i+1+2+3+4
在任何一个过程中你再去打印它
打印a的时候
可能会执行到其中的任何一步
所以它不是安全的
还是一样的要满足Happens Before
一定是使用读写锁 互斥锁还有atomic
这对于比较简单的一些加法
我们用atomic来解决
我们看一下demo
这个地方是我之前上打印出来的
可能找到它不连续的一个地方
这个也是要看运气
不一定会必踩
像这个地方就不连续
我们注意我们这一行标出来的
它是不连续的
如果你去要用atomic的去解决它
其实也比较简单
我们原始的代码是在这里
注意它的这个地方是可能会出现data race的
我们再有个config_test.go
我们改了一下
怎么做
我们每次Config对象我在写的
demo里面我们用了一个什么特性
就是用的atomic.Value
我现在声明的一个atomic.Value的这样的一个对象
然后我把Config结构配置保存进去
然后我在写的goroutine里面呢
我每次去new一个新的把它存进去
之后我是先调v.Load()
再把它断言成Config对象
然后再把再把它打印出来
其实就搞定了
非常简单
那么它其实是每次构建一个新对象
然后通过atomic.Value去存储进去
大家感兴趣可以自己看一下
它的实现非常有意思
我就不展开讲了
当然我们也可以用互斥锁来加锁解决
比方这个地方我先锁住
然后进行一个赋值然后再解锁
我在访问的时候
我们可以用读写锁
这个地方是读写锁
我们可以用RLock然后再把它打印出来
所以其实都是非常常见的
一个满足Happens Before的一个场景
读多写少就用读写锁
如果你的读特别多
我觉得甚至可以用atomic.Value
它可能性能会比读写锁还要好
赋值操作极少
因为极少你可以完全用新对象去替换
因为我不需要对老对象去进行一个修改
对注意这个地方是放了一个老对象进去
我是直接用一个新对象去替换掉
所以对于写极少读很多的情况下
用的atomic.Value可能是性能更好的一个选择
我们最终其实要看用互斥锁还是atomic
还是要做benchmark
只有benchmark能够出真理
Mutex相对更重一些
因为它设计更多的goroutine的上下文的一个切换
可能就是所谓的叫park blocking goroutine
它可能会把你的可能需要访问
但是所当前正在被持有
那么它就会把goroutine给park掉
所谓的暂停
然后如果等解锁之后再唤醒
所以我们这个地方做一个benchmark
我们看一下使用atomic.Value的方式
它内部只需要1.88纳秒
每一个op每一个operation
每个操作对于用互斥锁的做法它需要76(纳秒)
所以它成本会高一些
这个地方是我们列的一些结果出来
它实际上会快得多
所以我们适当的去考虑你的场景
你最终还是要做benchmark
就是模拟的场景
看哪个会更合适你
atomic其实更典型的场景
还是Copy-On-Write
Copy-On-Write这个为什么要单独讲
其实面试的时候
尤其像我们面试过程中可能会用Redis
问Redis的BGSAVE是怎么实现的
我们知道Redis是单进程的
BGSAVE它比较特殊
你单进程你要把一些你当前内存里面的数据
要把它做一个snapshot做个快照
然后把它写到磁盘里面
这种场景里面
你如何能够在满足当前Redis正在服务读写
同时你还要把它BGSAVE存到磁盘里面
所以当时Redis作者用了所谓的Linux的fork
那么它是fork的一个进程
fork的新进程和老进程
它是共享同一个内存地址空间
那么Copy-On-Write是指什么
如果你的老进程Redis的内存数据在变化在变更
那么是不是有一些值会被替换
那么这一些内存页呢
所谓的就是Copy-On-Write
就是copy出来
老的被篡改的内存数据它就会用新的内存页
你的老的就是用老的
这个就是标准的Copy-On-Write
因为你老的要拷贝一份给新的进程用
那么它就可以做BGSAVE存储到磁盘
所以你可以去了解一下BGSAVE fork原理 Cow
你基本上搜这些关键词都能搜得到
Copy-On-Write的思路在微服务降级
或者在local cache场景里面是经常使用的
写时复制指的是写操作的时候
复制一下全量的老数据到一个新的对象中
注意它是new一个新的对象
那么问题来了
老的对象其实它是只读的
它是从头到尾都是只读的
因为你没有去篡改它
你只是读一下它
把它拷到一个新的对象去
那么老的这些如果你不用的话
老的假设是个map
它可能就GC掉
那么新的对象你就要考虑如何能够安全的赋值给你的变量
我刚刚讲了如果是个map你可以赋值给变量
目前的runtime是原子的
虽然不满足可见性的问题
但是它的的确确不会报错
有可能会访问到过去
就是有可能不是最新的数据
因为真的是碰运气
有可能很长一段时间你访问的都是一个老数据
因为你的赋值操作虽然是原子的
但是你指向了新的内存地址
我们刚刚说的不是拷到一个新的对象里面去吗
它不一定在别的goroutine可见
那么比较安全的做法还是要有使用atomic.Value
来更新调用者的变量
既满足原子性也满足可见性
对吧
这两个都要满足
最终能够来完成所谓的无锁访问共享数据
这个里面我举的一个demo是什么
我们看一下
它这个地方也是一个config声明成一个atomic.Value
然后我们config.Store调一下loadconfig把配置加载成进去
这个goroutine每10秒去加载一次配置文件
就假设万一它有新版本的配置文件
它每10秒去Load一次
其它的我可以开n多个goroutine
n多个goroutine去并行地模拟成很多个请求去Load config
Load出来的这个Config其实是过去的一个副本
即便你这个时候去调到Store存储它
所以这个时候有一个goroutine访问到是
你上一次的Store进去的一个config
我们假设叫v1
如果这个时候存储的一个v2版本的一个config对象
可能有一些goroutine它就能拿到v2对象
所以它能够达到一个无锁的访问共享数据
利用的是atomic.Value
是比较安全的一个做法
旁边这个例子
这个例子也是一样的
其实我们有点像一个local cache的一个场景
所以大家还是要去认真看一下
标准库atomic.Value的
它的的一个文档
对它底层感兴趣也去阅读一下
也是很常用的
我们还要除了atomic
我们来稍微重一点的就是互斥锁了
对互斥锁讲
我们大概要介绍一下它的原理
因为我们知道互斥锁它保护的是一整段一个代码逻辑
它避免大家都进入到某一个临界区
产生一个内存(竞争)
就产生一些data race
产生一些数据的竞赛怎么做
互斥锁非常简单就是Look Unlock
没什么好讲的
但是我们注意一下
我们底层Mutex的原理其实也是非常有意思
我这里面就举一个小case
我们看一下这个代码
有两个goroutine
一个叫goroutine1
一个叫goroutine2
goroutine1我们看一下它是for循环
然后去持锁
持锁然后再sleep 100毫秒
然后再解锁
相当于它持锁的时间是100毫秒
另外一个goroutine它是先sleep 100毫秒
然后再去拿锁
也就是说它是持锁100毫秒
它是每100毫秒去拿一次
请问这两个goroutine
谁拿锁次数多
在for循环时间相同的情况下
我们这个地方就做了一个demo
模拟了一下跑了一下
结果发现goroutine1拿了70多万次数(口误，实际700多万次)
goroutine2只拿了10次
有点违反常理
感觉大家都是sleep100毫秒
为什么一个人持锁比另外一个人持锁多这么多倍
应该是700多万次
g2只获取了10次
所以很有可能在一些意外的case里面
会出现产生一个锁记是拿不到锁
其实跟go的runtime实现原理有关
我就要讲一下互斥锁的一个实现原理了
我们先看一下为什么会出现这种case
那么首先goroutine1将获得锁并休眠100毫秒
当goroutine2试图获取锁的时候
g2它试图获取锁的时候
它会被添加到锁的队列中
它是一个FIFO的
它是first in first out就先入先出
对吧
因为它发现锁被人持有了
那么他会park the goroutine
就是把g2暂停
加入到一个锁的所谓的FIFO队列中
然后它将进入一个等待状态
然后当goroutine1完成它的工作时候它会释放锁
1.8之前版本的Go
它是会通知队列
唤醒goroutine2
也就是这个图
它会release lock
然后在把g2这个goroutine
marked as runnable就是把它标记为可运行
之后等到go scheduler
就是go的调度器把它拉出来运行
然而goroutine2等待运行
goroutine2是需要一个唤醒的动作把它唤醒
那么这个时候因为我们goroutine1
我们回想一下它的代码
它这边一释放锁立马建议进行一个for循环
立马又去拿锁了
它for循环一结束
这里一解锁立马for循环继续拿锁
goroutine1将再次请求锁
就g1再去请求锁
g2因为它等待
它去被scheduler就拉起来再去取锁
结果它尝试去拿的时候
悲剧了
g1又把锁持有了
它将继续等入
它将继续的进入到一个等待模式
所以说最终结果就是g1是700多万次
g2只拿到10次
这个时候就产生一个锁饥饿
我们先看一下通常就是常见的几种互斥锁的一个实现原理
第一种模型叫barging
这种模式完全是为了提高吞吐量
什么叫barging
就是当锁释放的时候
它会唤醒第一个等待者
然后把锁给第一个等待者
或者给第一个请求锁的人
G3一旦去拿锁
一旦它释放了锁
唤醒一个goroutine
然后就拉倒
然后就结束了
至于它到底是不是能够它唤醒的那个人一定持有
不一定
因为这样吞吐最好
我唤醒它我就拉倒
我赶紧我的当前运行的goroutine继续做别的事情
第二种模型叫handoff
就是手把手
就这个图
当锁释放的时候
锁会一直持有
直到第一个等待着准备好获取锁
它公平
它降低了吞吐量
因为所被持有直到另goroutine要被唤醒
肯定是时间是非常久的
因为我们要等待一个Go scheduler把它拉起来
所以它时间是非常久的
所以有点像这个场景
我去把第一个在排队的人把它唤醒
然后等着它执行
那么一个互斥锁的headoff
刚刚说了它可以比较平衡
两个goroutine之间的锁分配
不会产生饥饿
但是它性能是比较差的
它会迫使第一个goroutine等着它ready了
我才能去释放锁
这个就比较慢
那么还有一种模型叫spinning
叫自旋
自旋是什么呢
自旋指的是在等待队列为空
因为你没有人在等
那么或者是应用程序重度使用锁的时
效果是不错的
因为你不断的暂停
parking或者unparking
就是暂停goroutine
我们叫暂停goroutine或者把它唤醒
它有一个上下文的切换
有不少的性能的成本开销
相比自旋来说要慢得多
那么自旋肯定是所谓的叫fast pass
就是它的路径是非常快的
因为你没有人在等待
我肯定是自旋尝试for循环
死循环来那么几发
然后如果拿到锁立马就持锁
这个其实是三种模型
那么Go1.8它使用的是barging and spinning的结合实现
什么意思
就是当试图已经被持有的锁时
如果本地队列为空也就是说没有人在等
并且goroutime runtime的P的数量
P是在runtime表示的是一个工作队列
像一个队列
因为你只有一个队列你肯定不能spinning
否则别的goroutine全部啥都干不了
因为我们假设只有一个线程一个队列
然后有大量goroutine
你这个时候尽量是让其他goroutine能够执行
不能去做spinning
所以是本地队的为空且P的数量大于1
那么goroutine将自旋几次
它不会一直自旋
自旋是有浪费CPU
其实它是属于浪费CPU然后换时间的
它将自旋几次
然后自旋以后
如果还拿不到锁它就会goroutine park就会暂停
那么在程序高频使用锁的情况下
它充当了一个快速路径
所以它是两者的结合
那Go1.9为了解决这个饥饿的模式
它添加了一个新的模式叫饥饿模式
它能够解决之前我们讲的case
它具体是怎么做
这个模式它会在释放的时候触发Handoff
我们刚才讲的handoff吞吐低
所以hendoff它不是什么情况下都会使用的
它的触发场景是所有等待锁超过一毫秒的goroutine
也称为有界等待
就是它等的时间比较长
我们会把这些goroutine标记为饥饿
当标记为饥饿状态的时候
unblock方法会hendoff
就是这个时候才会触发
有饥饿的goroutine会触发
handoff把锁扔给第一个等待者
那么在饥饿模式下
自旋也会被停用
否则会什么呀
spinning的goroutine不就很容易被抢到
你饥饿的goroutine又抢不到
所以在饥饿模式下自旋会被停用
传入的goroutines将没有机会获取为下一个等待者保留的锁
对
所以我们上完 Feature以后
它的锁就相对来说是比较公平的
所以我们看一下g1 57次
相对来说比较好的解决这个问题
所以我们通过这样的几张图啊
我们大概了解的整个互斥锁的大概的原理
它其实是不难
它所谓的存在fast pass就是spinning
也存在barging
是为了吞吐考虑
那么在存在饥饿的情况下
用handoff解决公平的问题
所以目前来说相对趋于完善
整个Go的互斥锁的实现
我们下一个是讲什么呢
下一个实际上是关于errgroup
errgroup我提过多次
大家可以看一下这个地址
我们叫是Go的一个官方的sub package
一个子包
如果我们需要把一个复杂的任务
尤其是典型的像BFF
它多个微服务rpc需要BFF都去捞
捞完之后需要做数据的聚合
然后这些任务之间如果不存在依赖关系
不存在a
类似说你先要调账号
先调账号获取判断账号的一些什么权限
你才能做别的事情
对吧
如果你不存在这种情况
就像我们比方打开b站
我们看一下视频详情页
这个视频详情页你肯定是传一个视频ID
视频ID这个时候我们肯定是先查稿件信息
查到稿件信息以后里面肯定有作者信息
就是up主的信息和你自己身份的信息
因为你要判断你跟up主有没有关注关系
这时候其实就是会去同步调一下rpc获取up的信息
同时会把你的
你自己的用户ID和up主的ID做一下关系链的判定
它其实可以并行去做
它完全没有任何数据依赖关系
所以应该像一个fork join
就是把任务做分解
分别计算然后再汇总
所以这种场景里面非常适合errgroup
核心呢
errgroup的源码也非常简单
它核心就是用了sync.WaitGroup
我们经常用WaitGroup是干嘛
管理goroutine的生命周期
我们之前介绍过好多次
就是goroutine的生命周期管理是非常重要的
因为你要起很多goroutine去并行做很多任务
那么用WaitGroup来管理是最简单的
对于errgroup的源码实现就是用WaitGroup
管理启动的goroutine
然后每一个goroutine分别执行
然后最终可以汇总一个结果
当然有一个报错它可以全部暂停
待会我们简单过一下它的源码
那么它最终解决什么问题
就是并行的工作流
如果没有数据依赖关系
每个工作流都可以并行执行
那么否则你的耗时就是a+b+c
a function + b function + c function三个rpc
如果abc完全可以并行的话
就是我所谓的短板效应
哪个最慢就是那个耗时就相对高一些
第二就是说用errgroup
你甚至可以去做一些错误处理或者优雅降级
处理起来都非常方便
另外就是errgroup很方便
通过context来做传播和取消
可以做很多取消
稍微要注意一下就是errgroup
因为它是一个函数的指针的写法
一个闭包的一种方式
所以一定要注意闭包的一个问题
我们看一下一个demo
这个就是标准的errgroup的场景
我们先new一个errgroup
拿到一个g对象然
后我们这个时候是打开的b站首页
我们会调一个Go方法
调用广告
因为你可能首页会看到广告
调用AI的接口
AI可能有些信息根据你个人的兴趣偏好
可能出一些你感兴趣的一些内容
然后调用运营平台
运营平台可能是它有一些广告位
有一些运营位置
可能是运营编辑推荐的
其实三个可以并行去处(理)
然后我们只要调Wait方法
你利用abc三个局部变量
因为相当于你这个地方可以a=xxxxx
b=xxxxx
那么abc因为是三个
分别是三个局部变量
它们不存在锁争用的问题
它们是三个人各搞各的
那么你最终就可以有办法把abc三个数据在wait方法之后都拿到
然后你可以做你自己的数据组装
所以我觉得这个是非常方便的
我们来看一下它的源码实现
其实也是非常简单的
内部实际上内部实际上你看用的WaitGroup来管理
用WaitGroup来管理goroutine
用sync.Once来做error的处理
因为是一旦有一个goroutine报错
有一个错误就退出
或者说可以忽略这个错误
它都可以做得到
我们看看它怎么做的
我们可以先看一下Wait方法
Wait的方法其实一定是等所有goroutine都返回
那么这个时候调一下cancel
就是把context给cancel掉
这个cancel其实是一个需要注意
这个地方因为你要携带上下文
所以这个地方context也是要cancel掉的
那么Go方法就是这个地方是管理生命(周期)
goroutine生命周期的
所以它做一个加一(g.wg.Add(1))
那么defer要把它结束掉
然后这个地方传的是函数指针
这个地方是要调这个f
然后这个error如果报错不等于nil
我用sync.Once(老师口误，下同)
sync.Once也是sync包里面很常见的
只会触发一次
你可以理解为多个goroutine使用它
最终你想干一个什么事情
只想让它做一次触发
就是用sync.Once有点像一个单例模式
g.errOnce.Do就是把g.err
这里这个地方是有个err的一个字段的
有个err字段
我可以把它赋值
因为它只会执行一次
假设两个goroutine同时都报错
我只会把第一个error保存起来
然后再去调一个cancel
这个地方会去调一个cancle
因为你这个地方调cancel的好处是什么
因为我这个地方会拿到一个context上下文
我内部的函数指针里面可能会依赖这个context
因为它一旦被cancel
依赖context的对象就会被取消掉
那么对应的方法也会被退出
因为context是最常见用于goroutine取消的
所以这个代码其实是
对于errgroup代码来说本身是非常简单的
但是它使用场景多非常多好
最后的话就是sync.Pool
主要还是来说我们很高频
比方像打日志
打日志我们一般大家如果去看Nginx代码
它这个打日志
就是它性能为什么好
Nginx它性能做得非常好
Nginx是做的非常特殊
它的打日志是在栈上面
注意
它是在栈上面申请了一块局部的一个buffer
然后buffer拿到之后
去构建做各种的字符串的拼接
再打印
因为我可以用栈上的内存地址把它刷到磁盘里面去结束
性能非常好不申请任何对象
复用的还是栈对象
但是对于Go来说要做到这样的事情
其实不太方便
为什么呢
因为你在栈上拿到一个buffer
你构建完这个字符串之后呢
你只要调一个fmt.Printf
或者做一个你的日志库
把它buffer打进去
它会判定为逃逸
这个时候没办法只能逃逸了
对吧
那就会分配到堆上
那怎么办呢
对于这种Request-Driven级别的
很高频要频繁地拿一个buffer
然后使用释放使用释放
怎么办
在Go里面目前只能使用sync.Pool
你可以构建从Pool中调get方法
从Pool中拿一个对象
如果这个Pool为空
它会调new方法
所以Pool的结构体是需要你自己去实现这个New方法的
所以我们目前对于Request-Driven的这种方式适合的
但有同学可能会问
如果你是要用内存池做
所谓的连接池合不合适
其实是不合适的
因为放到sycn.Pool里面的所有对象
它都可能会被任何时候
都可能会被GC掉
那么这个时候如果你连接没关闭
它就存在一个连接泄露
不能放连接池
它只能放没有状态的
能够被随时回收的一些这种内存的对象
所以放进Pool对象
是不确定它什么时候会回收的
目前Go的特性来说
它可能是第二次GC的时候会把它存在Pool里面的池会清空
因为目前早期版本
其实sync.Pool做了很多优化
感兴趣也可以看一下
它做了很多去锁的优化
我们知道sync.Pool它是个全局的对象
很多人都可以去复用它
它有点像ThreadLocal一样
你可以挂在某一个线程上
然后减少锁分配的方式
它内部使用的是一个ring buffer加双链表的方式
一定要注意
因为1.13它引入了一个叫victim cache
它的GC顶多内容能够保留两轮GC
一定会被清空
所以你不能放带状态的
sync.Pool就是我们说的它是很高频释放buffer
就是非常适合sync.Pool
像我们做很多中间件
甚至我们做一些基础库
我们kit的库基础库
都非常实用非常常见
去使用sync.Pool
这些就是我们今天关于整个sync这一块
sync包的标准库
sync包我们就介绍到这里了
回顾一下我们介绍的像atomic
介绍的互斥锁的原理
也介绍了像errgroup
也简单说了一下sync.Once的一个使用
sync.Once在这种单例的场景里面非常常见
然后也介绍了一下
最后一个就是sync.Pool这种模型
其实还是要大家需要花大量时间去看标准库以及源码
或者是我们在实际工作中类似errgroup是非常非常常见使用的
但是一定要注意它使用的过程中注意闭包的问题
我们下面会讲Go使用通信来替换原生
就是原生的这种很底层的锁的方式
来访问共享内存
那就是channel
我们下一章会接重点介绍