这节课主要是讲编程方法。从 Go 内存模型开始,讲了 Go 协程并发时容易出现的问题,一些更优雅的处理方法,最后讲了 Lab 2 构建 Raft 中的一些问题和 Debug 技巧。
建议配合上一篇博客 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()
}
}
锁就是保证某段代码的原子性,除了用来互斥访问共享数据,还用来保护不变的量。
在 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,而只使用共享内存、mutex、共享变量、Set 集合,这样写的代码更容易理解。(当然这只是助教的习惯)
在其他线程等待唤醒时用 condition variable 更好。
和用 channel 阻塞等待效果一样,即信号量的效果。
这里举了个 Raft 中的例子,同一把锁,s0申请锁后给 s1 发 RPC,s1 页申请锁后给 s0 发 RPC,并且双方之后的处理函数中还要获取这把锁,最后就造成了互相等待的死锁局面。
一般来讲不要在 RPC 调用期间持有锁。
如果需要用到共享变量,可以赋值给新变量后通过参数传入,只在赋值时加锁,赋值结束即释放。
最后讲了一些 Raft 中的 Debug 方法。