Mit 6.824 中的Go并发编程经验和思考

555 阅读11分钟

这篇文章写在我完成 6.824 Lab2:Raft之后。 这个Lab我前前后后做了五天,整体下来感觉酣畅淋漓。 多线程编程相信是个程序员就有经历过,可是对于我来说, 这是第一次编写状态如此复杂的,需要考虑的case如此多的并行程序。 网络是不可相信的,所有的请求,可能丢失,可能延时到来。 程序是不能长久运行的,随时可能被杀掉。而程序的核心则是一个复杂的状态机,一旦代码中哪里考虑的不周全,就会出现race condition,出现死锁,活锁,以及各种逻辑错误。 而并行程序的复杂性也使得Debug变得困难,大多数时候需要查看运行日志寻找蛛丝马迹,有时甚至会有无法复现bug的情况。 这些可能对于高级工程师来说已经是稀松平常了,但是对于我,平时做的事情无非就是看看书背背知识点,写写无状态的服务器,搞点小玩具,最多刷刷题目烧烧脑。 所以能有一个这样的机会亲自实现一个这样的系统,真的是非常激动人心的。 赞美Mit师生,给我们提供了这么完备的基础设施。

Lab需要用go实现,而go是一门十分适合并行程序编写的语言,语法足够简单,有性能优秀的gc,标准库功能强大,这也是Morris选择go的原因。 但是大多数人其实没有接触过go的,新手上路不免踩上一点坑。 常见的如大小写我就不谈了,可以从lab主页提供guide等资料和代码中的提示找到一些常见的语言坑。 这里写一点实现的过程中会遇到的并行问题,以及我的解决方案。

实现Lab2的过程常见的并行问题

1. 如何正确使用time.Timer?

定时器是实现raft的过程不可缺少的组件。 比如,election timeout的实现,我们会用到定时器的触发与重置。 AppendEntries Rpc也需要定时发送以维持leader权威。 于是,我们需要一个可以重置时间,并且可以超时触发的工具,time.Timer是符合这个要求的。

但是Lab的主页中却明明白白地说:

Don't use Go's time.Timer or time.Ticker, which are difficult to use correctly.

而Timer的一系列函数,如Stop和Reset,也在注释上写明了使用的注意事项,但是却十分令人费解。 所以Lab推荐使用for + sleep的组合,在醒来的时候check一些变量,达到定时器的目的。 也就是,彻底废除Reset Timer的功能, 使得timer一定会触发,从而避免Reset/Stop Timer会出现的迷惑行为。

但是上面的实现终究是不够优雅的,我们还是希望可以正确地重置超时时间, 让程序的运行符合算法的描述。

那么,可行的使用方式是怎么样的呢? 以election timeout为例

func (m *timeoutManager) start() {
	t := time.NewTimer(m.random())
	defer t.Stop()
	expired := false
	for {
		select {
      // channel for resetting this timer
		case <-m.rChan:
			if !t.Stop() && !expired {
				// drain the channel
				<-t.C
			}
			expired = false
			t.Reset(m.random())
		case <-t.C:
			// timeout event fired
			expired = true
			m.rf.mu.Lock()
			if m.rf.state != Leader {
				m.rf.convertToCandidate()
			}
			m.rf.mu.Unlock()
			t.Reset(m.random())
		case <-m.rf.cancel:
			return
		case <-m.cancel:
			return
		}
	}
}

要理解上面的代码, 首先应该明白timer的工作逻辑。 从Timer提供的最表层的Api来看, timer的使用方法就是New之后就会往系统中添加一个计时器。 用户代码监听timer内的channel,一旦超时时间发生,runtime就会往C中发送一个信号(空结构体), 从而使得用户可以感知到这一事件的发生。

那么从常理来看,t.Stop会终止这个timer,而Reset就会重置这个timer,这是没问题的。 而time包要求只能在已经停止的timer上调用Reset,这也是没有问题的。

