这节课主要是讲编程方法。从 Go 内存模型开始,讲了 Go 协程并发时容易出现的问题,一些更优雅的处理方法,最后讲了 Lab 2 构建 Raft 中的一些问题和 Debug 技巧。
建议配合上一篇博客 Go 内存模型食用。
文章目录
Go 协程使用匿名函数的问题
匿名函数(闭包)中进行传参。Go 除了 Map 和 Slice 都是传值,即创建一个变量的副本。
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(x int) {
sendRPC(x)
wg.Done()
}(i)
}
wg.Wait()
}
func sendRPC(i int) {
println(i)
}
打印结果一切正常:
0
1
3
4
2
闭包中可以引用作用域任何变量而不用传参的,但是在协程中引用外部会改变的变量就会出现并发安全问题:
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
sendRPC(i)
wg.Done()
}()
}
wg.Wait()
}
func sendRPC(i int) {
println(i)
}
这里原因是协程中还没打印,for 循环就结束了。
5
5
5
5
5
之后的 Lab 中在使用循环创建协程时,尽量使用传参。
周期性地做某些事
最简单的方式:直接在无限循环中写一个函数即可。
func main() {
time.Sleep(1 * time.Second)
println("started")
go periodic()
time.Sleep(5 * time.Second) // wait for a while so we can observe what ticker does
}
func periodic() {
for {
println("tick") // TODO
time.Sleep(1 * time.Second)
}
}
如果想要做定时任务,知道某件事发生后停止。
例如定期发送心跳,知道调用关闭信号退出协程。
一般会有一个函数调用检查是否关闭,就像 Lab 1 中循环调用 m.Done()
看是否结束 Coordinator 一样。例如在 Lab 2 中可以用 rf.killed()
检查 Raft 是否结束。
var done bool
var mu sync.Mutex // 如果不是全局定义的锁,要用引用类型并传引用
func main() {
time.Sleep(1 * time.Second)
println("started")
go periodic()
time.Sleep(5 * time.Second) // wait for a while so we can observe what ticker does
mu.Lock()
done = true
mu.Unlock()
println("cancelled")
time.Sleep(3 * time.Second) // observe no output
}
func periodic() {
for {
println("tick")
time.Sleep(1 * time.Second)
mu.Lock()
if done {
return
}
mu.Unlock()
}
}
这里使用同步用的是锁,在协程之间传递消息用 channel 其实更加方便。
互斥锁
Lab 2 中经常需要 RPC handler 去 Raft 上读写数据,这些并发操作就需要同步处理,一般就是抢互斥锁。
但是锁使用上可能会有一些错误,例如下面这个,一个闭包里不是一整个原子操作,导致本应不变的 total 值中途会有临时增减,加上并发原因,可能最后审核协程得到的结果就不正确了。
func main() {
alice := 10000
bob := 10000
var mu sync.Mutex
total := alice + bob
// 跟踪两人互相转账
go func() {
for i := 0; i < 1000; i++ {
mu.Lock()
alice -= 1
mu.Unlock()
mu.Lock()
bob += 1
mu.Unlock()
}
}()
go func() {
for i := 0; i < 1000; i++ {
mu.Lock()
bob -= 1
mu.Unlock()
mu.Lock()
alice += 1
mu.Unlock()
}
}()
// 审核协程
start := time.Now()
for time.Since(start) < 1*time.Second {
mu.Lock()
if alice+bob != total {
fmt.Printf("observed violation, alice = %v, bob = %v, sum = %v\n", alice, bob, alice+bob)
}
mu.Unlock()
}
}
锁就是保证某段代码的原子性,除了用来互斥访问共享数据,还用来保护不变的量。
同步原语:condition variable(条件变量)
在 Lab 2A 中,会有节点变成 Candidate,给所有 Follower 发送请求投票,Follower 返回信息并表示有没有投票给这个 Candidate。
请求投票是并行的,但是我们不想等所有节点都回复后再决定谁成为 Leader,只要一个 Candidate 票数过半就可以了。这部分代码实际上很复杂。
以下是计票的基础代码:
func main() {
rand.Seed(time.Now().UnixNano())
count := 0 // 得票数
finished := 0 // 得到响应数
var mu sync.Mutex
for i := 0; i < 10; i++ {
go func() {
vote := requestVote()
mu.Lock()
defer mu.Unlock()
if vote {
count++
}
finished++
}()
}
// 2
for {
mu.Lock()
if count >= 5 || finished == 10 {
break
}
mu.Unlock()
}
if count >= 5 {
println("received 5+ votes!")
} else {
println("lost")
}
mu.Unlock()
}
func requestVote() bool {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
return rand.Int() % 2 == 0
}
上面的代码可以正常工作,但是 2 处的 for 循环产生了 busy waiting,即反复检查状态,消耗大量 CPU 资源。
一个简单的方式,就是加一个 sleep
:
for {
mu.Lock()
if count >= 5 || finished == 10 {
break
}
mu.Unlock()
time.Sleep(50 * time.Millisecond)
}
实际上代码中用一些 magic constants(魔术常量),例如这里 sleep
的 50ms,使用任意数字代表正在做一些不是很正确或者不是很清楚的事。
此时的问题是,有多个并发线程对某个共享变量更新,有另一条线程等待该共享数据中的某个事件,例如某个属性变为 true,这个线程会一直等待知道这个条件变成 true。
有一个专门解决这种问题的并发原语:condition variable(类似 Java 中的 Condition 锁的 wait()
notify()
)
func main() {
rand.Seed(time.Now().UnixNano())
count := 0
finished := 0
var mu sync.Mutex
cond := sync.NewCond(&mu) // 与锁指针关联
for i := 0; i < 10; i++ {
go func() {
vote := requestVote()
mu.Lock()
defer mu.Unlock()
if vote {
count++
}
finished++
cond.Broadcast() // 持有锁时,修改了变量后广播
}()
}
mu.Lock()
for count < 5 && finished != 10 {
cond.Wait() // 等待广播
}
if count >= 5 {
println("received 5+ votes!")
} else {
println("lost")
}
mu.Unlock()
}
func requestVote() bool {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
return rand.Int() % 2 == 0
}
这样就不用等待一段时间检查一次了。
注意只有在持有关联的锁时才能调用 cond.Broadcast()
,操作不正确可能导致死锁。
一个抽象的使用例子:
// 线程
mu.Lock()
// do something that might affect the condition
cond.Broadcast()
mu.Unlock()
----
// 等待线程
mu.Lock()
while condition == false {
cond.Wait()
}
// now condition is true, and we have the lock
mu.Unlock()
broadcast
会唤醒通知队列中所有阻塞的线程,而如果用 signal
,那就只唤醒通知队列中第一个阻塞进程(即最早阻塞的进程)。signal
使用得好性能更高,但是 broadcast
适用范围更广。
同步原语:channel
没有讲什么新东西,看看其他博客怎么使用就行。
不过助教说他一般尽量少使用 channel,特别是有缓存 channel,而只使用共享内存、mutex、共享变量、Set 集合,这样写的代码更容易理解。(当然这只是助教的习惯)
在其他线程等待唤醒时用 condition variable 更好。
同步原语:waitgroup
和用 channel 阻塞等待效果一样,即信号量的效果。
死锁 DeadLock
这里举了个 Raft 中的例子,同一把锁,s0申请锁后给 s1 发 RPC,s1 页申请锁后给 s0 发 RPC,并且双方之后的处理函数中还要获取这把锁,最后就造成了互相等待的死锁局面。
一般来讲不要在 RPC 调用期间持有锁。
如果需要用到共享变量,可以赋值给新变量后通过参数传入,只在赋值时加锁,赋值结束即释放。
Debug
最后讲了一些 Raft 中的 Debug 方法。
-
打印 log:
log.Printf()
这里在
util.go
文件中对打印 log 进行了封装,只有 Debug 参数等于 1 时才输出 log。好处就是在代码中任何地方都可以加打印 log,例如每个节点的变化。而需要关闭时只改一个参数即可(我之前都是一个个删除)。
-
ctrl + \
,Go 退出信号,退出所有协程并打印 stacktrace,从stacktrace 中就能找到可能出问题的地方。 -
打开
race
检测是否有并发冲突。