MIT6.824 spring21 Lab2A总结记录

写在前面

这是我第一次写博客,主要是为了记录自己在做MIT6.824 Lab的过程中的实现设计,防止以后忘记。

其实之前做完6.828的时候就想写了来着,但是还是由于种种原因搁置了(其实是因为懒。。。)

这次下定决心,主要是因为完成这次实验花了相当久的时间,修改了好几版才想出现在的版本。

在基本逻辑实现之后又排除掉了很多race和goroutine leakage的问题。总觉得不记录下来,以后全都忘掉太亏了(😂)

我跑了50次test,都通过了。代码正确性应该没有问题。但是很多地方都有冗余(毕竟是一遍一遍修改出来的。。。),还可以改的更精简。

如果你在看,请多多包涵!

整体结构

---------------------------------------------注意:整体结构在2B/2C中有较大调整!-------------------------------------------------------

主要调整的部分是蓝色线消息通知的部分。详见Lab2B/2C总结记录。

实现的整体结构大概就是这样。图中的每个框框都表示一个goroutine,对应着一个函数。

左边的两个框框是两个RPC Handler。

红色的框框(ticker)表示说这个goroutine不会终止。它将在死循环中永远执行。

蓝色的messge表示channel。两个RPC handler通过*非阻塞的*channel,向elect()和leader()这两个gouroutine发送终止信号。

绿色的字表示相关的箭头的发生时机,这个后面会加以详细解释。

 

状态(State)

在开始描述各个函数之前,让我先来讲一讲Raft状态的表示。

为了方便起见,我在Raft struct中维护了一个枚举类型的状态变量state。它一共有三种取值:follower/candidate/leader.

值得一提的是,这个变量的维护(防止race)是一件比较麻烦的事情。在这次实验的整体逻辑完成之后,我也的确花了不少的时间来找出潜在的race。