但是问题出现在select上, select的原理是将监听的所有事件打乱重排,顺序检查,如果事件发生了,那么进入处理逻辑,如果没有事件发生,且没有default,那么阻塞在这些频道上。

也就是说,进入了其中的一个case之后,其他的事件可能发生了,也可能没有发生。 对于我们raft的逻辑,如果重设事件发生了,那么说明timer应该重置,已经发生的超时事件由于没有竞争成功,应该当做没有发生。我们绝对不希望刚刚转变成follower或者candidate之后(这个时候会调用resetTimer),过期的超时事件仍然被处理,从而在没有新的超时事件发生的情况下转变到下一个任期。 过期超时事件为什么仍然能被处理? 因为超时事件是通过一个缓冲channel发送的,只要channel中还有值,我们就认为超时了。 所以要在channel中有值的时候将值消费掉,防止事件的错误的触发。

于是我们使用 t.Stop()检查timer的状态,如果还没有发生,那很好,t.Stop()返回正确,我们亲手停止了这个timer,所以。 如果t.Stop()返回了错误,那么表明这个timer已经触发了或者已经被停止过了,那么如上面所说,我们应该消费掉channel中可能存在的值。

有些同学可能会用以下的代码完成这个逻辑

if !t.Stop(){
		// drain the channel
  select{
  case <-t.C:
  default:
  }			
}

上面说的是,如果channel里有值,则消费掉,如果没有,进入default然后返回。 但是这其实是个错误的逻辑,因为负责往t.C中发送值的是一个和用户代码完全并行的协程,也没有锁保证逻辑串行化。 也就是说,会出现以下的状况:

  1. 超时,go出一个协程往t.C发送值(信号)
  2. 用户代码调用t.Stop,返回错误,因为timer已经超时触发了
  3. 用户代码进入select,但是此时负责发送值的协程还没有执行,于是进入default。
  4. 负责发送值的协程往频道中发送值,完成任务退出。
  5. 在下一个for中,进入超时处理的逻辑,raft错误地在重置了时间后的瞬间增加自己的任期,变成candidate。

当然,上面描述的其实是一个很难遇到的case,真发生了也不会有太大的问题,不过是重新选举罢了。 解决的方案也很简单,用一个expired变量表示timer中的值有没有被真正处理,如果没有,阻塞地等待并清空t.C,然后进入下面的逻辑。 这样处理可以保证不会出问题。

写完了上面的这些,终于知道为什么Morris推荐不要用Timer了,正确的使用需要考虑的东西确实是有点多,而且和lab本身也没有什么关系。 但是能结合运行时理解了一个组件的工作原理并能正确地运用它也是很美妙的不是吗~

2. 针对Raft的应用场景,如何使用Channel进行通信

2.1 如何在调用Kill()命令后优雅退出所有后台协程?

后台协程分为两种,一种是类似于timeout和heartbeat的,会定时触发,这时候只需要检查标志结束的flag是否设立就可以了。 一种是类似applier的, 一般阻塞在channel或者condition variable上,如果使用channel的话,这时候只需要在raft创建的时候初始化一个零缓冲的cancel频道, 在调用kill()的时候,将这个channel关闭,那么所有监听这个channel的协程都会收到信号, 达到了优雅退出的目的。

2.2 如何更好地利用带缓冲和不带缓冲的两种channel?

channel是CSP在go语言中的实现。

Uber制定的go语言规范中说道,channel的容量要么为0,要么为1。如果容量为0,那么发送方和接受方都会阻塞在频道上,直至可读/可写。

