【6.824分布式系统笔记】LEC 5: Go, Threads, and Raft|Go协程并发问题、Raft Debug技巧

这节课主要是讲编程方法。从 Go 内存模型开始,讲了 Go 协程并发时容易出现的问题,一些更优雅的处理方法,最后讲了 Lab 2 构建 Raft 中的一些问题和 Debug 技巧。

建议配合上一篇博客 Go 内存模型食用。

文章目录

    • Go 协程使用匿名函数的问题
    • 周期性地做某些事
    • 互斥锁
    • 同步原语:condition variable(条件变量)
    • 同步原语:channel
    • 同步原语:waitgroup
    • 死锁 DeadLock
    • 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()
  }
}

锁就是保证某段代码的原子性,除了用来互斥访问共享数据,还用来保护不变的量

同步原语: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,例如每个节点的变化。而需要关闭时只改一个参数即可(我之前都是一个个删除)。

    【6.824分布式系统笔记】LEC 5: Go, Threads, and Raft|Go协程并发问题、Raft Debug技巧_第1张图片

  • ctrl + \,Go 退出信号,退出所有协程并打印 stacktrace,从stacktrace 中就能找到可能出问题的地方。

  • 打开 race 检测是否有并发冲突。

你可能感兴趣的:(分布式系统,golang,后端,Go,分布式,6.824)