MIT6.824-Lab2A

 阅读约 10 分钟

Lab2A

Lab2A的地址:https://pdos.csail.mit.edu/6.824/labs/lab-raft.html

Lab2A需要完成Raft协议中的Leader Election部分。
按照论文Figure2实现如下功能:

  • 初始选举
  • Candidate发布RequestVote rpc
  • Leader发布AppendEntry rpc, 包括心跳
  • server的状态转换(Follower, Candidate, Leader)

实验提示:

  • raft.go添加必须的状态。
  • 定义log entry的结构。
  • 填充RequestVoteArgsRequestVOteReply结构。
  • 修改Make()以创建后台goroutine, 必要时这个goroutine会发送RequestVoteRPC。
  • 实现RequestVote()RPC的handler。
  • 定义AppendEntriesRPC结构和它的handler。
  • 处理election timeout和"只投票一次"机制。
  • tester要求heartbeat发送速率不能超过10个/s, 所以要限制HB发送速率。
  • 即使在split vote的情况下,也必须在5s内选出新的leader, 所以要设置恰当的Election timeout(不能按照论文中的election timeout设置为150ms~300ms, 必须不大不小)。
  • 切记, 在Go中, 大写字母开头的函数和结构(方法和成员)才能被外部访问!

测试

lab 2A有两个测试:TestInitialElection()TestReElection(). 前者较为简单, 只需要完成初始选举并且各节点就term达成一致就可以通过. 后者的测试内容见Bug分析部分。

构思

经过不断重构, 最后的程序结构如下:

  • MainBody(), 负责监听来自rf.Controller的信号并转换状态.
  • Timer(), 计时器.
  • Voter(), 负责发送RequestVote RPC和计算选举结果.
  • APHandler(), 负责发送心跳,并且统计结果.

四个通过channel来通信(自定义整型信号).
先前的设计是MainBody() 监听来自若干个channel的信号,后来了解到channel一发多收, 信号只能被一个gorouine接受, 可能会有出乎意料的后果.
所以修改MainBody主循环, 使得主循环只监听来自rf.Controller的信号, 然后向其他channel中发送信号(MPSC).

Bugs分析

2019-10-30

TestInitElection()能成功通过, 但是TestReElection()偶尔会失败, 于是笔者
TestReElection()添加一些输出语句, 将其分为多个阶段, 方便debug。每个阶段,测试函数都会检查该阶段内是否不存在leader, 或者仅有一个leader。

    选出leader1
# 1 ------------------------------
    leader1 下线
    选出新leader
# 2 ------------------------------
    leader1 上线, 变为follower
# 3 ------------------------------
    leader2 和 (leader2 + 1 ) % 3 下线
    等待2s, 没有新leader产生
# 4 ------------------------------
    (leader2 + 1 ) % 3 产生
    选出新leader
# 5 ------------------------------
    leader2 上线
# 6 ------------------------------

这里说明一下, 测试函数TestReElection()使用disconnect(s)把节点s从网络中下线。节点本身没有crash, 仍然正常工作。使用connect(s)将会恢复节点与网络的通信。

测试在第3-4阶段时偶尔会失败. 分析发现: 此时系统中不应该存在leader, 但是仍有某个节点声称自己是leader.

笔者进行多次测试发现如下规律:

test    leader1  leader2  (leader2 + 1) % 3
success   0         2         0
failed    0         1         2
success   2         1         2
success   1         0         1
failed    0         1         2
..

只有当 (leader2 + 1) % 3 == leader1时才测试成功。为什么呢?

在3-4阶段时,节点leader2(leader2 + 1)%3都被下线。
条件l1 == (l2 + 1) % 3使得在第三段时, leader1leader2(即1-3阶段产生的两个leader)都下线, 所以此时网络中没有leader了.

如果不满足这个条件, 那么唯一一个在网络中的节点就是leader1了。leader1迟迟不变为Follower, 会导致测试失败.

通过分析日志发现: leader1重新加入网络时, 没有收到leader2的心跳(如果收到,会使leader1变为follower), 自己也没有发送心跳, 也没有收到candidate的RequestVote RPC. 看起来是被阻塞了。

