Files
the-way-to-go_ZH_CN/eBook/14.7.md
Haigang Zhou f08d76efa6 第十四章上半部分 (#836)
Co-authored-by: Joe Chen <jc@unknwon.io>
2022-05-17 16:24:13 +08:00

108 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 14.7 新旧模型对比:任务和 worker
假设我们需要处理很多任务;一个 worker 处理一项任务。任务可以被定义为一个结构体(具体的细节在这里并不重要):
```go
type Task struct {
// some state
}
```
旧模式:使用共享内存进行同步
由各个任务组成的任务池共享内存;为了同步各个 worker 以及避免资源竞争,我们需要对任务池进行加锁保护:
```go
type Pool struct {
Mu sync.Mutex
Tasks []*Task
}
```
`sync.Mutex`[参见9.3](09.3.md))是互斥锁:它用来在代码中保护临界区资源:同一时间只有一个 go 协程 (goroutine) 可以进入该临界区。如果出现了同一时间多个 go 协程都进入了该临界区,则会产生竞争:`Pool` 结构就不能保证被正确更新。在传统的模式中(经典的面向对象的语言中应用得比较多,比如 C++JAVAC#worker 代码可能这样写:
```go
func Worker(pool *Pool) {
for {
pool.Mu.Lock()
// begin critical section:
task := pool.Tasks[0] // take the first task
pool.Tasks = pool.Tasks[1:] // update the pool of tasks
// end critical section
pool.Mu.Unlock()
process(task)
}
}
```
这些 worker 有许多都可以并发执行;他们可以在 go 协程中启动。一个 worker 先将 `pool` 锁定,从 `pool` 获取第一项任务,再解锁和处理任务。加锁保证了同一时间只有一个 go 协程可以进入到 `pool` 中:一项任务有且只能被赋予一个 worker 。如果不加锁,则工作协程可能会在 `task:=pool.Tasks[0]` 发生切换,导致 `pool.Tasks=pool.Tasks[1:]` 结果异常:一些 worker 获取不到任务,而一些任务可能被多个 worker 得到。加锁实现同步的方式在工作协程比较少时可以工作得很好,但是当工作协程数量很大,任务量也很多时,处理效率将会因为频繁的加锁/解锁开销而降低。当工作协程数增加到一个阈值时,程序效率会急剧下降,这就成为了瓶颈。
新模式:使用通道
使用通道进行同步使用一个通道接受需要处理的任务一个通道接受处理完成的任务及其结果。worker 在协程中启动,其数量 `N` 应该根据任务数量进行调整。
主线程扮演着 Master 节点角色,可能写成如下形式:
```go
func main() {
pending, done := make(chan *Task), make(chan *Task)
go sendWork(pending) // put tasks with work on the channel
for i := 0; i < N; i++ { // start N goroutines to do work
go Worker(pending, done)
}
consumeWork(done) // continue with the processed tasks
}
```
worker 的逻辑比较简单:从 `pending` 通道拿任务,处理后将其放到 `done` 通道中:
```go
func Worker(in, out chan *Task) {
for {
t := <-in
process(t)
out <- t
}
}
```
这里并不使用锁从通道得到新任务的过程没有任何竞争。随着任务数量增加worker 数量也应该相应增加,同时性能并不会像第一种方式那样下降明显。在 `pending` 通道中存在一份任务的拷贝,第一个 worker 从 `pending` 通道中获得第一个任务并进行处理,这里并不存在竞争(对一个通道读数据和写数据的整个过程是原子性的:参见 [14.2.2](14.2.md))。某一个任务会在哪一个 worker 中被执行是不可知的反过来也是。worker 数量的增多也会增加通信的开销,这会对性能有轻微的影响。
从这个简单的例子中可能很难看出第二种模式的优势,但含有复杂锁运用的程序不仅在编写上显得困难,也不容易编写正确,使用第二种模式的话,就无需考虑这么复杂的东西了。
因此,第二种模式对比第一种模式而言,不仅性能是一个主要优势,而且还有个更大的优势:代码显得更清晰、更优雅。一个更符合 go 语言习惯的 worker 写法:
**IDIOM: Use an in- and out-channel instead of locking**
```go
func Worker(in, out chan *Task) {
for {
t := <-in
process(t)
out <- t
}
}
```
对于任何可以建模为 Master-Worker 范例的问题,一个类似于 worker 使用通道进行通信和交互、Master 进行整体协调的方案都能完美解决。如果系统部署在多台机器上,各个机器上执行 Worker 协程Master 和 Worker 之间使用 netchan 或者 RPC 进行通信(参见 [15 章](15.0.md))。
怎么选择是该使用锁还是通道?
通道是一个较新的概念,本节我们着重强调了在 go 协程里通道的使用但这并不意味着经典的锁方法就不能使用。go 语言让你可以根据实际问题进行选择创建一个优雅、简单、可读性强、在大多数场景性能表现都能很好的方案。如果你的问题适合使用锁也不要忌讳使用它。go 语言注重实用,什么方式最能解决你的问题就用什么方式,而不是强迫你使用一种编码风格。下面列出一个普遍的经验法则:
* 使用锁的情景:
- 访问共享数据结构中的缓存信息
- 保存应用程序上下文和状态信息数据
* 使用通道的情景:
- 与异步操作的结果进行交互
- 分发任务
- 传递数据所有权
当你发现你的锁使用规则变得很复杂时,可以反省使用通道会不会使问题变得简单些。
## 链接
- [目录](directory.md)
- 上一节:[协程和恢复recover](14.6.md)
- 下一节:[惰性生成器实现](14.8.md)