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

书接上文,我们在《ETCD源码分析Client端启动流程分析》中,深入源码查看ETCDClient端如何启动,如何向Server建立GRPC连接的。在本文中,我们继续探究ETCD的Client端是如何实现Watch逻辑的。
由于Watch逻辑整体还是比较复杂的,建议观看本文的小伙伴,把ETCD的V3.5版本源码打开,对比着文章查看。

ETCD源码Watch启动流程

首先对Watch流程进行分解,先介绍下ETCD客户端是如何启动Watch的。

在这里插入图片描述

Watch模块通过Watcher接口对外提供三个功能:

  • Watch() 用于创建对某个key或者某些key的Watch流;
  • RequestProgress() 用于告知客户端当前集群最新的reversion;
  • Close() 用于关闭所有的Watch流。

内部有一个私有的watcher实例,其中最关键的成员就是watchGrpcStream,存储了底层的GRPC Stream,提供了GRPC流的管理;请求、响应的管理;缓冲队列的控制等等,由于其中运用了大量的Channel,加上一些数据结构的处理,代码初看起来还是比较绕的。

不废话,让我们从Watch开始,先看看整个Watch流程是怎么启动的。

func (w *watcher) Watch(ctx context.Context, key string, opts ...OpOption) WatchChan {
	// 刚开始对opts做了些处理,
	// 我们不care,专心看主要逻辑

    // 每调用一次Watch
    // 都会创建一个watchRequest,这个wr会
    // 经过层层流转,最终发送到ETCD服务端
    // 并在客户端服务端之间
    // 创建出一条GRPC的Stream
    // 
    // Watch可能会被多次调用,*但是*(强调一下)
    // 但是以后就会共用最初创建的那条GRPC的Stream。
    // Watch机制的代码做的这么复杂,也就是
    // 为了处理共用一条Stream连接的问题。
	wr := &watchRequest{
		ctx:            ctx,
		createdNotify:  ow.createdNotify,
		key:            string(ow.key),
		end:            string(ow.end),
		rev:            ow.rev,
		progressNotify: ow.progressNotify,
		fragment:       ow.fragment,
		filters:        filters,
		prevKV:         ow.prevKV,
		retc:           make(chan chan WatchResponse, 1),
	}

	ok := false
	// 上面说一般是共用GRPC的Stream连接
	// 其实如果你传进来的ctx带的value不一样
	// 它就不会共用连接了。
	// 不过我们一般是用不上的,所以读者们可以忽略
	// ,记住是共用连接就好。
	// ps. 一般传进来的ctx有两种:
	//     1. context.Background()
	//     2. clientv3.WithRequireLeader(ctx)
	//     后面还会详细介绍
	ctxKey := streamKeyFromCtx(ctx)

	var closeCh chan WatchResponse
	for {
	    // 创建或重用GrpcStream
		// 如果ctxKey不变的话,
		// 就用streams中的缓存。
		// 
		wgs := w.streams[ctxKey]
		if wgs == nil {
			wgs = w.newWatcherGrpcStream(ctx)
			w.streams[ctxKey] = wgs
		}
		donec := wgs.donec
		reqc := wgs.reqc
		...

		select {
		// 上面代码创建出的wgs,
		// 其内部创建的gorotine是核心处理逻辑
		// 这里也只是把 wr 通过 reqc 通道
		// 发到核心goroutine就完事了。
		case reqc <- wr:
			ok = true
			
		// 如果创建的wgs被done了
		//(包括关闭、异常等等情况)
		// Watch()报告失败,流程终止。
		case <-donec:
		    ...
		}

		if ok {
			select {
			// wr 正确发送以后,
			// 其retc会收到一个chan WatchResponse
			// 而retc本身是个
			// chan chan WatchResponse
			// 应该怎么叫?channel的channel吧。
			case ret := <-wr.retc:

			    // 只有走到这里,
			    // 才是正常结束Watch()函数
				return ret
				...
			}
		}
	}

    // 异常结束
	close(closeCh)
	return closeCh
}