于是笔者修改TestReElection()函数, 在2-3和4-5阶段让测试函数睡眠若干个election timeout:

fmt.Println("2-------------- ")
...
time.Sleep(time.Duration(2 * ElectionTimeout) * time.Millsecond)
// ...
fmt.Println("3-------------- ")
// ...
time.Sleep(time.Duration(2 * ElectionTimeout) * time.Millsecond)

笔者发现, 经过更长时间(12s), old leader最终都得知了存在更大的Term从而变为follower. 在修改后的测试中, 每次测试均正常通过.

笔者在此阶段做的工作有:

  • 插入输出语句, 打印更详细的日志。
  • 大量执行测试, 总结测试成功失败的规律。
  • 结合规律和测试逻辑来判断失败场景。
  • 修改测试函数,使其等待更长时间。

笔者发现问题不是在死锁,于是认为问题是线程饥饿所致。、

查阅资料发现, 有人提到当某些goroutine是计算密集的, 会导致饥饿。可是笔者的实现中,goroutine执行少量逻辑判断后就进入睡眠阻塞,与计算密集毫不相干。

此外检查执行环境, 确认全部核心都投入使用。

2019-11-08

笔者终于找到了问题的根源, 并且找到了解决办法.
笔者在今天突然意识到rpc调用本身的问题:

rf.peers[server_index].Call("Raft.AppendEntry", &args, &replys[server_index]) 
                            

此处的Call(), 是实验代码中自带的、用来模拟执行RPC的函数, 如果RPC正常返回, 则该函数返回true, 如果超时则返回false.

笔者在Lab1中也用过类似的函数, 所以并没有怀疑此函数.

然而笔者突然意识到,该函数的代码注释并没有说明等待多久才算超时!

很可能Call的超时是导致测试失败的关键. 于是笔者翻阅源码,发现Call这个函数自身并没有定时器,它依赖于Endpoint(模拟端点设备的结构)中的channel!也就是说channel何时返回结果,它就什么时候返回。

笔者继续翻看代码,突然一个7000一闪而过,笔者定晴一看,不禁背后一凉,心中大惊:

// src/raft/labrpc/labrpc.go
func (rn *Network) ProcessReq(req reqMsg) {
    enabled, servername, server, reliable, longreordering := rn.ReadEndnameInfo(req.endname)

    if enabled && servername != nil && server != nil {
        //... 这里也是设置超时参数, 非重点
    } else {
        //...
        if rn.longDelays {
            ms = (rand.Int() % 7000) // <--------------------- Lab2A下, 最多会等待7s!
        } else {
            ms = (rand.Int() % 100)
        }
        // 等待一段时间然后向channel发送信息。
        time.AfterFunc(time.Duration(ms)*time.Millisecond, func() {
            req.replyCh <- replyMsg{false, nil}
        })
    }

}

为什么Lab2A下的超时设置是7000ms呢? 会不会跳到其他分支?不会!

首先测试开始时会配置测试环境, 其中就有这样一句:

// ...config.go: line: 84
cfg.net.LongDelays(true)

这里设置了长延时参数。
其次, 在测试函数TestReElection()中, .disconnect(s)会下线节点s, 使得s的网络请求总是超时. 在代码层面, 就是把enabled这个布尔值设为false

综合上面两点, .disconnect(s)s的超时设置将跳转到LongDelays这个分支, 从而rpc等待时间最高会达到7000ms, 这将会导致old leader长时间阻塞在rpc调用上, 不能及时处理到来的RPC, 也就没办法及时变为follower, 最终导致测试失败。

那该怎么办? 知道了原因就好办了: 在APHandler处设置超时监控, 超时或者rpc按时返回则停止等待.

修复之后,再也没见过test fail了, 真开心。

总结

笔者虽然按照论文来实现,但在实现过程中,屡屡遗漏某些细节,不得不花大量时间测试(比如rpc中携带比自己还大的Term,自身就要立刻变为follower)。

此外,对于多线程+定时器这样的场景,基本不能用断点调试的办法。打日志,分析日志几乎是唯一路径。分析日志也挺考验思维逻辑的。

这个Debug过程给笔者最大的教训是, 不要盲目信任别人的代码和承诺

阅读 165更新于 11月26日
推荐阅读
目录