14.7 新旧模型对比:任务和 worker

假设我们需要处理很多任务;一个 worker 处理一项任务。任务可以被定义为一个结构体(具体的细节在这里并不重要):

  1. type Task struct {
  2. // some state
  3. }

旧模式:使用共享内存进行同步

由各个任务组成的任务池共享内存;为了同步各个 worker 以及避免资源竞争,我们需要对任务池进行加锁保护:

  1. type Pool struct {
  2. Mu sync.Mutex
  3. Tasks []*Task
  4. }

sync.Mutex参见9.3)是互斥锁:它用来在代码中保护临界区资源:同一时间只有一个 go 协程 (goroutine) 可以进入该临界区。如果出现了同一时间多个 go 协程都进入了该临界区,则会产生竞争:Pool 结构就不能保证被正确更新。在传统的模式中(经典的面向对象的语言中应用得比较多,比如 C++,JAVA,C#),worker 代码可能这样写:

  1. func Worker(pool *Pool) {
  2. for {
  3. pool.Mu.Lock()
  4. // begin critical section:
  5. task := pool.Tasks[0] // take the first task
  6. pool.Tasks = pool.Tasks[1:] // update the pool of tasks
  7. // end critical section
  8. pool.Mu.Unlock()
  9. process(task)
  10. }
  11. }

这些 worker 有许多都可以并发执行;他们可以在 go 协程中启动。一个 worker 先将 pool 锁定,从 pool 获取第一项任务,再解锁和处理任务。加锁保证了同一时间只有一个 go 协程可以进入到 pool 中:一项任务有且只能被赋予一个 worker 。如果不加锁,则工作协程可能会在 task:=pool.Tasks[0] 发生切换,导致 pool.Tasks=pool.Tasks[1:] 结果异常:一些 worker 获取不到任务,而一些任务可能被多个 worker 得到。加锁实现同步的方式在工作协程比较少时可以工作得很好,但是当工作协程数量很大,任务量也很多时,处理效率将会因为频繁的加锁/解锁开销而降低。当工作协程数增加到一个阈值时,程序效率会急剧下降,这就成为了瓶颈。

新模式:使用通道

使用通道进行同步:使用一个通道接受需要处理的任务,一个通道接受处理完成的任务(及其结果)。worker 在协程中启动,其数量 N 应该根据任务数量进行调整。

主线程扮演着 Master 节点角色,可能写成如下形式:

  1. func main() {
  2. pending, done := make(chan *Task), make(chan *Task)
  3. go sendWork(pending) // put tasks with work on the channel
  4. for i := 0; i < N; i++ { // start N goroutines to do work
  5. go Worker(pending, done)
  6. }
  7. consumeWork(done) // continue with the processed tasks
  8. }

worker 的逻辑比较简单:从 pending 通道拿任务,处理后将其放到 done 通道中:

  1. func Worker(in, out chan *Task) {
  2. for {
  3. t := <-in
  4. process(t)
  5. out <- t
  6. }
  7. }

这里并不使用锁:从通道得到新任务的过程没有任何竞争。随着任务数量增加,worker 数量也应该相应增加,同时性能并不会像第一种方式那样下降明显。在 pending 通道中存在一份任务的拷贝,第一个 worker 从 pending 通道中获得第一个任务并进行处理,这里并不存在竞争(对一个通道读数据和写数据的整个过程是原子性的:参见 14.2.2)。某一个任务会在哪一个 worker 中被执行是不可知的,反过来也是。worker 数量的增多也会增加通信的开销,这会对性能有轻微的影响。

从这个简单的例子中可能很难看出第二种模式的优势,但含有复杂锁运用的程序不仅在编写上显得困难,也不容易编写正确,使用第二种模式的话,就无需考虑这么复杂的东西了。

因此,第二种模式对比第一种模式而言,不仅性能是一个主要优势,而且还有个更大的优势:代码显得更清晰、更优雅。一个更符合 go 语言习惯的 worker 写法:

IDIOM: Use an in- and out-channel instead of locking

  1. func Worker(in, out chan *Task) {
  2. for {
  3. t := <-in
  4. process(t)
  5. out <- t
  6. }
  7. }

对于任何可以建模为 Master-Worker 范例的问题,一个类似于 worker 使用通道进行通信和交互、Master 进行整体协调的方案都能完美解决。如果系统部署在多台机器上,各个机器上执行 Worker 协程,Master 和 Worker 之间使用 netchan 或者 RPC 进行通信(参见 15 章)。

怎么选择是该使用锁还是通道?

通道是一个较新的概念,本节我们着重强调了在 go 协程里通道的使用,但这并不意味着经典的锁方法就不能使用。go 语言让你可以根据实际问题进行选择:创建一个优雅、简单、可读性强、在大多数场景性能表现都能很好的方案。如果你的问题适合使用锁,也不要忌讳使用它。go 语言注重实用,什么方式最能解决你的问题就用什么方式,而不是强迫你使用一种编码风格。下面列出一个普遍的经验法则:

  • 使用锁的情景:

    • 访问共享数据结构中的缓存信息
    • 保存应用程序上下文和状态信息数据
  • 使用通道的情景:

    • 与异步操作的结果进行交互
    • 分发任务
    • 传递数据所有权

当你发现你的锁使用规则变得很复杂时,可以反省使用通道会不会使问题变得简单些。

链接