好我们接下来讲关于chan相关的一些内容
channel呢
我们刚才讲到
goroutine和channel是Go并发原语里面最关键的两个特性
那么关于channel的知识
我觉得整体来说用法还是比较简单的
就是它没有太多的复杂的一些语法糖的
一些东西语法定义上的一些东西
我们其实核心要搞清楚
关于channel它有一些什么样的最佳实践
或者说它有一些什么样的模式
能够让你去解决你的工作中的一些问题
我们前面介绍过一个例子
是关于使用channel能够解决什么
就是任务分发
任务分发是非常常见的一种模型
就是接收一批任务
然后分发给n个worker
每个goroutine能够接收接收这个channel
然后去做一些工作的并行
其实是非常简单的一种模式
我们现在看看关于channel还有一些什么需要注意的一些点
channel本质上来说它是一种类型安全的消息队列
也就是说多个goroutine可以并发去读写它
充当两个goroutine之间的一个通道
Rob Pike经常说communicating来解决share memory的一个问题
那么将通过它同步地进行任意资源的一个交换
那么channel控制goroutine交换的能力
从而创建了Go的同步机制
也是Go这个模式是让大家非常喜欢的一种写代码的方式
当创建的channel没有容量的时候
我们称之为无缓冲的一个管道
反过来如果使用有容量的一个创建的一个channel
我们称为缓冲的一个通道
要了解channel交互goroutine的一个同步行为是什么
我们就需要知道通道的类型和状态
根据我们使用的无缓冲通道
和是不是缓冲通道
场景会有所不同
然后分别讨论一下每个场景
那么unbuffered channels
我们代码make这样的
我们先看一下图我们简单说一下
就是假设这个是发送者
这个是接收者
12345表示它的一个流程
第一个人发送者他有一个消息要处理
他会把这个消息放到这个广告里面
但是这个时候它啥都干不了
必须第三步等接收者我们叫receiver
接收者拿到这个消息以后
这个时候我们看到这个人这个手才和channel彻底地解绑
然后他就可以做做别的事情
也就是说如果接触者没有ready
它是会阻塞的
所以也比较简单
无缓冲它没有任何容量
你必须等接收者过来
同时准备好你才能进行一个消息的传递
当goroutine试图将一个资源发送到一个无缓冲通道
并且没有任何一个goroutine在等待接收该资源的时候
该通道将锁住发送goroutine
并使其等待
这个人它是一直会等待的
当goroutine尝试从无缓冲通道接收
并且没有goroutine等待发送的时候
也就是说有个人来试图拉取消息
但是没有任何人发送资源
怎么样它也会怎么来(阻塞)
所以无缓冲信道的本质是保证同步
它一定是同步交换的
就必须两边同时ready
它的整个使用也非常简单
你看这个地方就make一个chan
开一个goroutine
一个goroutine是做发送
一个goroutine是做接收
然后我们不管谁先启动
就必须两个人全部ready
两个goroutine才会结束
因为一个发送接收
发送和接收是同时结束的
所以当第一个goroutine在发送消息foo之后
在这个地方发送foo之后它会被阻塞
因为还没有接收者准备好
也就是这个人假装还没起来
在go的官方文档里面对这种行为进行了很好的显示
必须如果你的size为0
你的容量为0
必须两边同时ready
如果你的channel是unbuffered
并且你的receiver
你的receiver还没有收到这个值
那么两个人都会什么
就是发送者会阻塞
你可以理解为Receive是先于Send发生
我们叫Happens Before
接收者是先于send的发生的
第二就是它的好处是什么
就是100%能够保证能收到
对发送者来说
它返回意味着接受者一定收到了
代价的话就是延迟
因为你不知道你的发送者到底是什么时候ready
这个是要注意的
我们再来看buffer channel
那么buffer channel也比较简单
它是具有容量的
我们先看这张图
还是这个人是发送者
它可以一直在发送消息
所以你看这个地方它是一直可以去往里面投递消息
然后这个人其实是从里面拿消息
其实就非常简单
当然如果你的整个buffer满了的话
这个发送者会什么 会堵住
它就会阻塞
那么当goroutine挺试图将资源发送到缓冲通道的时候
如果该通道满的时候
刚刚说了它会锁住goroutine并使其等待缓冲区可用
也就是说如果有人收走一条消息
发送就不会阻塞
它就会解锁然后它会继续发送
那么如果通道中有空间的话
发送者可以立即执行
它不会pending住
就是说它不会parking
那么当goroutine试图从缓冲队列接收数据的时候
如果里面有数据肯定是可以持续进行的
如果没有数据也是会被等待的
所以它的锁行为也比较好解释
其实就这么简单
也没有太多的东西
使用是非常简单的
但是我们可以做一个benchmark
就是用不同的buffer size
我们在chan创建过程中定义的缓冲区大小可能会极大地影响性能
注意缓冲区这个size的大小
chan的size越大可能对性能提升越大
其实不是的
反而是因为越小它会导致goroutine的这种阻塞和唤醒的频次变高
导致的吞吐下降
并不是说你的buffer越大意味着吞吐越好
而是buffer越大它最终一定会达到某一个水平之后就没有任何区别
你的内存空间换时间它只能换到一定程度
它不会说你无限增大它就无限快
其实并不是这样
当然了如果你特别小的的确确是会对性能产生一些影响
我们做了一个基准测试
一个producer在这个通道里面注入百万个数组元素
而5个walker将读取
并将它们追加到一个名为total的结果变量中
对我们做了一个benchmark
就是这个地方是WithBuffer
这个0 1 5 25不同的一个size
它的性能其实是有些差异的
你像我们这个地方如果是没有buffer的一个channel
它的整个某一个operation的耗时其实是非常高
因为我刚刚说的它有各种的parking unparking
就导致goroutine会不断的阻塞和被唤醒
它是有些成本的所以它花的时间也会比较多
然后这个是等于1的
你看明显会有一些提升
这个等于worker的数量
它的吞吐量明显就好很多
然后当我们的worker是5
我们buffer的数量明显大于worker的数量等于25的时候
它吞吐就更好一些 当然不是
说无限增大以后它还是会这样
然后呢 在buffer channel的场景里面
send要先于receive发生
就是先send才能receive
好处是延迟是更小的
代价是什么
它并不能保证数据到达
越大的buffer就越小的保障
因为你要投递一个数据
你要知道对方是否接收到
其实是不知道的
取决于你的buffer的长度大小
如果你的buffer=1
那么只给你延迟一个消息就能保证一定送达
那么什么情况下用
其实取决于如果你需要同步通讯
就用unbuffered channel size
如果你需要异步通信
那就用buffer channel size
我开源的一个叫goim的一个项目
有点像一个TCP的一个server
像我们的直播弹幕里面的这种房间或者是那种叫什么
聊天群聊这种
我当时设计的时候就是用了一个buffer size
为什么
因为我希望能够让channel里面很多人会往这个房间发消息
我希望channel有一定的空间能够buffer更多的一些消息im的内容
我会尝试一直去读
读一个批次然后做一个消息聚合再发送
那么对下游的话这样的话可以减少一些系统调用
对如果你是等于1的话
它就频繁的唤醒
这个其实不是特别好
完全要取决于你的使用场景
那channel其实本质上它没有太多的内容可讲
但是它有一些pattern
我这边列了一些这种叫Cuoncurrency Patterns都是基于channel来做的
我建议大家还是要认真去把这一块几个玩法去认真阅读一下
挑其中的几个点讲
什么叫Time out
我们最终可以利用什么
利用channel来做一个超时的控制
对吧
其实我们知道够利用context也是可以做一个超时控制的
context本质上它Done的那个方法也是用channel来做
级联取消和级联超时的
其实思路是一致的
然后像Pipeline管道Fan-out Fan-in还有取消
对吧
我们经常有make一个 Closed或者叫Done的一个channel
用来给它把channel可能关闭掉
来唤醒需要表示这个方法需要被结束了
其实取消我们也用得非常多
这个地方如果是取消的这种场景里面
一定要注意我们close一个channel
一定要先于与receive发生
也就是说我们closed的时候
一定没有任何人再写下去(close) 否则会panic
所有的close你可以理解为它是一种特殊的写操作
所以它必须是没有任何写操作的时候才能进行close
那么receiver这时候就会收到一个是否ok的一个信号
表示channel要关闭了
好我们先看一下Time out的一个模型
我们看到Time out
实际上我们看一下
假设你是在异步做一些逻辑
可能在等一个消息回来
但是你就不知道它什么时候结束
这个时候你其实只要等Time out一个消息过来就好了
Time out可以用什么
可以用time.After
你像这个case里面是make的一个channel去做的
当然这个地方也可以用什么time.After
就这样会简单一点
什么叫Moving on的一个pattern
Moving on指的是
我们有没有遇到过
假设你的数据库集群
你的从库有三四个从库
你这个时候哪一个最快用谁的结果
假设我并行发了4个请求出去
谁最快返回我就用谁的
结果我们这个时候
我们可以想象一下
假设我这里有好多个连接
那么这一些连接我可以并行开n个goroutine
然后分别去做一个查询
那么查询它的查询结果可以发送到channel
channel我们注意这个地方就是一个unbuffered channel
它是个同步调用
它不是一个buffer
它是一个同步的
为什么要这样做
我们为什么这个地方还加了一个default
因为假设你发了三个连接
三个人都反回了结果
一定我只关注一个
那么你可以用default把它们退出
因为表示这个channel里面已经有内容了
这个地方因为它是开goroutine出去的
所以只要这个地方返回
它receive是先于send发生
所以这个地方返回
那么其他的人就会走default
忽略掉那些结果所以它叫Moving on
所以这个是非常经典的一个模型
我不会每一个pattern全部介绍
因为老实讲整个用法还是非常简单的
所以这几种都是非常常见的模型
大家可以自己去搜一下它具体是怎么玩的
我们最终介绍它的一些原则
我觉得首先第一个就是
你要搞清楚什么情况下用unbuffered channel
那么如果unbuffered channel这个goroutine
这个发送者的goroutine
它产生了一个blocking
产生这种情况的时候
你应该怎么去处理
或者说怎么解决
是否是符合你预期的
因为它是个同步调用
那么你如果讲究的是吞吐
你需要能够buffer一些message缓存一些消息
那么你就要搞清楚
如果满了会怎么样
如果没满你可以继续处理
那么满了你应该会怎么样
你是丢弃还是等待
我们用default去丢弃
我们可以用channel发布进去我可以等待它
buffer的大小不代表它是表示性能的提升
到一定程度那个buffer size它其实就已经达到能够性能非常好了
不是说越大就越快
这个其实是要注意的
它只是为了减少唤醒的成本
这个其实是要提到的
所以chan呢
我自己工作中在写业务代码里面
直接用chan
我想想 其实非常少的为什么呢
因为我们写业务代码都是顺序的流式处理
但是我反而用errgroup相对比较多一点
因为我用errgroup开几个goroutine并行去做一些事情
相对比较多
我们什么情况下用chan比较多
我在实际工作里面最多的向任务分发
比方说建一个channel
然后有几个worker去消费 这是一种
还有我们自己内部做日志库的时候
因为你写日志库要刷盘
要刷盘 对吧
我们希望刷盘的话能够把它
把那些发送的日志消息发到一个管道里面管道里面
把消息读取出来
把消息打成一个大包然后去刷盘
这样的话I/O吞吐会好得多
这种情况下我们日志库实现的时候底层也是会用channel来做
那种bytes buffer的一个发送
一个bytes buffer的channel可以去接受很多bytes buffer
然后做一个buffer的组装
这个我们也用的比较多
或者说它就是个纯bytes的一个channel
这也这种是非常多的
我如果产生channel满了
日志可能会考虑丢弃
因为如果阻塞的话我不知道会堵多久
假设是I/O打爆了
你的channel的消费速度一定是跟不上生产速度
所以说这个时候只能丢弃消息
但是你要看你的场景里面日志能否丢弃
让业务继续运行还是说日志重要
这个完全要看你的业务工作里面到底哪个是更重要一些的
channel的整个用法也是非常简单的
你一定要注意就是先搞定close channel的时候
没有任何人在发送了你才能够安全的close
所以我(有时候的确)很难做到的时候
我会考虑发送一个nil
它是个特定的一个占位符
发送了nil而当我收到nil的时候退出
就是我能知道一定不会发生nil发到channel里面
比方说我是一个channel
然后一个结构体的指针
我知道一定不会发nil的情况下
我可以用这种占位符的方式
用nil做一个占位符表示个退出信号
但本质上这种做法不是非常好
因为你不知道还有其他的发送者在发送
说白了就是你有可能会没消费完
所以不是特别好
channel的内容我们就讲到这个地方
我们接下来会继续讲
我们就讲context
因为context其实实际上是整个并发章节里面最重要的
因为1.4以后引入了context就解决了我们的Go的最典型的两个问题
一个就是超时
一个就是取消
我们来看一下context
它是有一些什么样的最佳实践
那么context有一点像ThreadLocal
就线程级别的一个共享变量
我比较喜欢叫请求级别的上下文
你像我们发一个HTTP请求
一个HTTP请求进来
我会创建一个上下文
里面会挂载一些东西
因为你一个HTTP请求进来
你要调很多函数
我最终可以通过root的一个地方可以把它统一取消掉
所以这个就是要请求级别的一个上下文
它可以做取消
它可以带一些东西
因为每一个传统请求都会在自己的gorountine里面处理
你的请求处理有可能还会启动其他额外的一些goroutine来访问
比方说你会开一个errgroup
并行去访问下游的一些东西
这个时候你可能会带上context
比方数据库 rpc
你可能都会开一个goroutine去处理
那么如何把它们全部统一的能够托管起来
并且统一的处理取消或者处理超时
这是context要起很重要的一个作用了
还有说
这一些处理请求
注意它是某一个请求的这些goroutine
因为你不管你请求的goroutine它后面扇出了多少个goroutine
就这一个请求的goroutine
它可能会开个goroutine处理数据库
开个goroutine处理rpc
不管它是上述的多少
都把它归因到这一次请求
所以它们实际上是有一些所谓的继承关系的
我可能需要从这一些个goroutine之间带一些信息
那么这些信息不方便通过函数传参来做到
不方便通过闭包的方式来传递
那么比方像身份信息
那么超时的一些时间
你不可能
因为你假设你这个方法是完全是做一个a业务的
它不需要
如果你每一个都要带一个time out参数去控制的话
肯定是很详细就不太好用
它其实有点像我们HTTP请求
你的主体是body
body里面有很多你的业务的上下文
但是你可能有一些head
head里面带一些IP
你IP不是你的主宰的业务的一个实体
你的IP信息只是一个旁路的信息
你希望挂载到上下文里面
有可能不用的
举个例子你a调b调c
你的a肯定是拿到了用户信息
你假设你的b不需要用但是你c需要用
但是你这个时候就强行传递给了b
b其实又不需要用
只是说b调c的时候要用做成一个显示的参数去传递
有点奇怪
其实是不太好的
因为有可能你不知道任何一个goroutine到底哪里需要用哪里不需要用
所以有时候把它挂到一个类似像HTTP head里面
或者挂到go的context里面
我觉得都是比较爽的一个点
我自己最喜欢还是它的超时处理和取消
那么当一个请求被需要取消或者超时的时候
所有处理该请求的goroutine
刚刚说了它们会归因到一个请求
它可以全部的快速退出
因为对做一个高可用的服务里面
只要管控住了goroutine的生命周期
你的耗时能管控住
你就不容易进程被oom打爆
因为你的goroutine一堆积
有一些野生的goroutine或者说有一些goroutine生命周期你无法管控
它一旦堆积得越来越多
会产生什么情况
内存不够你的服务就可能会crash掉会挂掉
只有管控住goroutine的生命周期
意味着你的服务才能够稳定
那么要管控goroutine生命周期
再出现一些下游子系统出现故障的时候耗时变高的时候
只有快速的让它们失败
你才能保证自己不会挂掉
所以feel fast
快速退出是很常见的一个处理可用性的技巧
那么feel fast的核心是什么呢
就是它有超时处理
超时处理要跟请求全部关联上
那么就一定要用context
刚刚说成1.4
其实是1.7引入了context包
应该它是先从Go的子包后来慢慢就变成一个它的主包
引入到标准库里面
那么使得最终跨API边界的这些goroutine能够享受到请求级别的元数据
取消信号和超时也叫截止日期
也叫超时
很容易传递给处理该请求的所有涉及的goroutine
显示地传递
显示传递是什么呢
我们看一下
你每一个函数第一个参数都是context
叫显示传递
所以也有人诟病吐槽
就是说你像其他语言类似这种ThreadLocal Storage
这都是都是一种隐含的
不是放到那个函数签名里面的
但是对于Go来说目前是要首参数放这个context
然后它是个显示地传递
它会污染你所有的方法都要带上这个
但是这个也是被人诟病的地方
但是对我来说我目前已经能接受了
因为标准库的做法是
如果你不知道要不要用context
通常来说标准库会提供不带context的方法签名和带context的方法签名有两种
我觉得虽然不是很优雅
但是能解决一部分问题
至于Go2
Go2.0它未来会长什么样我还不知道
我还不知道我
看到社区有很多讨论
我自己是比较喜欢显示传递的
我也习惯了每一个函数都带上第一个参数带上context
当然我知道有很多人会吐槽
我们看一下它有几个方法
Deadline
Deadline表示的是当下Context还剩多少超时时间
它什么时候会过期
Done表示的是返回一个只读的channel
这个channel我们实际上结合goroutine和channe使用
用来做我们叫root的节点或者parent的节点取消
那么这个Done也会收到信号
就可以级联取消
或者超时
最终它是用做取消用的
还可以收到一个Err
如果Err被关闭了
context已经被取消了
那么它就会返回一个error Canceled
如果它是超时的可能会返回一个error Timeout
那么我就知道context上下文是已经报错了
那么Value表示的是我可以把它挂在一些什么元数据
我可以挂在一些请求的上下文信息进去
如何将context集成到API里面的
实际上也非常简单
你如果想要将context集成到API时候
请记住非常重要的一个点
它的作用就是请求级别的
例如单个数据库查询
存在是有意义的对吧
但原数据库对象存在则没有意义
这能明白吧
就是说你是针对这一次数据库的查询context是有意义的
但是你context跟DB对象没有任何关系
它只对请求有效
那么怎么挂到API里面
刚刚前面讲了第一个参数
The first parameter of a function call
对吧
就是第一个参数
首参数传递肯定context对象
我们参考比如说像net包
net包的Dialer.DialContext
它的DialContext可能是我看签名第一个就是context
然后带上network网络和网络地址
那么这时候你可以通过context来做超时和取消的一个处理
还有说它是一个optional的config
它是挂载到请求结构体的可选的参数
比方说像net/http在第一个Request对象它是一个可选的context
net/http的Request对象
它是有个叫WithContext的方法
WithContext以后它会返回一个新的Request的对象
Request对象内部实际上就包含了一个context的上下文
两种姿势一种是传参
一种是挂载到结构体里面
我们通常第一种用的比较多
第二种在基础库的设计里面
或者说你要兼容老的会用第二种方式比较多一点
因为你要区分Request对象要带不带上下文
你肯定是通过挂载一个context
把它挂进去
那goroutine呢
因为你要及时到API
我们看一下
这是个用户发的HTTP请求
然后它调用了一个A方法
A方法还会调B方法
但是A方法可能还开了一个goroutine调到一个G方法
G方法又开了一个goroutine
它会同步调H
那么这个H其实上是做了个rpc
那么这个时候可能还会内部开了一个handle的一个grpc一个goroutine
它会去调F
我们看到一个请求实际上是有可能由好多个goroutine组成
并且会call很多的方法
每个方法里面可能会启动goroutine可能不启动
所以你要把这一坨goroutine关联到某一个上下文
这个context就非常有用了
取消传递数据都非常有用
我们先看第一个
我们标题叫什么
Don't store Contexts inside a struct type
我们尽量的不要在在结构体类型里面去放一个context
为什么
它本质上是跟请求上下文有关的
注意它是跟请求有关的
你放到一个结构体里面是不是怪怪的
就像我们放到DB对象里面
它跟DB对象没有直接关系
它必须产生一个请求的时候才会用到它
所以为什么net/http的Request对象可以用呢
因为它针对请求设计的一个Request的对象
它是针对请求的一个结构体
那么这种场景下是可以用的
但是我们尽量还是通过传参的方式
比方说DoSomething
我们这个地方什么DoDomething
传参的第二个点是什么呢
Incoming request to a server should create a Context
那么如果有一个传入到某一个server的请求
我们一般在接收请求的时候都会新建一个context
那么这个就会用context.Background
或者context.TODO
我们通常都是用Background
使用context一个很好的心智模型
在你的程序中流动
贯穿你的所有代码
你的请求的
第一收到这个请求以后
比方你使用gin或者是用我们kratos框架
我们在设计库的时候
一旦请求被接受
就会创建一个Background的Context
然后我的middleware我的其他http.Handler都会使用到这个Context
它贯穿了你的所有代码
这通常意味着不希望存储的结构体里面
而是通过一个函数传递到另外一个函数
并根据需要进行扩展
理想情况下是每个请求都会新建一个Context对象
并且在请求结束的时候
可能Context就等它GC掉不需要使用
不存储上下文的一个例外是什么呢
我们一般channel发送的时候
在管道传递的时候有可能会挂载一个context
因为有些场景里面你可能是异步地发送一个任务到一个地方去
那么异步任务的这个worker可能会对这个context感兴趣
要判断是否被取消之类的
或者挂载了什么元信息
这种消息传递的方式可能会使用
但我们尽量还是要少用
我们简单就讲到这里
好
我们接下来讲一下它的原理
它的原理也非常简单
比方说我们第一个context.WithValue
我要带一些元数据进去
那么context.WithValue内部它实现是基于一个叫valueCtx的结构体
定义一个结构体
我们看到非常简单就定一个key value
那么是一个interface类型
然后注意这个地方包含了一个可能是interface
这个是我画了一个图
我们一个goroutine假设它context.WithValue带了带了个key
它不是会返回一个新的context
一个红色的
这个时候我们因为WithValue是创建的
那么我Context.Value查这个key的时候怎么办
因为我们知道每次WithValue都会新建一个context
假设我WithValue就有三次就有三个Context
123
我这个时候一个goroutine要去查一个值的时候
它会先去找你当前的Context
它怎么搞
它是先看当前节点的key是否相等
如果相等就直接返回
如果不相等它会递归的拿到我们另外一个context
我们valueCtx肯定是包含了一个大写的Content
所以你可以通过c.Context访问到它
那么它会递归的去往上找
直到root的Context
root Context一般是Background
就是context.Background或者TODO
那么它会返回nil
如果从这样就找都找不到它会返回nil
你大家有没有想过为什么WithValue
它每次挂载一个值
它都要去创建一个新对象
然后用链表的方式去关联起来的
因为context是goroutine safe的
意味着任何一个goroutine都可以去并发的读它
那么并发地读它意味着你不能去篡改里面的
我们可能很容易想到是用一个map
有个map存key value
然后加个互斥锁
但是这个其实不太好
因为它会并发的去访问它
我再举个例子
大家可能就更清楚了
如果你的root的Context
突然在下面的传递的时候
你异步开了goroutine
它塞了一个值进去
那么你后面又能读到它
是不是产生了一个污染的行为
如果你用map的话
它没有层级结构
那么用链的方式
你只能找上游继承下来的Context
你看我们这个代码
你只能往上找上有继承下来的
那么它有个层级关系
也就是说假设是abc
a只能找a和Background
那么b呢
b能找b自己的以及a的以及Background的
c能找c自己的再等于往上找找b的找a的再找Background的
所以它产生了一个隔离
所以这是为什么每次WithValue创造一个新的Context
它是避免使用map这种方式来共享
而是它是隔离的
但是它的性能会差一些
因为它是链表一层层找的
所以WithValue不适合大量地去调用
你要挂50个对象进去
你掉50次位置那就哭了
一般我们也很容易想到怎么办
是不是搞一个map
搞一个map
WithValue的时候放个map进去
里面放50个或者你一个结构体
这样会好一些
所以我们知道你可以通过WithValue
在一个方法里面允许带一些上下文信息进去
并且它是可以被goroutine并发访问的
然后会被很多个goroutine同时来使用
那么这里的数据挂载的数据更多的
同样的也是面向请求级别的元数据
什么叫请求级别的元数据
比方说我们讲微服务的时候
是不是讲过一个多租户
多租户染色的一个场景
我们是不是讲过你可以为每一个API定一个重要性
那么你传递重要性之后
我的下游的链路中
我就知道请求调这个方法是来自哪个API
它是否是重要的
如果是重要的话
可能能降级或者不能降级
可以用你这些信息
它跟你的业务实际上没有太多关系
它是个控制策略
挂在元数据里面是非常合适的
或者是链路追踪
我们知道要做分布式链路追踪
trace信息也是可以挂载进去的
甚至我们的IP这些也是可以挂载进去的
但是我觉得不应该作为函数的可选参数来使用
就是Context里面的WithValue见过有人挂一个sql.Tx
就是一个事务对象
传递到data层使用
因为元数据相对函数参数来说
它更加隐含
另外它是面向请求的
sql.Tx事务这个对象它不是个面向请求的
它是一次数据库的操作
所以参数更加显示Context更加隐式
并且你一定要注意它是否是面向请求级别的
才决定能不能放
到底能不能放
就是想它是不是面向请求级别的
我们再来看一下同一个Context对象
可以传递给不同的goroutine使用
那么上下文对于goroutine同时使用是安全的
也就是说你放进去的值它一定是不能被篡改的
因为table它不可能被篡改
因为被篡改味着有人同时读写它就会产生data race
但是我的的确确会出现一些case
出现一些case会要去修改里面的值
往下游再传递
怎么办
我们之前还记得Copy-On-Write
我新建一个对象然后用deep copy拷给它
然后我把里面的值改一下
我再挂一个新的值是不是就搞定了
大家明白我意思吧
就是你的老的只是读出来
搞一个新对象
deep copy搞进去
把新的对象改一改
重新挂载生成一个新的context往下传递
这样是没有污染的这样才是干净的
Context value它只是用来挂载信息的
它不是用来做控制流的
这个也是要注意的
也是很多人用value里面的东西来做控制流
做一些业务流程的控制 不好
它只能做一些旁路的信息挂载
还有一个场景是什么
就是刚刚说的我们要挂50个字段的场景
我们目前在我们公司内部我们参考的grpc的做法
grpc有个metadata的一个lib
我们参考它的做法
怎么做呢
我们先看一下
非常有意思
首先我们是root的context
它是context.Background
我调一次context.WithValue
那么我这个时候可以挂
我们参考的grpc的metadata
我们定义的一个md对象
metadata的缩写是md
这个md实际上就是个map[string]string
其实你就可以挂好多个字段进去了
只是说它都是字符串的
但是map你读出来
注意仍然不能去改里面的东西
因为它可能会被其他goroutine并发访问
所以如果你要对map做一个修改操作
怎么办
还是一样的
deep copy一份
把它塞一个新的key value
然后再重新挂载
这也是非常安全的
举个例子我们这个里面WithValue生成一个ctx1
如果有一些人在modify这个map
有些人在改这个map
但是这个时候有一些goroutine
因为你可能Context传到goroutine1和goroutine2
有些人试图在读map
是不是就产生data race
就会挂掉
所以说同样的
这个map一定是用Copy-On-Write的思路去把它的数据替换
并且用WithValue就生成一个新的Context
再往下游传递
这样才是安全的
我们记住这句话
Replace a context using WithCancel. WithDeadline. WithTimeout. or WithValue.
你要替换一个Context的时候
一定不要去改里面的value的值
一定是用这4种方法
WithCancel WithDeadline WithTimeout WithValue
去生成一个新的Context再往下游传递
你就不会用错
一定注意这一点
那么Copy-On-Write的思路
我们这个地方还是演示一下
我们看看它为什么不会出这个错
刚刚我们这个地方是有人改它
所以报错了
那么实际上你去用Copy-On-Write的时候
你这个goroutine假设你要搞一个新的
搞一个新的对象
怎么做
先copy老的(map)
在这个地方make一个新的map
Copy的意思就是把它读一遍把它拷进去
然后你再把新的k2新的值搞进去
然后再调一个WithContext
这个时候再调WitheContext它就生成一个什么
生成ctx2
那么我这个时候再去FromContext取出来的时候
用value取出来的时候
它实际上读的对象是这个对象
我们假设这个对象叫v2
这个对象叫v1
这个是老的
这个是新的
那么这个地方这个goroutine读的是不是v2的数据
那么这个goroutine读的是谁的
因为它读的是老的Context
所以它读的是v1的数据
这个时候虽然是并行地并发地去访问
但是并不会产生data race
同时也不会污染ctx1和ctx2的数据
只在自己的作用空间范围内可见
简单一句话叫
The chain of a function calls between them must prppagate the Context.
必须是传递Context
然后构建一个新的
除了value
我们还要介绍就是Context的取消的一个行为
我们看一下这段话
当一个context被取消的时候
所有派生自context的对象都会被取消
我们把特性叫级联的取消
级联取消就是
这个goroutine因为一些原因主动调到cancel方法
因为它可能是WithCancel
我们知道WitheCancel会返回一个新的context和一个cancel方法
你可以自己手动调(cancel方法)
那也可能是因为WithTimeout
产生了一个新的context
因为它超时了
它自动地帮你调了cancel
不管什么原因总之它被取消了
那么由于这个goroutine取消以后
它内部实现其实对于context实现来说
它内部其实就是一个Done的channel
就是make的一个unbuffered的channel
然后把它close掉了
它close掉以后
你派生自它的
它都会及联的收到
因为Done的channel
只会返回同一个channel
那么大家都会返回这个channel
channel一旦被close掉
所有在等待Done方法的这些goroutine都会收到close的信号
它都会退出
可以理解为就是WithCancel这个context
这个context是parent context
然后WithCancel之后它会生成一个新的
那么在内部它会进行一个传播关系链的关联
Done会返回一个channel
当我们取消某个parent的时候
实际上它会递归层层cancel掉
就是child context的Done channel
我刚刚说错了
刚刚说的是大家会指向一个Done channel
实际上是每一个人context有自己的Done channel
只是说你的root取消的时候
它会一层层递归
层层递归让child的context每一层都取消掉自己的
那么最终大家都可以安安全全退出
这个其实是非常爽的
这边其实是一个非常简单的demo
我们看一下
大家很常见的就是用context.Done拿到这个对象
只要它这个地方返回意味着就不会泄露goroutine
你不知道它什么时候退出
它就不容易被泄露
这个是非常巧妙的一个做法
还有一个就是
我们刚刚说的是Cancel
Timeout其实没有太大的区别
你可以理解为WithTimeout产生的一个context
它相当于内部挂了一个timer
挂了一个定时器
那么到时的时候它times up到时了
它就会唤醒这个context
然后一样的把它级联地层层地让大家都退出
达到的效果有点像什么
有点像time.After
这个地方timeout的一秒钟
我也可以定义一个更短的
这个地方是一毫秒
一毫秒超时
然后我这个地方用WithDeadline
用WithDeadline的context加上一个超时时间一毫秒
产生一个新的context和cancel
注意这个cancel一定要取消
一定要调defer把它cancel
避免泄露
这个地方一定要调这个方法
那么这个context我看
这个是一秒钟
这个是一毫秒
所以你最终会收到一个叫什么error
这个ctx.Err就是告诉你这个context是被取消的还是被超时的
你的所有派生的goroutine都能被托管起来
可以级联取消
所以非常的方便
那么context使用我这个地方只讲了它的一小部分作用
那么在我讲微服务的可用性的时候
我会把展开来讲
利用超时传递和级联取消
可以做到跨服务级别的级联取消
我们也叫超时传递
它最终其实都是结合的context来做的
context我们总结下来几个点
注意WithValue实际上每次都会新生成一个context
因为新生成context往下游的其他goroutine传递
产生一个隔离空间
隔离空间它是个链表
找值的时候是链表
那么WithCancel和WithDeadline它们没有太大区别
就是一个带定时器一个是手动取消
那么它都是级联可以取消掉的
那么利用这个特性
如果你context用好了
那么Go的服务你可以做到非常的稳定
注意这句话
你context用好
你的go的服务的稳定性你就成功了一大半
因为服务稳定性最重要的是什么呢
就是feel fast
只要你的消费速度能够赶得上你的生产速度
你的goroutine的生产速度
你的进程永远吞吐都是够的
就不会产生堆积
不堆积就不容易oom
就不会整个程序炸掉
所以非常关键context
context在我们可用性的时候我会单独讲
就是超时传递的使用
context本身挂载值还有一个小技巧
刚才也提了挂载值的时候我们可以挂载一个map
但是这个map也是个只读的
这个方法大家可以参考一下
grpc的metadata的使用
你可以把它抠出来作为你自己的业务库来使用
我觉得也是非常方便的
我们总结一下
它有一些context的使用技巧
我这边对着念一下
大家看一下它有一些什么样的非常重要的一些知识
第一个
Incoming requests to a server should create a Context.
如果是针对一个服务的每一个接收的请求
都应该在接收请求后立马创建一个context
那么通常它的context就是
它的root context都是Background
我们这个情况下就不要用TODO了
TODO你不知道填什么时候用context.TODO
第二个Outgoing calls to servers should accept a Context.
你调别人
你也要接受一个context 为什么
因为你调别人意味着上游对你是有生命周期管理的一个义务的
你去调rpc
那么上游
一定是由上游方法来调你
那么你最好要接收一个context参数
所以无论你是自己撸了一个rpc框架
还是你使用grpc
grpc肯定是要传的context参数的
你如果是自己撸的rpc框架
也最好是要接收contaxt的参数
你在实现自己rpc框架的时候一定要注意
要实现context Done的方法
来做级联的取消
第三个Do not store Contexts inside a struct type;
不要用struct type来存储context
可能因为它是个上下文请求级别关联的
http.Request是个特例
它本身就是个请求
当然instead. pass a Context explicitly to each function that needs it.
就是你要显示地最需要它的函数去传递它
这个动作其实也就间接污染了所有方法
这个没办法
第4个就是说我们一系列的调用链
其实最终都是可以被关联起来
可以被级联取消和唤醒的
另外就是说我们如果要对context要进行替换
要替换context
无论你是要挂载新的值
注意里面的WithValue带的值你不能篡改
你只能用WithValue重新改一个新的副本去挂载
或者是你要设置新的超时
挂在一个新的WithCancel
都应该用这4个方法
再下个点是什么
当一个context被取消的时候
所有继承自context的对象都会被取消
叫级联取消
还有下一个就是说
The same Context
这个是什么
就是指的是context是可以被多个goroutine可以并发去使用的
并且它是安全的
所以你要保障你传递的value也是安全的
因为WithValue里面的value一定也是能够被并发访问到
所以要保证它的goroutine safe
我们通常不要传一个nil的context
其实我们调方法也是一样的
随便调别人的方法
尽量不要去传一个nil
人家指定的是个指针
也不应该平常的去调的时候传个nil
所以你这个时候应该传一个TODO
不知道这个时候用什么的时候就用TODO
然后还有就是context value通常是用于请求上下文级别的一些数据
它是通过这样的一个方式透明传递到各个goroutine
而不是作为一个
一个函数的可选参数来传递的
比方说sql.Tx
事务对象或者一个日志库什么的
这种都不应该传递
还有说All blocking/long operations should be cancelable
就是所有长(调用)会阻塞的
长调用都应该能够被取消
因为管控好goroutine的生命周期
goroutine的生命周期
就管好了这个服务的生命线
还有context value不应该用来做你的程序的控制流
就是你的业务有逻辑
应该是用来做它
就是来挂载一些信息的
挂载一些元数据的
最终你要用这些元数据做一些别的事
做一些非你主干流程的一些事情
这个是一个最后
其实是尽量我们少用context value
因为context value其实还有一些小技巧
尽量不要用WithValue挂很多
我们可以用参考grpc的metadata定义一个map
还有就是说在定义这个key的时候
你如果用字符串定义
一定会出现一个冲突
因为各个goroutine之间大家不容易定一个
全局的不冲突的字典
所以说我们一般会
你的技术库里面的各个子包
一般会用私有类型
用私有类型的话它会先判定类型
然后再判定里面的值
那么你私有类型的话每个包的私有类型在自己包里面定义的
它一定不会出错
所以我们可以看一下
context官方的标准库它有个非常简单demo
然后我这个地方也给了一些官方的一些talks
也可以大家课后要去看一下
其实认真看完官方的这个blog
基本上我都会把这些知识都覆盖到的
后面我会包含非常多的一些reference
我希望大家这些reference都认真去阅读
因为整个sync的内容
sync整个并发这块的内容非常多
好这一块我们整个就讲完了
然后大家如果有什么疑问
快速提出来
然后我后面找时间给大家尽快去解答
因为我相信大家这个时候一定会有疑问
但是在我们提出疑问前也一定先思考
然后看完一些reference
然后我们再来我们再来对齐