MIT6.824 spring21 Lab2B+2C总结记录
写在前面
这次借着写Lab2C的功夫,把先前的bug修了一下。说是修bug,实际上重写了很多逻辑。现在的版本我认为可以算是完美。(Lab2A 2B 2C的testcase各跑50次,全部PASS)。
这次的修改很大程度上是受了LAB2A/2B代码讲解的那堂课的启发。整体框架还是延续了我之前的思路,但是几个容易引起bug的设计都完全重写了。一些“拧巴”的代码也都捋顺了。
总得来说,我对这次改进非常满意。
刚刚开始写博客。如果你在看,请多多包涵!
github仓库地址:https://github.com/sun-lingyu/MIT6.824-spring21
goroutine之间的通信
这次修改解决的最大痛点就是通信问题。
下面说说两个被放弃的实现思路:
使用channel
按照先前的实现,每当RPC Handler处理过程中发现自己的currentTerm过时,需要终止leader/candidate的工作,并转为follower状态时,都需要通过channel通知leader/candidate。
这个实现有以下几个问题:
首先,leader/candidate函数中要反复获取/放弃锁,以便让RPC Handler能够获取锁、处理RPC请求并可能通知leader/candidate终止。这增加了leader/candidate函数实现的复杂性(经常要使用select-default语句监听终止channel)。而且反复的获取和放弃锁会导致每次获取到锁时都要重新获取被锁保护的变量(因为它们的值可能发生了变化)。
其次,这样做很容易引起race condition。在原先candidate的实现中,RPC handler对终止channel的写入是没有获取锁的(新开了一个goroutine用于发信号)。这对leader不会有太大影响(leader一旦被选举成功,会保持leader状态,直到)。但是对于candidate,情况有很大不同:candidate每过一段时间会重新选举(reelection)。我实现reelection是通过重新为rf.candidateAbortChannel赋值。但是极有可能发生这种情况:同时有RPC Handler向channel中写入终止信号。这就导致了candidate可能出现异常的终止。而且,在实际测试中,这种情况被race detector发现的概率不高。虽然发生了异常终止,但是难以察觉。
第三,我在2A的博客中提到,只有leader和candidate可以修改state,这是不好的。采用这种实现,Handler不能及时修改状态。这就导致可能有多个handler协程向终止channel发送信号(但只有一个信号能够被接受),导致thread leakage。而且这种方式最大的问题是不直接、逻辑不合理,与Raft paper中的描述不完全相符。这产生了不必要的理解成本,也为可能的bug埋下了隐患。
使用condition variable
用channel进行通知是丑陋的。
在lab2B中,我尝试用condition variable对leader的终止进行了重写。详细的实现我不做赘述(因为我还是不满意),感兴趣的可以查看我仓库的Raft-2B分支。主体思路是RPC Handler对leader的通知改为使用cond.Signal()。leader的主协程首先创建 len(rf.peers)-1 个子协程(用于向其他server循环发送AppendEntries),并在创建任务完成后执行cond.Wait()等待终止。一旦接收到终止信号,则通过buffered channel通知子协程终止。
当然,这种方式也是有问题的。(只能通过47/50次测试)
首先,如果一个进程在当选leader后延迟了一段时间才执行到cond.Wait()处,并且在这期间有RPC Handler执行了signal,那么这个signal将被错过。也就是说,我们不能保证leader可以及时终止。
其次,leader仍然需要通过channel通知子协程。子协程执行的函数实现仍然比较复杂。
第三,我没有修改“只有leader和candidate可以修改state”这个问题。而是在handler中加入了“当要终止leader/candidate时,循环执行:先放弃锁,睡眠一小段时间,再检查state是否已经变为follower。若没变,则再通知终止。”这个逻辑可以看作是对原问题的小修小补。但很遗憾,它并不能解决问题。
第一,在实际测试中,往往要循环相当长的次数,才能发现state改变了。
第二,在RPC Handler中放弃再获取锁,很多状态已经发生了变化。如果handler还按照之前的逻辑继续,会发生错误。
从错误中学习
在思考了上面两种实现的问题之后,我陷入了沉思。
幸运的是,Frans Kaasoek教授的代码给了我很大的启发。
虽然我的实现和教授的实现有很大差别,但是很多重要的点非常值得学习:
第一:不能滥用通过channel的通信。channel很容易导致阻塞或者thread leakage。当你把channel用的很复杂很奇怪的时候,就应该及时停下来想想了。实际上,教授的代码里面核心逻辑都没有使用到channel。
第二:每次发送RPC request,都启动一个新的协程。且发送RPC request的协程可以直接处理reply。这样的好处是无需通过channel在协程之间传输reply。而且避免了两次发送相互干扰(可能上次发送迟迟没有得到reply,耽误了下一次发送)。
第三:教授的代码中经常利用”新创建的协程不会拥有锁“这个特点进行处理。因此可以让一个父协程在拥有锁时创建新的子协程(例如用来发送RPC request),之后继续向下执行处理逻辑。
新的实现
在吸收了上面的启发之后,我对实现进行了几个重要改进:
1. 取消leader/candidate代码中对AbortChannel/cond的监听。而是改为在需要执行关键逻辑之前先判断以下条件:
!rf.killed() && rf.currentTerm==currentTerm && rf.state==leader/candidate
其中currentTerm是在刚刚进入leader/candidate状态时保存的rf.currentTerm
2. 在RPC Handler中可以直接修改rf.state,不用通知leader/candidate。
3. 每次需要发送新RPC request时,都发起一个新的协程。当这个新携程收到reply后,尝试获取锁并直接对reply进行处理。
4. leader退出时无需向各个子携程发送终止信号。直接将rf.state改为follower即可。
5. 在rf.timer fire后,不做额外判断,直接创建新协程执行rf.candidate()函数。而在rf.candidate()函数的最开始,获取锁并检查rf.state==leader。若是,则放弃锁并返回。原先的实现是在rf.ticker()函数中先获取锁并检查这一条件,再在rf.candidate()函数再次获取锁并执行candidate的初始化(包括设置rf.state=candidate)。这样可以避免在rf.ticker函数和rf.candidate函数中分别获取锁带来的问题:可能在rf.candidate中获取锁的时候,raft的状态已经发生了改变。
不难发现,上面的改进核心就在于,函数可以通过rf.currentTerm和rf.state唯一确定什么是合法的状态。这样减少了协程间沟通的成本。只需要获取锁并检查这两个值就可以了。
代码实现就不贴出来了。有兴趣的可以移步github仓库。
额外的,我对代码进行了整合和复用,把一些逻辑用函数进一步封装,增加可读性。而且,经过改进后的leader和candidate的代码结构很相似(虽然leader更复杂),便于理解和维护。
其他实现细节
在这一部分中,我将再展开一些实现中的细节。
Start函数
这一部分实际上是在2B中实现的。但是我觉得没必要为2B单独写一篇博客。就在这里统一描述了。
我对Start的实现核心是condition variable(名为rf.newLogCome)。
【关于condition variable,有一个小点值得记录。
在初始化condition variable时是可以指定与它相关联的锁的,如下:
】
有两个地方可以执行rf.newLogCome.Signal():
1. 有新entry。当Start函数被调用时,先获取锁,添加log entry,并执行rf.newLogCome.Signal()
2. heartbeatTimer fire。leader函数中会创建一个heartbeatTimer,周期性fire,并执行rf.newLogCome.Signal()
另一边,只有一个地方可以执行rf.newLogCome.Signal():
在leaderProcess函数中,若没有新的log entry,则执行rf.newLogCome.Wait()【raft_leader.go:96行】,等待新的entry到来或者heartbeattimer fire。
若leaderProcess函数中该协程被唤醒,但是发现没有新的log entry,那么它将发送一个空的心跳包,然后再次执行rf.newLogCome.Wait()
这样做的原理在于:利用“被唤醒时是否有新entry”这个条件,我们可以区分heartbeatTimer fire和有新entry这两种情况。
发送log时需要等待
当leader向其他server发送非空的AppendEntry RPC Request时,需要注意不能连续发送。
在每发送一个这样的RPC请求后,都需要等待一段时间,等待负责发送的协程收到回复并处理。
在我的实现中,为了简单起见,每发送一个这样的RPC请求后都要睡眠(time.Sleep)一段时间。
一开始我设置的时间是10ms。但是这个时间太短了(不足以收到其他server的回复并处理),导致leader发送大量相同的RPC请求。最终发起的子协程数量过多导致程序崩溃。
经过测试发现,典型的接受到回复的时间大约在10-20ms。因此我将睡眠时间改为heartbeatInterval的一半,即100ms。解决了该问题。
Figure8
论文中的figure8展示了一种特殊的情况。
它告诉我们,即使leader将一个log entry成功记录到大多数机器上,也不能保证这个log entry成功commit。
实际上,如果这个log entry的term不等于leader的当前term,则不能认为这个log entry成功commit。
(具体原因见论文)
因此,在commit时应对该条件加以判断。
args中的log entry
一个值得注意的点是:
若RPC调用的参数args中包括了一些log entry,那么要将这些entry复制到args中。
也就是说,要写成下面这种形式:
而不是单纯地写为:
如果采用了下面一种写法,args中的Entries实际指向的内存将是rf.log。由于我们发送RPC request时是不加锁的,这会导致在没有锁保护的情况下对rf.log进行读取。这样将会发生data race。
针对AppendEntry得到Success==false的optimization
要通过2C,必须实现论文中提到的这个optimization:
在leader发送的AppendEntry被follower拒绝后,要将nextIndex向前前移一整个term,而不是仅仅前移1。
这个优化的关键在于,leader要对三种情况进行区分:
1. follower的log太短,还没有达到AppendEntriesArgs.PrevLogIndex这个位置
2. follower发回的 “冲突index处的log Term” 大于 leader的“冲突index处的log Term”
3. follower发回的 “冲突index处的log Term” 小于 leader的“冲突index处的log Term”
第一种情况,直接把follower的nextIndex前移置follower返回的“它的log长度”处
对于第二种情况和第三种情况,需要稍作推理。结论是分别要"go back to last term of the leader" 和 "go back to last term of the follower"
这里只要想到了对第二种情况和第三种情况区别处理,就应该能够想明白该怎样做。
写在最后
这次Lab的实现要点在这里我都做了详细的解释说明。主要是防止自己日后忘记。
实现一个完美的raft不容易(现在网上很多人都不能够做到!)。我达成这个成就,靠的是大量时间的投入、思考和反复的迭代更新。
写下这么多代码后,我越来越觉得:工程实现有很多细节问题需要注意,而这些都是需要在实战中积累的宝贵经验!