ETCD源码分析(二)Client端Watch流程分析二

1,146 阅读10分钟

书接上文,继续分析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 这条路径,其中errserveWatchClient循环在接收到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 = nilerr = 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的关闭流程,在看之前,我们可以停下来想想,基于以上文章讲的这么多信息,如果我们来实现这个关闭流程,应该怎么做??

我认为应该分以下几步:

  1. 如果有错误,要收集起来,通过watchStream的channel发给用户侧知晓
  2. 关闭substream内部的ws
  3. 关闭resuming内部的ws
  4. 关闭上下文,这样依赖这个上下文的对象都可以被关闭

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流程分析到这也就大结局了,结束之前,我们再回顾下整个流程,看看有哪些精华的设计思想可以为我们所用:

  1. 各种角色的划分 初看Watch流程代码的人,肯定会被绕晕(我就被绕晕了)。搞不懂为什么要这么多角色,数据在其内部转发来转发去,分析完各位应该都有清晰的认识了,不是为了炫技,只有两个根本目的:一是为了资源的共享,搞出来这么多抽象角色;二是为了分工明确,拆解复杂的逻辑。 而我们在分析过程中,也抓住了正确思路,那就是拆。不论是watcher、watchGrpcStream、watchStream等角色的拆开分析,还是启动、监听、关闭、重启流程的拆开分析。在分析过程中,只关注分析主题所关注的信息,忽略无关代码。这种思想在任何场景下的源代码分析都是可行的。
  2. ctx 的使用 ETCD代码中对ctx可谓是使用到了精髓,关闭离不开它,通信离不开它。 这样来看,若ctx只被用来传递一些上下文变量,可真是大材小用了。
  3. donec/resumc/closingc 的使用 我们在写代码时,是不是经常用 status \ isClosing 这种int值或者bool值表示状态? 这种表示方式当然是可行的,但是在多线程环境下,很可能出现并发问题。我们在看ETCD的代码,它表示状态的方式大量的使用了Channel,这个Channel可以不发送任何数据,它只是个信号,close(donec)就会被触发,从而表示了状态的流转。而且是并发安全的。

还有其它的总结,读者们可以发到评论区,大家一起讨论。