书接上文,继续分析Client端Watch流程的关闭流程的处理。首先我们再回顾下Watch中的关键角色和设计理念:
角色 | 说明 |
---|---|
watcher | 对外接口Watcher的实现,重点是Watch()方法 |
watchGrpcStream | 桥梁,管理内部GRPC连接、管理内部的虚拟Stream、消息接收和分发 |
watchClient | 对应底层的GRPC连接客户端 |
watcherStream | 虚拟的Stream,处理服务端和客户端的通信 |
要分析关闭流程,我们首先来找找有哪些情况会导致关闭:
1. xx.ctx.Done()
首先,所有的角色都有内部循环,所有的角色都包含ctx上下文。所以,ctx是通用的关闭方式。
一般来说,ctx的使用套路有如下通用代码:
func main(){
ctx, cancel = context.WithTimeout(ctx, client.cfg.DialTimeout)
go resource.Run(ctx)
defer func(){
if cancel != nil{
cancel()
}
}()
for{}
}
func(r *Resource) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
// .. do somethings
}
}
这样,当ctx被cancel()或者timeout到期,运行的子goruntine便会自动退出。其内部也是通过Channel来通信的,这边不在过多介绍。主要看看Context在Watch流程中的使用情况。
在Watch流程中的各角色内部循环,基本上也保持着跟以上示例代码相似的结构,都是通过select
检查ctx.Done()是否不阻塞了,从而执行关闭循环的逻辑。
2. <-w.errc
在watchGrpcStream的内部大循环中,有case err := <-w.errc
这条路径,其中err
是serveWatchClient
循环在接收到ETCD服务端发来的错误时转发过来的,但不一定会导致Watch流程关闭,根据错误的不同,可能会被关闭,也可能会自动重启。
3. watchGrpcStream里没有虚拟Stream了
当watchGrpcStream发现所有的虚拟Stream已经被关闭了,也会把自己关闭,结束Watch流程。
4. 用户侧主动调用Close()
当用户主动调用Close()时,整个Watch流程当然也关闭了。这也是通过调用全局ctx
的cancel()方法来实现的,所以和第1点的逻辑是相同的。
关闭流程具体分析
找到所有关闭场景后,我们接下来仔细分析下各角色是如何关闭的:
1. watcher 的关闭
func (w *watcher) Close() (err error) {
w.mu.Lock()
// 上篇文章说过,streams中
// 保存了watchGrpcStream对象,而且一般只有一个,全局公用
streams := w.streams
w.streams = nil
w.mu.Unlock()
// 关闭所有 watchGrpcStream
for _, wgs := range streams {
if werr := wgs.close(); werr != nil {
err = werr
}
}
// 由于关闭会调用 ctx 的 cancel()
// 所以 ctx 本身肯定会报 context.Canceled 的错误
// 这是正常现象,说明cancel()调用成功
if err == context.Canceled {
err = nil
}
return err
}
watcher的关闭十分简单,因为它本身没有循环,只需要通知watchGrpcStream需要关闭就好,它基本上啥也不用做。
2. watchClient的关闭
func (w *watchGrpcStream) serveWatchClient(wc pb.Watch_WatchClient) {
for {
resp, err := wc.Recv()
if err != nil {
select {
case w.errc <- err:
case <-w.donec:
}
// 接收到ETCD服务端的err
// 退出循环,关闭自己
return
}
select {
case w.respc <- resp:
case <-w.donec:
// 发现watchGrpcStream已经donec了(关闭了)
// 自己也马上退出
return
}
}
}
watchClient的关闭也不难看懂,接收到错误了,自己的循环就退出;或者发现watchGrpcStream已经done了,自己也退出。这里有个小tip,如果代码卡在resp, err := wc.Recv()
这行(也就是阻塞等待ETCD服务端的消息),这时如果watchGrpcStream给done了,它这个循环怎么退出?
其实答案就在watchClient被创建时,watchClient, err = w.remote.Watch(w.ctx, w.callOpts...);
这里,watchGrpcStream的ctx已经被传进去了,所以一旦这个ctx关闭,resp, err := wc.Recv()
这里也会马上返回resp = nil
、err = context.Canceled
,不会再阻塞代码。
3. watcherStream的关闭
func (w *watchGrpcStream) serveSubstream(ws *watcherStream, resumec chan struct{}) {
...
// 自动重启用的
resuming := false
// 关闭流程
defer func() {
if !resuming {
// 标记进入关闭流程(关闭中)
// 需要watchGrpcStream才能真正的关闭watcherStream
// 因为需要watchGrpcStream来回收资源
ws.closing = true
}
// donec 是ETCD中常用的信号设计
// close(ws.donec) 就代表 这个 ws 已经 done了(关闭了)
close(ws.donec)
if !resuming {
// 发给watchGrpcStream处理自己的关闭流程
w.closingc <- ws
}
// watcherStream的数量-1
w.wg.Done()
}()
emptyWr := &WatchResponse{}
for {
...
select {
case outc <- *curWr:
...
case wr, ok := <-ws.recvc:
if !ok {
// close(ws.recvc)
// 也可以让 watchStream进入关闭流程
return
}
...
case <-w.ctx.Done():
return
case <-ws.initReq.ctx.Done():
return
case <-resumec:
// 这个标志表示进入重启流程
// 虽然watchStream的内部循环退出了
// 但重启后,内部循环又会重新开启
resuming = true
return
}
}
}
这里watchStream就是要区分关闭和重启两个流程的区别:关闭把自己发给watchGrpcStream回收资源;重启就是关掉内部循环,等重启完成后,又会重新开启,资源是不变的。
我们结合 watchStream 的状态流转图,再详细的介绍一下:
通过watchRequest,我们会创建一个watchStream,这个在前面的文章中已经介绍过了。创建出来的watchStream是resuming状态(预备状态),只有收到ETCD服务器的resp,才会转为substream状态;而watchGrpcStream如果重启底层GRPC连接,又会把所有substream状态的watchStream打回resuming状态;其三,不论是resuming还是substream状态,都可以被closing,ctx.Done
就不介绍了,close(recvc)
的情况有两种:
1. watchGrpcStream 自己要关闭了,就通过close(recvc)
关闭它内部的所有watchStream
2. ETCD服务器返回了标志Canceled
的response,也会调用close(recvc)
关闭watchStream
接下来看看 watchGrpcStream如何回收watchStream资源的:
// 本代码截取自 watchGrpcStream 的 run() 方法
case ws := <-w.closingc:
// 稍后分析
w.closeSubstream(ws)
// 当ETCD服务端返回标志Canceled的response,会把ws加到closing这个set中
// 然后关闭它的recvc,这里是把ws从closing中移除。
delete(closing, ws)
// 这里当substreams和resuming的ws都没有,就watchGrpcStream也可以直接退出
// 这里注意,如果watchGrpcStream直接退出,就不跟ETCD服务器打招呼了(底层连接都没了)。
if len(w.substreams)+len(w.resuming) == 0 {
return
}
// 如果watchGrpcStream本身没退出,还是要跟ETCD服务器客气一下
// 不然ETCD服务器那边对应的ws没关闭。
if ws.id != -1 {
// 为防止发多次退出消息,加到唯一集合里。
cancelSet[ws.id] = struct{}{}
cr := &pb.WatchRequest_CancelRequest{
CancelRequest: &pb.WatchCancelRequest{
WatchId: ws.id,
},
}
req := &pb.WatchRequest{RequestUnion: cr}
if err := wc.Send(req); err != nil {
...
}
}
}
这里有closing和cancelSet两个数据结构,都是为了防止其它地方也在关闭ws导致重复关闭的问题,这些细枝末节就不再展开了。重点还是watchStream的状态变换。
// 回收 watchStream的资源就做了三件事
func (w *watchGrpcStream) closeSubstream(ws *watcherStream) {
...
// 1. 向用户侧发送关闭消息,关闭用户侧的Channel
if closeErr := w.closeErr; closeErr != nil && ws.initReq.ctx.Err() == nil {
go w.sendCloseSubstream(ws, &WatchResponse{Canceled: true, closeErr: w.closeErr})
} else if ws.outc != nil {
close(ws.outc)
}
// 2. 把 ws 从substreams中移除
if ws.id != -1 {
delete(w.substreams, ws.id)
return
}
// 3. 把 ws 从resuming中移除
for i := range w.resuming {
if w.resuming[i] == ws {
w.resuming[i] = nil
return
}
}
}
3. watchGrpcStream的关闭
最后便是watchGrpcStream的压轴出场,它的关闭流程也是最复杂的。因为还涉及到自动重启。接下来我们首先分析自动重启过程:
case err := <-w.errc:
// 自动重启只有一种情况,那就是接收到ETCD服务端发来的错误
if isHaltErr(w.ctx, err) || toErr(w.ctx, err) == v3rpc.ErrNoLeader {
// 不能重启,发生严重错误
// 严重错误一般是指:
// 1. 不是 Unavailable 的错误
// 2. 也不是 Internal 的错误
closeErr = err
return
}
// 重新连接一个WatchClient,原来的那个,因为出现err,已经退出了
if wc, closeErr = w.newWatchClient(); closeErr != nil {
return
}
// 因为所有ws都变成resuming了,找队首的ws,并把它的request发出去
// 重新开始ws的状态流转
if ws := w.nextResume(); ws != nil {
if err := wc.Send(ws.initReq.toPB()); err != nil {
w.lg.Debug("error when sending request", zap.Error(err))
}
}
// 因为所有ws都变成resuming了,cancelSet重置
cancelSet = make(map[int64]struct{})
接下来看下newWatchClient内部逻辑:
func (w *watchGrpcStream) newWatchClient() (pb.Watch_WatchClient, error) {
// 还记得我们分析watchStream内部循环时
// 说的resumec吗?可以回去再看下
close(w.resumec)
w.resumec = make(chan struct{})
// 重置resumec后,所有 ws 内部循环都会退出
// 但不是关闭,这里等循环退出完
w.joinSubstreams()
// 把substrems状态的ws转为resuming状态
for _, ws := range w.substreams {
ws.id = -1
w.resuming = append(w.resuming, ws)
}
...
// 有一种 ws 是不能转为 resuming 状态的
// 那就是它内部的 ctx 到期了(ctx.Done)
// 这里就是把这种 ws 剔除
stopc := make(chan struct{})
donec := w.waitCancelSubstreams(stopc)
wc, err := w.openWatchClient()
close(stopc)
<-donec
// 好了,剩下的resuming状态的ws,又可以重新开启循环
// 完成重启
for _, ws := range w.resuming {
if ws.closing {
continue
}
ws.donec = make(chan struct{})
w.wg.Add(1)
go w.serveSubstream(ws, w.resumec)
}
if err != nil {
return nil, v3rpc.Error(err)
}
// 重新监听GRPC底层连接
go w.serveWatchClient(wc)
return wc, nil
}
细心读者可能会发现,newWatchClient() 为了重启做这么多操作,那它第一次运行的时候会不会收到影响?仔细观察就会发现,第一次运行时,还没有ws呢,这时很多代码都是空跑,没有实际逻辑。
看完了重启,我们在看看watchGrpcStream的关闭流程,在看之前,我们可以停下来想想,基于以上文章讲的这么多信息,如果我们来实现这个关闭流程,应该怎么做??
我认为应该分以下几步:
- 如果有错误,要收集起来,通过watchStream的channel发给用户侧知晓
- 关闭substream内部的ws
- 关闭resuming内部的ws
- 关闭上下文,这样依赖这个上下文的对象都可以被关闭
ok,我们看看watchGrpcStream的真正核心代码是怎么做的:
defer func() {
// 收集错误,这个错误会在closeSubstream时候被带到用户侧。
w.closeErr = closeErr
// 关闭 substreams 内部的 ws
for _, ws := range w.substreams {
if _, ok := closing[ws]; !ok {
close(ws.recvc)
closing[ws] = struct{}{}
}
}
// 关闭 resuming 内部的 ws
for _, ws := range w.resuming {
if _, ok := closing[ws]; ws != nil && !ok {
close(ws.recvc)
closing[ws] = struct{}{}
}
}
// 等待ws们关闭成功
w.joinSubstreams()
// 回收ws们的资源
for range closing {
w.closeSubstream(<-w.closingc)
}
w.wg.Wait()
// 关闭自己
w.owner.closeStream(w)
}()
核心关闭流程就在 defer 中,在closeStream内部:
func (w *watcher) closeStream(wgs *watchGrpcStream) {
w.mu.Lock()
// 发送donec信号,告知自己关闭
close(wgs.donec)
// ctx 关闭
wgs.cancel()
// 把自己从watcher的streams中移除
if w.streams != nil {
delete(w.streams, wgs.ctxKey)
}
w.mu.Unlock()
}
真正关闭流程跟我分析的关闭流程相比较,我忽略了watchGrpcStream被缓存起来用于共用了,如果它被关闭,我们应该把它从缓存中移除。
Watch流程总结
OK,Watch流程分析到这也就大结局了,结束之前,我们再回顾下整个流程,看看有哪些精华的设计思想可以为我们所用:
- 各种角色的划分
初看Watch流程代码的人,肯定会被绕晕(我就被绕晕了)。搞不懂为什么要这么多角色,数据在其内部转发来转发去,分析完各位应该都有清晰的认识了,不是为了炫技,只有两个根本目的:一是为了资源的共享,搞出来这么多抽象角色;二是为了分工明确,拆解复杂的逻辑。
而我们在分析过程中,也抓住了正确思路,那就是拆。不论是watcher、watchGrpcStream、watchStream等角色的拆开分析,还是启动、监听、关闭、重启流程的拆开分析。在分析过程中,只关注分析主题所关注的信息,忽略无关代码。这种思想在任何场景下的源代码分析都是可行的。 - ctx 的使用
ETCD代码中对ctx可谓是使用到了精髓,关闭离不开它,通信离不开它。
这样来看,若ctx只被用来传递一些上下文变量,可真是大材小用了。 - donec/resumc/closingc 的使用
我们在写代码时,是不是经常用 status \ isClosing 这种int值或者bool值表示状态?
这种表示方式当然是可行的,但是在多线程环境下,很可能出现并发问题。我们在看ETCD的代码,它表示状态的方式大量的使用了Channel,这个Channel可以不发送任何数据,它只是个信号,close(donec)就会被触发,从而表示了状态的流转。而且是并发安全的。
还有其它的总结,读者们可以发到评论区,大家一起讨论。
接下来,若有时间,我还会继续总结ETCD或者其它系统的各种知识。古人云:“学而时习之,不亦说乎”。 在我的实践中,知识若不常翻常总结,已经不是高兴不高兴的问题了,过段时间不用,跟新手还是没什么区别,尤其是在当下各种新技术层出不穷的情况下。开玩笑的说,当时大脑皮层是学会了,可惜海马体没学会。所以总结还是很重要的,希望自己能坚持下去!