Raft 加锁建议 若你在 6.824 的 Raft 实验里不知如何用锁,下面是一些可能有用的规则和思路。 规则 1:只要有多于一个 goroutine 使用的数据,且至少有一个 goroutine 可能修改该数据,这些 goroutine 就应使用锁来防止同时使用该数据。Go 的 race detector 在检测违反此规则方面表现不错(但不会帮助下面几条规则)。 规则 2:只要代码对共享数据做一连串修改,且若其他 goroutine 在这串修改中途看到数据会出错,你就应在这整串操作外加锁。 示例: rf.mu.Lock() rf.currentTerm += 1 rf.state = Candidate rf.mu.Unlock() 让另一个 goroutine 只看到其中一次更新(即旧 state 配新 term,或新 term 配旧 state)都是错误的。因此我们需要在这整串更新期间持续持有锁。所有使用 rf.currentTerm 或 rf.state 的其他代码也必须持有该锁,以保证所有访问的互斥。 Lock() 和 Unlock() 之间的代码常被称为“临界区”(critical section)。程序员选定的加锁规则(例如“使用 rf.currentTerm 或 rf.state 时必须持有 rf.mu”)常被称为“加锁协议”(locking protocol)。 规则 3:只要代码对共享数据做一连串读(或读和写),且若另一 goroutine 在这串操作中途修改数据会出错,你就应在这整串操作外加锁。 一个可能在 Raft RPC handler 中出现的例子: rf.mu.Lock() if args.Term > rf.currentTerm { rf.currentTerm = args.Term } rf.mu.Unlock() 这段代码需要在这整串操作期间持续持有锁。Raft 要求 currentTerm 只增不减。另一个 RPC handler 可能在另一个 goroutine 中执行;若允许它在 if 判断和更新 rf.currentTerm 之间修改 rf.currentTerm,这段代码可能最终把 rf.currentTerm 减小。因此必须在这整串操作期间持续持有锁。此外,所有对 currentTerm 的其他使用都必须持有锁,以确保在我们临界区内没有其他 goroutine 修改 currentTerm。 真实的 Raft 代码需要比这些示例更长的临界区;例如,Raft RPC handler 通常应在整个 handler 期间持有锁。 规则 4:在持有锁的情况下做任何可能等待的事通常不是好主意:读 Go channel、向 channel 发送、等待定时器、调用 time.Sleep()、或发送 RPC(并等待回复)。一个原因是你可能希望其他 goroutine 在等待期间能推进。另一个原因是避免死锁。设想两个节点在持锁时互相发 RPC;两个 RPC handler 都需要对方节点的锁;两个 RPC handler 都无法完成,因为各自需要的锁正被等待中的 RPC 调用持有。 会等待的代码应先释放锁。若不方便,有时可以单独起一个 goroutine 去做等待。 规则 5:在释放锁再重新获取锁时,要小心对跨这段间隔的假设。一种常见情况是为了避免持锁等待。例如,下面这段发送 vote RPC 的代码是错误的: rf.mu.Lock() rf.currentTerm += 1 rf.state = Candidate for { go func() { rf.mu.Lock() args.Term = rf.currentTerm rf.mu.Unlock() Call("Raft.RequestVote", &args, ...) // handle the reply... } () } rf.mu.Unlock() 这段代码在单独的 goroutine 中发送每个 RPC。错误在于 args.Term 可能与外层代码决定成为 Candidate 时的 rf.currentTerm 不一致。从外层创建 goroutine 到 goroutine 读取 rf.currentTerm 可能过了很久;例如可能经过多个 term,该节点可能已不再是 candidate。一种修复方式是让创建的 goroutine 使用外层代码持锁时复制的 rf.currentTerm。类似地,Call() 之后的回复处理代码在重新获取锁后必须重新检查所有相关假设;例如应检查 rf.currentTerm 自决定成为 candidate 以来是否已改变。