我们上一节了解到了goroutine的一些泄露
生命周期一些管理
然后也给大家给了一些作业
去使用我们的goroutine和channel的这种方式
或者类似像errgroup的这种方式
可以参考一下我们go-kratos框架里面
如何去做一个应用的生命周期管理
这个代码其实还是非常精髓的
其实也是大家对知识的一些总结
我希望大家还是要多尝试写一写
然后也看看开源项目里面有些有哪些地方的确是做得不好
像我之前做应用的生命周期管理的时候
就发现有一些开源库它在启动一个HTTP server的时候
它就会判定error不等于nil
如果不等于nil的时候就调log.Fatal
然后直接让程序退出了
我们之前也介绍过log.Fatal是通过os.exit退出的
最终没有执行defer
这种方式都是不好的
所以系统性我们了解到goroutine本身的管理以后
我们在接下来就要重点去了解一下内存模型
内层模型实际上是整个并发语义的非常关键的一个内容
很多人其实在这个地方了解的不够深或者不够仔细
当然这背后的扩展的一些知识也非常多
我都会在课堂内容里面抛出来
然后业余的时候大家再去花时间系统去学习
我们先了解一下Go的内存模型
Go的内存模型全部是在Golang的一个官方的reference里面
我建议大家还是要仔仔细细去阅读一遍
然后我通过我讲解的方式给大家快速的过一下
如何保证一个goroutine中看到另外一个goroutine中修改变量的值
简单来说我有两个goroutine
一个是负责写一个负责读
你如何保证两个goroutine
其中一个goroutine一定能看到另外一个goroutine修改的值呢
因为我们知道它是一个并发的访问
这种情况下其实它是一个叫未定义的行为
如果程序中修改数据的时候
其他goroutine同时在读取
你要保证它一定能看得到这个值
我们一定要将读取操作串行化
简单来说一定是先写操作
执行完了然后才能进行读操作
你就能看到它写的值
为了串行化访问
我们就要使用Go提供的一些叫同步语义同步原语的一些关键字
比方说
使用channel
使用sync包里面的Mutex加锁
或者是使用sync atomic里面的原子的一些CAS指令
来做一些数据的一些串行化的访问
我们尽量不要太聪明
以为你觉得它是一个原子的操作
然后你能读到它最新的值
我见过最危险的一些代码
就是说在一个goroutine里面定义一个True or False
然后在另外一个goroutine里面依赖这个变量来做一些控制开关
如果它为False的话
goroutine就退出一个for循环
这种其实是如果没有任何的Go的同步原语的话
它是很危险的
那么为什么会出现这种情况
我们先了解一个术语叫什么
叫Happen-Before先行发生
那么在一个goroutine中
读和写一定是按照程序中的这种
你的代码的 写的这种方式顺序去执行的
编译器和处理器只有在不会改变goroutine行为的时候
它可能会去改变读和写的执行顺序
这句话什么意思呢
CPU的处理器
还有我们的编译器
它会为了一些优化程序的目的
可能会让你的一个goroutine里面
比方说A=1 B=2
它在一个goroutine里面
它在编译或者是在执行的时候
A=1和B=2
它在不破坏这个代码的语义的情况下
这个顺序是不一定的
它可能会重排
就有可能先执行就是
你代码写的是A=1 B=2
那么有可能会先执行B=2 A=1
是因为对于一个goroutine来说
它的语义实际上是没有任何区别的
但是对于另外一个goroutine来说
它看到的顺序到底是A=1先执行还是B=2执行是不确定的
我们就拿一个例子来说
线程1它执行A=1 print(B)
线程2执行的是B=1 print(A)
就是一个交叉
线程1是做A=1的赋值
线程2做B=1的赋值
然后大家交叉打印变量
我们先想一下这个代码
按照我们的理解
我们看一下会出现哪些case
因为两个线程1线程2会同时执行
先执行第一步A=1
再执行第二步print(B)
这个时候是不是(线程)1打印的就是0
因为print(B)
这个时候还没
假设线程2还没有执行
是不是就打一个0出来
那么这时候
假设线程2再执行
B=1 print(B)已经打过了
第二步已经执行过了
你执行第三步其实已经没作用了
再执行第四步
那么第四步执行的时候就是01
换个顺序就有可能是10
或者是我们在还有一种情况就是(1)和(3)同时执行
(1)执行了然后再执行(3)
再执行(2)和(4)
是不是打印两个1
所以说10 01 11这三种结果都会出现
有没有可能会出现两个00
看上去逻辑上无论谁先执行
都打印不出00
我们就看看一种case
我们用我们写的代码
先会编译成汇编的代码
也就是各种指令
包括读写内存的指令
CPU的设计者们为了榨干CPU的性能
叫无所不用其极
各种手段都用上了
你可能会听过一些什么像流水线 分支预测
然后其中为了提高读写内存的效率
因为我们知道内存它的访问速率
比方说是有多少个纳秒对吧
但是为了读写内存进行得更快
我们CPU设计者它不是抽象的
所谓的L1 cache和L2 cache
我们叫cache Line有一本书专门
介绍CPU的和内存的一些原理和结构的
叫《What Every Programmer Should Know About Memory》
可以去搜一下这篇文章
它其实讲了很多电路的一些细节
比方说内存大概是怎么做的
我们知道内存是dram吧
就动态内存
还有sram静态内存
静态内存的它的整个电路的设计上会更简单一点
所以它的存储空间会小一些
但是它访问速度会快得多
所以说CPU的设计者为了解决这种差速的问题
就是一个特别快一个慢一些
我们知道硬盘可能更慢
我们一般怎么去优化硬盘的效率
我们是不是一般利用内存buffer一些数据一次刷盘
对吧
同样的思路也是一样的
我们如果有了CPU开出来
也是可以对一些指定打包去刷到我们的内存里面去
所以说也是为了提升读写内存的效率
会对读写的指令重排或者是buffer
我们先看一下重排
所谓的内存重排英文叫什么
叫Memory Recordering
我们这个部分先讲CPU的重排
其实还有编译器的重排
我们先看看demo
这个代码里面X=0
然后for i in range(100)
作为一个0~100的一个range
然后for循环里面不断的把它X=1赋值
然后再把它打印出来
但实际上有没有可能会优化成这样的
就是X=1
因为你从逻辑上来说
你for循环100次X=1
实际上是不是可以直接把它这个代码变成X直接等于1
我再做X的打印其实是没有任何影响的
因为你这里边是执行了100次X=1
和我只执行1次X=1没有任何的区别
我们其实就是编译器可能会做一些类似的优化
做了一些for循环里面的优化
这是一种方式
但是对于单线程运行
在一个线程里面的这个代码来说
它没有任何区别
但是如果同时有另外一个线程干了另外一件事情
比方说另外一个线程他干了个事情X=0
它执行了X=0
有没有可能for循环
如果被优化成我们右边这张图
它一开始是打印1
突然就会打成0
有没有可能
对于多核心多线程的场景下
是没有办法轻易的判定两个代码是否是等价的
所以说有可能做的这些优化就会导致
你的程序出现一些不可预期的一些异常
或者是不可预期的一些结果
就像这个情况
我们打印
即便有另外一个线程有可能执行了X=0
执行了一次
我的结果有可能就是大概率都是等于全是打印1
但是对于这种第二种情况它就不一定了
它有可能是打印1然后后面全是打印0
这个就指的差别
我只举了一个非常简单的例子
非常简单的例子
我们再来看一个
刚才讲了
CPU为了抚平内核内存硬盘之间的速度差异
搞出了各种策略
例如我们知道的三级缓存
L1 L2 L3 cache
那么L1是最快的但是它最小
L2稍微大一点然后它就速度又慢一点
L3可能更大一点速度可能更慢一点
我们再看这个代码还是刚刚A=1 B=1 print(A) print(B)这个代码
假设我们执行的第一步操作
我们这个时候的操作A=1赋值
变量赋值
我们是先写到我们的CPU的cache line
先把它存在这个地方
L1的cache line里面
为了不必让2(Thread2)等待1(Thread1)的执行结果可见之后才能执行
我们把一的结果暂存到这个叫store buffer
就是cache line
暂存的store buffer
就说我的1的操作
我没有必要把它全部刷到内存里面去
我直接存到store buffer里面
那么我的2(Thread2)做操作的时候
其实是无关紧要的
那么store buffer对于单线程来说是完美的
因为你看你这个地方直接把它放到store buffer里面
假设我这个地方是print(A)
因为我是从我的自己的核1的store buffer里面去取的
所以打印出来也是没有问题的
写到了A1的L1 cache line
那么我再去打印的时候也是A=1
没有任何语义上的破坏
对于单线程来说是完美的
但是假设我们先执行(1)和(3)
好这个时候会变成什么样呢
第一个Core1
它的store buffer里面A=1
那么Core2呢
B=1
这两步都执行完了
接着执行(2)和(4)
奇迹就要发生了
(2)这个操作
它看了一下自己的store buffer里面没有发现B的值
于是它就从我们的内存里面它把B的值取出来
它这个地方用的B
它把B的值取出来
因为我相当于这个地方产生了cache miss
主存我的内存里面去读的
那么读出来等于0
于是我们就打印了一个0
那么同样的第4步
第4步操作打印A
它也是去访问A
结果cache miss读不到
又从主存里面去读A=0捞回来
也打印0
所以最终打印的就是0和0
为什么
因为我们现在写的值只存到了store buffer里面
并没有刷给我们的内存
它只是暂存在我的store buffer里面
所以对于多线程的程序
你要访问一个被共享的变量
你一定要提供某种叫锁的一种机制
所有的CPU一般都会提供锁的支持
比方说称之为barrier或者fence
就这个barrier指令
它会要求所有对内存的操作必须扩散到memory之后
才能执行其它对memory的操作
也就是说如果我在某一个A=1操作执行完之后
我加了barrier指令
把A=1刷到内存里面去才会执行下一个指令
这是CPU提供的一些机制
基于这种barrier的机制
很多语言就是会抽象出更高级的一些指令
比方说锁
或者说我们CPU也会提供类似像CAS指令
atomic原子的CAS比较和替换
或者原子自增原子自减
原子自增的这种指令通常都是用标准库提供的
所以我们在访问共享变量的时候
必须要使用某种锁机制
不管它是barrier或者是atomic CAS的指令
或者是更高级的像互斥锁的这种机制
那么这样的话访问的变量才是安全的
所以我们不要去假定它的执行顺序
因为发生了CPU指令重排或者是编译器指令重排
它的整个执行情况是完全不可预知的
所以我们不要做假(设)
我们不做假设
我们用最安全的机制来应对这种case
我们再看一下Go的内存模型
它又提了一些什么样的事情
为了说明读和写的必要条件
我们定义了叫先行发生
也就Go提到的Happen-Before
如果有个事件e1发生在e2前
我们也可以说e2发生在e1后
如果e1不发生在e2前
也不发生在e2后
我们就说e2和e2是并发的
就像我们看到Thread1
和Thread2里面的A=1 B=1
还有print(A)和print(B)
这几个代码不是完全无法确定到底谁先谁后的
我们把这种情况叫什么
叫做并发发生
那么如果你是一个单一的goroutine
一个goroutine里面的顺序
就是你代码写的顺序没有任何问题
但是请注意代码的顺序是
保证你单个goroutine里面的语义是正确的情况下
有可能指定还是会重排
你看到的顺序未必是你真正看到代码执行的顺序
对于执行的过程可能会做一些调整
但是对你的语义是没有破坏的
对单个goroutine来说是这样的
当满足下面的条件的时候
对变量v的读操作r是被允许看到对v的写操作w
要满足什么条件
我的一个读操作
我的一个读操作能看到前面的一个写操作
要满足什么条件
第一r不先行发生于w
也就是说先要执行w再执行r
我就能看到你刚刚对变量v的一个写操作
第二在w之后在r之前没有对v的其他的写操作
写之后
我w写之后和我读之前
没有任何对v的其他的一些写操作
那么我就能保证我看到的值是你刚刚写的值
那么为了保证对变量v的读操作r
能看到对v的写作w
要确保w是r允许看到的唯一的一个写操作
那么要满足条件
我们还要满足两个更苛刻的条件
然后看一下
w先行发生于r
因为我们刚刚上面那个条件
对于单个goroutine来说是没什么问题的
我们看看第二个
第二个条件就是
首先第一个
w先行发生于r
可能前面其实是一样的
第二个其他对共享变量v的写操作
要么在w前
要么在w后
这个条件比前面的条件更严格
它是一个共享的变量
需要没有其他写操作与w或者r并发发生
它是个共享变量
没有任何并发发生都不行的
就不应该有任何并发发生的
要么在它之前
要么在它之后
所以这个是叫Happen-Before
那么在单个goroutine里面
实际上是没有所谓的并发的
上面两个条件基本上是一个意思
就是读操作r看到最近一次写操作的w写入的v的值
但是多个goroutine访问共享变量的时候
必须要建立一个同步事件
来确保先行发生的条件
来保证读操作能看到需要的写操作
简单来说就是第一
你要通过一种互斥锁或者是atomic的这种机制
来保证先行发生的这个条件
Memory Model里面还提了两个点
第一就是
我们的所谓的对一个变量的零值的初始化的时候
它其实本质上也是等同于写操作
对一个变量初始化
你声明一个变量
因为你声明一个变量它不是要赋予一个零值
这个就等同于写操作
第二个我要重点去说的
对于大于single machine word的变量
什么叫大于single machine word
我们知道以前有x86-64对吧
我们有64位
64bit的CPU架构
一个指针大小一个变量它是多少字节
它是8个字节
理论上来讲对于64bit的CPU
一个8个字节的一个赋值它是原子的
它是一个机器字或者叫机器码
你去对它一个操作的时候
它应该是原子的
举个例子
我有一个变量v
它是一个int的一个指针
那么你把它指向地址a或者指向地址b赋值动作它应该是原子的
原子的指的是
它不会存在说有一个byte数组它是50个字节
那么你在赋值的时候你要拷一个50字节进去
它可能是一波波的拷进去
那么有可能它处于一个中间的状态
就是拷了一半没另外一半没拷
所以说单一machine word的赋值应该是原子的
但是你超过了一个machin word的大小的话
赋值的顺序就不确定了
这段你能用它干嘛呢
我们稍微懂一些所谓的machine word的时候
很多同学会写一些为了去锁写代码
怎么写呢
举个例子
我们使用Go的map
我们知道map在runtime里面
它其实就是一个hmap
它内部是一个hmap的结构体
然后我们是有一个是有个指针指向它的
我们通常会用所谓的COW
COW就是Copy-on-write
新申请个map
把老的map的值拷到新的map里面去
然后再用之前变量指向新的内存地址
我们刚刚讲了一个指针的赋值
它是原子的
它满足一个single machine word
但是这个动作虽然是原子的
但是从某种意义上来说
它未必能满足可见性的要求
为什么
因为它存在store buffer
什么意思
两个goroutine
一个goroutine指向的地址是v1
对于goroutine1来说
它看到map指向的地址是v1
这个时候如果有另外一个goroutine
做了一个赋值把它改成了v2
那么因为store buffer的存在
它可能那个人一直读到它是v1
所以说它虽然是没有副作用的
但是它不满足可见性
他其实也不满足Happen-Before
Happen-Before是要满足赋值原子
且你能看到别人刚才对变量的赋值的一个写操作
那么对于这种情况下是看不到的
因为有store buffer的存在
我们要原子赋值
我们用的时候还是要慎重一点
因为map它虽然是一个指针
但是假设哪天它突然内部runtime改了
改了里面的内部实现
你不知道
它可能变成一个类似像slice的结构
slice我们知道
它拿到的是个slice head结构
它是会超过一个single machine word的
那么这个时候它的赋值是不原子的
可能就会出现意外的bug
像interface
interface也是不满足一个single machine word的
那么对interface的赋值
大家以为还是原子的
实际上有可能拷了一半的数据就错了
如果你没有用一种锁机制去保障interface的赋值时候
你可能会出一些意外的bug
有可能程序会崩溃
那么我这里提到几个很关键的东西
一个是COW
其实COW这种机制
虽然我PPT里面没有列出来
COW叫Copy-on-write
叫写时拷贝
那么对于我们Linux进程的fork
像Redis开一个BGSAVE
都是利用这套机制
开个BGSAVE
另外一个新的进程它可能能共享老的进程的一些地址空间
然后对里面的数据进行访问然后刷盘
那么Copy-on-write就是说
如果这个时候刷盘相当于拿到了一个副本
如果有新的东西写进来
他就是把被变化的部分写时拷贝
就是把它拷出去
这样的话新老两个副本同时共存
但是它不是完全拷一份
它只是说有变化的部分会把它产生一个拷贝
我们刚才讲的
如果你有两个map
你想完成无锁的一个替换
新申请一个map
然后把老的map访问一遍拷进去
然后把这个变量指向它就搞定了
虽然它是原子
但是它不满足可见性
我们最好的办法是什么
用atomic
atomic value
你用load和store
用这种方式去存储这个就是变量或者指针的一个赋值
相对来说是安全的多
所以我见过很多以前老的代码
虽然知道它是个指针
满足原子性
去做这种替换是安全的
但是它不满足可见性
虽然没什么副作用但是我仍然不建议这样做
所以我们大概了解完之后
我们尽量不要觉得自己特别聪明
去绕开一些这种它特性
知道它满足原子
其实它不满足可见性
一定要注意不要自己太聪明
用锁是相对来说更安全的
不要觉得锁的它的性能就未必有可能差
我们可能有读写锁
我们甚至还有atomic value
可以做一个load的操作
load的操作它其实是原子的
它是用atomic来做的
可能效率还不错
尤其interface的这种
赋值是不安全的也不是原子的
包括slice
slice我们数组的赋值也是
大家可能觉得能够原子替换它
其实也不能做到原子替换的
一定要注意非常不安全
这一块的话还有我们如果要扩展去阅读关于CPU的
我们有没有想过
既然就是说有一个地方有一个变量改了
有一个buffer改了刷到内存
那内存如何通知其他CPU把这个值给过期掉
其他CPU核心也做了所谓的store buffer
或者是这种cache line做了buffer
你cache的老的值有没有办法可以把它清掉呢
所以要给一个关键字叫MESI
然后你去了解下CPU如何去通知其他的核心去把内存给刷掉的
我觉得这一块整个扩展的知识其实还是非常多
对如果大家觉得听完之后还有一些疑问的话
也可以留言或者整理一下
然后把这些问题再抛出来
因为这块其实是有些深度的偏计算机底层的一些原理的
这一块的知识我们就讲完了
那么下面的话我们会围绕Go的标准库的
sync包去介绍它的一些原理的实现
然后我们利用sync包能解决一些什么样的问题
甚至我会结合前面第一章讲的微服务
微服里面的BFF大家还记得吧
BFF我们知道它是做多个微服务数据组装的
那么多个微服数据组装
你自然而然就需要用到Go的goroutine
来并发去请求下游的服务然后做数据组装
那么既然你要并发去访问
既要控制好goroutine的生命周期
也要能够并行去执行
所以说这个其实就是sync的核心了
我们今天就先到这里