通过以上分析我们就会发现整个Watch()函数就做了三件事:

  1. 创建或使用缓存的watchGrpcStream
  2. 将WatchRequest发送到watchGrpcStream
  3. watchGrpcStream会为WatchRequest生成 chan WatchResponse用于接收结果
    并通过chan chan WatchResponse 返回给用户。

这里简单介绍一下 Watch(ctx context.Context, key string, opts …OpOption) WatchChan 这个函数支持哪些context,不同的context会让Watch产生不同的行为。

  1. context.WithBackground() / context.WithCancel()
    最常用的ctx,设置此ctx,watch会保证底层连接创建成功
    也就是会不断重试,直到连接成功或者ETCD服务端返回严重错误
    或者调用ctx的cancel()方法,也可以强行结束watch
  2. context.WithTimeout()
    这个就是Timeout时间后,自动帮你调用cancel()
    也就是timeout时间后,不管你的Watch流是正常还是异常
    都会强行结束,一般不能用这种context
  3. clientv3.WithRequireLeader(ctx)
    在ctx中加入 RequireLeader 标志,这个标志处理的问题是:
    若ETCD服务集群发生网络分区,且你的客户端Watch流正好连接少数一方的节点
    那你的Watch就卡住了,不会有响应,白白浪费资源。
    加入这个标志,Watch在发现自己连接的节点是少数一方节点时,尝试重新连接到其它的节点,这样就自动的处理了分区带来的不可用问题。
ETCD源码Watch监控流程

接下来,我们进入watchGrpcStream 的内部,看看它到底是如何处理连接复用问题的。
看代码之前要记住一点,watchGrpcStream做了两件事:1. 管理自己的底层GRPC连接(处理Request、Response);2. 管理共用底层连接的watcherStream。

为了简化描述,我们需要为几个关键角色起个别名
watchGrpcStream :wgs
watcherStream : ws
watchRequest : wr
watchResposne :wresp