(虽然我觉得我把race都找出来了,但可能实际上还是有。。。分布式嘛。

 

解决race问题的主要指导思想就是:

在raft执行的整个过程中,只有elect和leader这两个goroutine可以修改rf.state。

 

这里主要想记录一下go中枚举类型的表示方法。

golang里面是没有枚举变量这种东西的。所以要表示枚举变量,就需要通过间接的方式实现。

上面图中的代码是写在所有函数定义最前面的。这里定义了serverState类型(int的别名)。

在const声明中,我们看到了iota这个表达式。它可以从1开始自动递增。因此这里follower就等于1,后面的以此类推。

在定义了枚举类型之后,我们可以继续向下了。

RPC Handler

首先来挑简单的说。两个RPChandler是Hint中推荐先实现的两个函数。因此我也从这里开始说起。

 

两个handler有一些共性。

首先由于它们都需要密集地读取包括rf.currentTerm在内的多个共享状态,因此我把他们设计成在最开始获取rf.mu这把锁,并在执行的全过程中持有锁。

其次,它们都需要在reply中给出rf.currentTerm。(注意rf.currentTerm可能需要在处理的过程中被修改。reply中的值应该是处理完成后的最终值。)

最后,它们都需要根据args.Term和rf.currentTerm之间的大小关系做不同的处理。

 

综合这三点,两个RPC handler的结构大致如下:

 

各种case中具体的处理流程这里就不展开了。详细的东西可以去看论文和我具体的代码实现。

值得讲的是它们如何发送message给正在执行leader()和elect()的goroutine。(也就是整体结构图中的蓝色message)

这里以 args.Term > rf.currentTerm 的情况为例。此时我们收到的RPC请求方具有更高的Term。我们应该立即接受这一Term,且转为follower。

 

转换为followe应该区分两种情况:rf.state==leader和rf.state==candidate。

对于这两种情况,需要将终止信号分别告知leader和elect两个goroutine。

 

 

 注意到这里采用了非阻塞的发送(用一个新建的goroutine去发送messge。)这样可以保证RPC handler及时退出,放弃rf.mu锁,让leader和elect能够获取到锁,继续执行。

在我最开始的实现中,没有使用非阻塞发送。这样将导致死锁😛

 

那么是否需要担心这里出现goroutine leak呢?如果leader或者elect不再监听对应的channel,那么这两个新建的goroutine可能阻塞在对应的channel上。

答案是不需要。因为我们可以在leader和elect函数的实现中确保:

如果rf.state==leader,则leader一定会监听leaderAbortChannel。如果leader因为其他原因退出(如leader收到了Term更大的reply),那么保证leader会尝试drain the channel,然后获取rf.mu,修改rf.state=follower。

如果rf.state==candidate,则完全类似。

 

这样,我们利用rf.mu将RPC handler与修改rf.state的过程串行化,确保了如果rf.state==leader/candidate,且RPC handler向channel中发送了终止信息:那么leader/elect一定会排空这个信息,让发送goroutine正常终止。

这里比较tricky,需要仔细思考一下!

 

Ticker

ticker是最特殊的函数。它将持续执行,永不终止。

在Hint中,助教提示我们不要用time.Timer或者timer.Ticker,因为它们容易导致问题。助教推荐使用time.Sleep。

但是在实际操作中我发现time.Sleep不能很好地实现reset功能。

(关于使用time.Sleep,我觉得可以这样实现:

即:把一次计时打碎为多次计时。

再者,time.Timer等的使用还是有必要学习一下的。(谁喜欢重复造轮子呢😂

因此我使用了time.Timer实现计时器。增加了一把锁rf.timerLock用于重置timer(重置过程是互斥的)

每次Timer fire,都执行timer.Reset内置函数重置时间。

 

但是如果timer没有fire,reset timer的实现有必要加以注意。

此时必须要先stop timer,排空其channel,再reset timer。而且这一系列动作应该是互斥的(要加锁)。

这一套流程有固定的套路。但是这个套路仍然不能保证不发生race。这恐怕就是助教所说“容易发生错误”的原因。

详细的分析可以看https://zhuanlan.zhihu.com/p/133309349,这里不展开。

 

 

 

一个需要注意的点是:

如果timer fire,且当前rf.state==candidate,我们需要重新选举。

这表示我们需要放弃之前的那次选举,也就是让上一次的elect goroutine退出,且不再影响rf的任何状态(不再与RPC handler通信、不再修改rf.currentTerm等)。

可以用两个比较简单的机制来实现这两件事情:

1. 每次发现需要重新选举,都更换rf.electAbortChannel。(创建一个新的channel,赋值给它)

2. 更换之前,向之前的rf.electAbortChannel中发送一个false信号,告诉前一个elect终止,且退出时不要影响rf的状态(如修改rf.currentTerm)。

【注:正常的终止信号是true。这里发false,可以区分。】

 

Elect

elect函数仅由ticker调用(通过创建新的goroutine)

在elect中,我们通过创建多个send goroutine的方式并发地发送RequestVote请求。

这些send goroutine执行sendRequestVote函数,并将获得的reply发送到voteChannel。另一端,elect循环监听voteChannel。

 

elect函数的逻辑

elect函数的执行逻辑大致如下:

首先并发地发送requestVote请求

然后循环接受reply。

 

reply中的第一个if语句值得注意:

在elect函数刚刚执行时,我们记录了当时的rf.currentTerm。

在这里我们检查rf.currentTerm是否和当时记录的值相同。

如果不同,这代表RPC handler已经修改了rf.currentTerm。我们应该立即退出。

【当然,我觉得这里的实现总有些怪怪的。好像不加也可以。但是既然testcase都能过,就懒得改了😂】

 

最后,每次获取下一个reply时,如果有RPC handler发送了终止信号,则退出。

 

生产者goroutine的退出

elect函数中的实现是一个“典型的多生产者-单消费者”模式。

在讲elect函数的退出机制之前,我想先单独讨论下:对于“多生产者-单消费者”,多个生产者如何退出的问题。

我们知道,生产者向voteChannel发送reply是阻塞的。若消费者不再监听voteChannel,则会导致goroutine leak。

 

第一个自然的想法是把消费者的发送设置为非阻塞。即:

 

 

 但是这种方式的问题是:可能消费者忙于处理上一个到来的reply(由其他生产者生产。)那么这个reply将drop。

 

第二个想法是消费者循环执行exactly len(rf.peers)-1次(所有的server数量减去我们自己),即读取voteChannel exactly len(rf.peers)-1次。

这种想法较难实现。这导致elect在收到退出信号时不能及时退出,增加了不必要的实现复杂性。因此我没有采取这种方式。

 

实际上这种模型有一个更好的终止办法:使用有buffer的channel。

(虽然professor明确说不建议使用buffered channel,但是我觉得应该灵活变通😛

开辟一个buffer大小为len(rf.peers)-1的terminateChannel。

当终止时,elect向这个buffer中发送len(rf.peers)-1个signal。

将生产者发送处的代码改为:

 

即:发送reply,或从terminateChannel读取。

这样可以很好的防止阻塞,从而避免goroutine leak。

 

elect函数的退出

elect函数的退出逻辑值得描述一下。虽然没有什么新的东西,但是上面提到的一些东西要在这里实现。

这里以reply.Term>rf.currentTerm的情况为例:

 

首先,需要按照【生产者goroutine的退出】一节中的描述,向terminateChannel中发送len(rf.peers)-1个终止信号。

其次,需要按照【RPC Handler】一节中的描述,drain electAbortChannel。

最后,需要按照【ticker】一节中的描述,检查drain electAbortChannel得到的值(如果有)是否为false。若为false,则不修改任何状态,直接退出。

 

下面展示了一个典型的elect退出情况

 

 Leader

leader函数仅由elect调用(通过创建新的goroutine)

在leader中,我们同样通过创建多个send manager的方式并发地发送RequestVote请求。

这些send manager再周期性创建send goroutine,执行sendAppendEntries函数,并将获得的reply发送到heartbeatChannel。另一端,leader循环监听heartbeatChannel。

(与elect很相似。不过多了一级goroutine)

 

leader函数的逻辑

leader函数的执行逻辑大致如下:

 

 

 其实和elect在整体结构上并没有任何不同。。。

(但是细节上还是有需要注意的地方!😂

 

send manager

首先要说的就是send manager。

你可能会疑惑,为什么要引入这个东西?像elect一样不好吗?

先别急。

我们知道,leader是要周期性地向所有其他server发送心跳的。

如果我们不采用send manager:在send manager中,我们故技重施(仿照ticker):创建一个time.Timer。每当timer fire,创建一个send goroutine发送heartbeat。

 

看起来似乎很美好。

 

但注意,这种实现会出问题!

我们知道,接口函数sendAppendEntries是要等待一段时间才会返回reply的。(这个等待时间可能很长,甚至远远超过heartbeat的发送周期)
如果sendAppendEntries迟迟不能返回reply,那么发送实际上会阻塞。我们将不能按周期发送heartbeat!
(这个bug我找了好久/(ㄒoㄒ)/~~)
 
如果在执行sendAppendEntries的时候创建新的goroutine,我们可以避免这个问题。
如果我们添加一个额外的计时器innerTimer,经过半个heartbeatChannel之后fire,并kill sendAppendEntries goroutine,认为这次发送失败。
我们就可以在下一个原来的timer fire的时候,正常发送心跳包。(没有阻塞)
我们可以把上面实现中的
 
替换为
 
 
注意这里两个select语句的妙用。
既实现了innerTimer fire的提前终止,也防止了goroutine leakage。

 

 

 通过这种方式,我们实现了heartbeat的周期性发送。

 

leader函数的退出

leader函数的退出流程与elect相似。

但是leader相比于elect,有两个简化点:

1. leader不会发生elect中的"re-elect"现象(leaderAbortChannel中的信号只能为true,无需检查)

2. leader如果发生状态改变,一定是变为follower

因此我们可以将终止时的逻辑抽象成函数:

 

 (注意:drain leaderAbortChannel的过程并不包含在这里。至于原因,可以看代码。一句话说不清楚。)

 (注意:leader转变回follower,要resetTimer)

 

总结

至此,代码实现中的关键点总结完毕。

如果对raft选举的流程有疑问,可以仔细研究下论文。

本篇博客的重点不是选举逻辑,而是避免race,避免thread leak,避免deadlock,以及一些设计哲学。

我感觉有很多地方写的都不好,如果你在看,还请多多包涵!

posted @ 2021-03-11 20:07  sun-lingyu  阅读(513)  评论(0编辑  收藏  举报