This commit is contained in:
renjue
2026-02-25 23:02:08 +08:00
parent 7e5eb65220
commit 8be50722e8
33 changed files with 5336 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
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 <each peer> {
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 以来是否已改变。