// wgs 的主要流程就在run()内部,这是核心处理逻辑
func (w *watchGrpcStream) run() {
	...
	// 关闭流程会在单独介绍,此处不管
	closing := make(map[*watcherStream]struct{})
	defer func() {
		...
	}()

	// 启动底层GRPC连接
	if wc, closeErr = w.newWatchClient(); closeErr != nil {
		return
	}
	
	var cur *pb.WatchResponse
	for {
		select {
		// Watch() 方法内new出来的WatchRequest,
		// 最终会传递到这里。
		case req := <-w.reqc:
			switch wreq := req.(type) {
			case *watchRequest:
				outc := make(chan WatchResponse, 1)
				// 每个 wr 都会建立虚拟的Stream
				// 因为 watcherStream 实际上在共用底层连接
				// 所以我叫它叫虚拟的Stream
				ws := &watcherStream{
					initReq: *wreq, // wr 和 ws 是一对一关系
					// 每个 ws 都会在ETCD服务端生成watchId
					// 用于标识这个虚拟的Stream
					id:      -1, 
					outc:    outc,
					recvc: make(chan *WatchResponse),
				}
				ws.donec = make(chan struct{})
				
				// 建立好一个ws,WatiGroup都会加1
				// 记录建立了多少个ws
				w.wg.Add(1)
				// 虚拟的Stream开始提供服务
				go w.serveSubstream(ws, w.resumec)

				// ws 进入resuming队列,注意这个处理
				// 与之对应的还有一个substreams的hash set
				// 这两个数据结构都是用来存放 ws 的
				// resuming和substreams就好像预备党员和党员的关系
				// 初始创建的 ws 只是预备役,wgs会帮它发送 wr
				// *只有底层grpc连接收到对应的 wresp 后,*
				// *它才能提升到 substreams 中*
				w.resuming = append(w.resuming, ws)
				// 为保证wr的发送顺序(发wr收到wresp才发送下一个wr)
				// 如果w.resuming不是1(说明resuming中还有没拿到wresp的wr)就不能发
				if len(w.resuming) == 1 {
					// wc 是 wgs 内部GRPC连接的管理者(GRPC 的client)
					if err := wc.Send(ws.initReq.toPB()); err != nil {
						w.lg.Debug("error when sending request", zap.Error(err))
					}
				}
			...
			}
		// 底层收到了ETCD发来的一个 WatchResponse
		case pbresp := <-w.respc:
			// cur 专门用来支持 Fragment 功能
			// 也就是ETCD服务器可能会把比较大的wresp分片发送
			// cur 是用来把这些分片重新整合成完整的 wresp
			if cur == nil || pbresp.Created || pbresp.Canceled {
				cur = pbresp
			} else if cur != nil && cur.WatchId == pbresp.WatchId {
				// merge new events
				...
			}

			switch {
			case pbresp.Created:
				if len(w.resuming) != 0 {
					// 收到响应后,resuming队列的
				    // 第一个ws成功从预备役转为正式役
				    //(被ETCD服务端分配了一个WatchId)
					if ws := w.resuming[0]; ws != nil {
						w.addSubstream(pbresp, ws)
						// dispatchEvent
						// 会把wresp分给substreams里的 ws
						// 根据 wresp 中的 WatchId 匹配
						w.dispatchEvent(pbresp)
						w.resuming[0] = nil
					}
				}
				// 顺序发送下个resuming队列的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))
					}
				}
			...
			// 如果 wresp 既不是Created响应,
			// 也不是Cancel响应,也不是Fragment响应
			// ps. (wresp的响应类型分为这三种,
			//     Cancel响应会在关闭流程介绍)
			//     那就转发给 ws 处理
			default:
				// 转发到 ws 中处理
				ok := w.dispatchEvent(cur)
				if ok {
					break
				}

				// 客户端这里没有 ws 可以处理这个 wresp
				// 那就通知ETCD服务器关闭这个虚拟的watchStream
				if _, ok := cancelSet[pbresp.WatchId]; ok {
					break
				}
				// cancelSet用来去重
				cancelSet[pbresp.WatchId] = struct{}{}
				// 通知服务端关闭 pbresp.WatchId 
				// 对应的虚拟Stream
				cr := &pb.WatchRequest_CancelRequest{
					CancelRequest: &pb.WatchCancelRequest{
						WatchId: pbresp.WatchId,
					},
				}
				req := &pb.WatchRequest{RequestUnion: cr}
				if err := wc.Send(req); err != nil {
					...
				}
			}
		// 以下在关闭流程中会详细介绍
		case err := <-w.errc:
			...
		case <-w.ctx.Done():
			...
		case ws := <-w.closingc:
			...
		}
	}
}

这里我尽量详细的把源码的每个部分它的作用、设计注释出来。对照源码本身去看,应该能很快明白其中原理。

比较反对一些源码分析文章,抛出一大段源码,做几个寥寥注释,看的人云里雾里的那种。我觉得分析源码,要么解释详尽,要么图文并茂。当然这是题外话了。

最后,我们通过一张流程图,再总结下 wgs 的整个流程。

watchGrpcStream watchClient watcherStream newWatchClient() w.respc<-wresp loop wc new watcerStream(wr) WatchChan <- wresp close() alt [ws.recvc] [w.ctx.Done()] loop ws addSubstream() dispathEvent() closeSubstream() alt [wresp.Cre- ated] [wresp.Can- celed] alt [w.reqc] [w.respc] loop watchGrpcStream watchClient watcherStream