那么这两种channel的用法分别是怎么样的呢? 我以Lab2中的使用场景为例。

  1. Applier。 这个后台协程会监听commitIndex的变化(通过channel)。 在applier没有真正apply(也就是接受channel中的信息进入case分支)之前, 无论发生多少次commitIndex的变化, 对于applier都是没有影响的。 所以解决方案是,使用带1个缓冲的channel,并且在往这个channel中发送信息(也就是signal操作)的时候,使用select带default分支的非阻塞发送方式。 注意用锁实现一些逻辑的并行化。 这样设计的好处是, 发送永远是非阻塞的,并且可以完全保证逻辑的正确。
  2. cancel。 如2.1所说,0缓冲,设计出来就是为了取消的。 这种使用方式在context包的使用中十分常见。

3. 如何处理MatchIndex的计算问题

在Raft中,若你是一名Leader,那么你要负责根据各个server的matchIndex计算是否可以提交新的entry(当然这里还要涉及对待提交的entry的term的判断)。 matchIndex在AppendEntries Rpc返回之后可能会迎来修改, 这个时候应该唤醒等待的committer协程, 由committer判断是否进行commitIndex的修改。

那么committer具体逻辑如何呢? 这里要注意到raft里有很多的属性都是单调递增的,单调递增可以带来很多的便利性。 committer维护一个curMatch,代表目前的最大的匹配值,这个值是可能比commitIndex小的,因为curMatch在leader选举成功的时候是初始化为0的,表示leader不知道其他server的匹配情况。

那么每次出现matchIndex的修改事件,committer都会将curMatch尽力加到最大,如果哪一次无法继续增加curMatch,则开始比较commitIndex和curMatch,看能否改变commitIndex。 下一次改变事件来到的时候直接从当前值开始。

这里还有一个小的优化, 就在比较commitIndex和curMatch这里,可以对commitIndex做一个缓存,而不是每次都加锁读。 这也是利用到二者都是单调递增的特性。

4. 如何减小锁的粒度

我们不可能每次有操作都直接加上一把全局大锁,需要适当地拆分锁以减少锁的抢占,这里提供几个思路。

  • Leader的nextIndex和matchIndex两个数组和其他的raft状态是没有太大关系的,并且每次leader选举成功都会重新初始化这两个属性。 我的做法是把leader整个拆分出来,带着自己的状态,锁,方法还有一个raft的指针。 这样,读写nextIndex和matchIndex的时候完全不用抢占raft的大锁。 也就是将资源分组加锁了。
  • 分桶加锁,这里我没有使用,但是如果server的数量比较多的话,matchIndex的读写应该会比较严重的锁抢占。 可以用哈希函数将matchIndex数组的读写分散到几个桶里,每个桶有自己的锁,可以减少抢占。这也是十分十分常见的优化了。

其他的一些感悟

从这个Lab中获得的经验和思考还有蛮多的,但都不太成体系,就在下边零零散散地写一点吧。

  • 这次的Lab一个很锻炼我的地方就是,我真正地在编程中考虑了容错,考虑各种可能发生的情况,然后在心里用同步原语将它序列化,这是我之前很少做的。 或者说很少在内存中做,因为以前多多少少还是有涉及一些存储组件并发读取写入带来的一致性问题的。
  • 第一次大量运用日志进行debug,还真能找到不少问题。这让我一个从来只使用IDE找bug的人感到十分奇妙
  • Raft需要做的判断很多,一不小心就写成了if else地狱。 我的解决方法是,灵活运用go的defer函数在返回前统一处理,并应用提前退出的模式。 提前退出配合一些必要的注释,可以带来很强的代码可读性,我觉得这对我后期梳理逻辑,debug都是很重要的。
  • 适当地拆分状态。 我在网上看的一些实现,都是将所有的状态,函数写在raft的主类里。但是我自己还是选择将leader,timeout,election等的实现从主类里拆分出来,并使用不同的文件存放。 这样下来在编程的过程中,封装,命名,寻找逻辑所在处等都变得很清晰,(对我来说)是大大增加了可读性的。
  • 当然了,对Raft的感悟还是有一点的,但是我这几天都沉浸在实现中了,没来得及好好回味。 明天再看看吧!