从上图可以看出:

  1. 图中三个角色都有自己内部的死循环:
    • watchGrpcStream 的死循环在总览大局,既负责处理用户侧发来的WatchRequest,又负责接收watchClient发来的WatchResponse,并把它适当的转发到对应的watcherStream
    • watchClient 的死循环就做一件事:接收ETCD服务端发来的WatchResponse并把它发给watchGrpcStream
    • watcherStream 的死循环负责把 WatchResponse通过WatchChan发回用户侧,并维护结果缓冲队列(用户侧来不及处理的消息会被缓存在此)。

以上我们详细介绍了watchGrpcStream,接下来,我们进入watchClient、watcherStream的循环中看看。

func (w *watchGrpcStream) newWatchClient() (pb.Watch_WatchClient, error) {
	// -------Start--------
	close(w.resumec)
	w.resumec = make(chan struct{})
	w.joinSubstreams()
	for _, ws := range w.substreams {
		ws.id = -1
		w.resuming = append(w.resuming, ws)
	}
	// strip out nils, if any
	var resuming []*watcherStream
	for _, ws := range w.resuming {
		if ws != nil {
			resuming = append(resuming, ws)
		}
	}
	w.resuming = resuming
	w.substreams = make(map[int64]*watcherStream)

	// connect to grpc stream while accepting watcher cancelation
	stopc := make(chan struct{})
	donec := w.waitCancelSubstreams(stopc)
	// ------- END --------
	// 这里有一大段代码,开始我也没理解
	// 后来梳理了关闭流程才知道原因
	// 其实是跟重启有关,这里我们暂时可以不关心。
	
	// 其实除去上面那大段复杂代码,
	// watchClient 的逻辑其实很简单
	// 下面这里先通过GRPC的conn开启一个 wc
	wc, err := w.openWatchClient()
	...
	// 再启动goroutine循环,用来接收
	// ETCD服务端发来的 WatchResponse
	// 并转发给 watchGrpcStream
	go w.serveWatchClient(wc)
	return wc, nil
}

再看下 watcherStream ,这里主要看它内部的死循环的处理逻辑:

func (w *watchGrpcStream) serveSubstream(ws *watcherStream, resumec chan struct{}) {
	... 
	// 上面代码也是在处理关闭流程
	emptyWr := &WatchResponse{}
	for {
		curWr := emptyWr
		outc := ws.outc

		// outc 就是 WatchChan,就是用户侧接收的channel。
		// ws.buf 是缓冲队列,这里的处理是取队头数据
		if len(ws.buf) > 0 {
			curWr = ws.buf[0]
		} else {
			outc = nil
		}
		// 多路复用的四种情况
		// 1. outc <- *curWr outc不阻塞,那就把
		//    队头的WatchResponse发出去
		// 2. wr, ok := <-ws.recvc recvc有数据
		//   (数据是watchGrpcStream通过dispathEvent发过来的)
		// 3. <-w.ctx.Done() 上下文关闭,进入关闭流程
		// 4. <-resumec 这也是重启流程用的,这个分支如果执行
		//    ws 就从“正式党员”跌回“预备党员”
		//   (也就是说,从substreams移除,回到resuming)
		// select语句的特性,如果多个条件都可以执行,
		// 会随机选择一个执行。
		select {
		case outc <- *curWr:
			if ws.buf[0].Err() != nil {
				return
			}
			ws.buf[0] = nil
			ws.buf = ws.buf[1:]
		case wr, ok := <-ws.recvc:
			if !ok {
				// 关闭ws的recvc,会让处理循环退出
				return
			}
			if wr.Created {
				// 如果retc不为nil,才能进入下面代码
				if ws.initReq.retc != nil {
					// outc 通过retc发出去,会被Watch()本身的循环接收到
					// 忘记了的可以翻到最上面看看
					ws.initReq.retc <- ws.outc
					// 假如没有这句代码会发生什么?
					// 我们知道Watch机制有自动重试功能
					// 出现错误,Watch会自动再次重试(会在关闭流程详细介绍)
					// 重试会重新发送wr,收到ETCD服务端发过来Created标志的wresp
					// 所以这个判断可能会进多次
					// 但是对用户侧来说,重试是无感知的,
					// 用户侧不希望收到多次Created的wresp
					// 所以把 retc 置 nil 是必要的
					// 这样下次不会进来这段代码了。
					ws.initReq.retc = nil

					if ws.initReq.createdNotify {
						// 设置了WithCreatedNotify(),wresp会马上发出去
						// 不会进入缓冲队列
						ws.outc <- *wr
					}
					// 只有rev为0才更新进度
					// 这里处理的是:
					// 假如第一次创建Watch拿到当前Revision(假设是2)
					// 但是Watch因为某些原因重启了,这时ETCD服务器的Revision变成100了
					// 重启就丢失了2-100之间的值
					// 所以:在Watch流Created的时候,
					// 如果rev不为0,就不管ETCD服务器的Revision
					// 用initReq.rev原来的值
					if ws.initReq.rev == 0 {
						nextRev = wr.Header.Revision
					}
				}
			} else {
				nextRev = wr.Header.Revision
			}
			// 更新下当前的Watch进度
			if len(wr.Events) > 0 {
				nextRev = wr.Events[len(wr.Events)-1].Kv.ModRevision + 1
			}
			ws.initReq.rev = nextRev
			...
			ws.buf = append(ws.buf, wr)
		case <-w.ctx.Done():
			return
		case <-ws.initReq.ctx.Done():
			return
		case <-resumec:
			resuming = true
			return
		}
	}
}

以上,watcherStream的处理逻辑总结为以下两点:

  1. 把ETCD服务器的WatchResponse发出去
  2. 缓冲队列的维护
ETCD源码Watch关闭流程

关闭流程限于篇幅,将在下篇文章详细介绍。

参考文献
  • https://etcd.io/docs/v3.5/learning/data_model/
  • https://github.com/etcd-io/etcd/pull/7795/files
  • https://github.com/etcd-io/etcd/pull/6647/files
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Ceph是一个分布式存储系统,包括多个组件,其中包括Librbd块存储库。在Ceph中,Librbd提供了一种将RBD(块设备)映射到客户的方法,并使客户能够读写这些设备。在本文中,我们将分析Librbd块存储库的源代码以及RBD的读写过程。 1. Librbd源码分析 Librbd块存储库的源代码位于src/librbd目录下。其中,包括librbd.cc、ImageCtx.cc、ImageWatcher.cc、Journal.cc等多个源代码文件。这些源代码文件组成了Librbd块存储库的核心。 其中,librbd.cc是Librbd块存储库的主要源代码文件。在这个文件中,包括了Librbd的初始化、映射、卸载等方法。ImageCtx.cc则是Image上下文,用于管理Image的状态、锁定、映射等信息。ImageWatcher.cc用于监控Image的状态变化,Journal.cc则是Librbd的Journal日志管理。 2. RBD读写流程源码分析 在Ceph中,RBD由client和server两个部分组成。client在客户上运行,提供了将RBD映射到客户的方法。server在存储集群中运行,提供了RBD的存储和管理。 RBD的读写流程如下: 1)客户向Ceph Monitor请求RBD映射信息,Monitor返回Image ID和Image特性; 2)客户向Ceph OSD请求RBD数据块,OSD返回数据块内容; 3)客户将数据写入或读取到本地块设备; 4)客户向Ceph OSD写入或读取数据块,OSD返回操作结果; 5)客户向Ceph Monitor请求解除RBD映射,Monitor返回解除结果。 在上述过程中,涉及到的源代码文件有:librbd.cc、ImageCtx.cc、ImageWatcher.cc、Journal.cc等。 总结 Librbd块存储库和RBD读写流程是Ceph存储系统的核心组件之一,通过分析源代码可以更加深入地了解Ceph存储系统的实现原理。同时,对于开发者来说,也有助于在Ceph存储系统上构建更加高效、稳定的存储应用。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值