假设我们需要处理多任务,一个worker处理一项任务。
旧模式:使用共享内存进行同步
由各个任务组成的任务池共享内存,为了同步各个worker以及避免资源竞争,我们需要对任务池进行加锁保护
Sync.Mutex被用来保护临界区资源,同一时间只有一个go协程可以进入该临界区,如果同时出现多个go协程进入该临界区,会产生竞争,Pool结构就不能正常被更新,在传统模式中,worker可以被这样写,
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处理;不加锁,会导致协程在第一个任务处发生切换,导致剩余的任务结果发生异常,一些worker获取不到任务,而一些任务会被分配给多个worker,加锁实现同步的方式,可以在工作协程比较少的时候工作的很好,但当协程数量很大,任务数也很多的时候,处理效率会因为频繁的加锁/解锁开销而降低,当工作协程数增加到一个阀值的时候,程序效率会急剧下降,这就成为了瓶颈。
新模式:使用通道
从通道得到新任务的过程没有任何竞争
随着任务数量增加,worker数量也应该相应增加,同时性能并不会像第一种方式那样下降明显
对于任何可以建模为Master-Worker范例的问题,一个类似于worker使用通道进行通信和交互、Master进行整体协调的方案都能完美解决。如果系统部署在多台机器上,各个机器上执行Worker协程,Master和Worker之间使用netchan或者RPC进行通信(参见15章)
。
怎么选择是该使用锁还是通道?
通道是一个较新的概念,本节我们着重强调了在go协程里通道的使用,但这并不意味着经典的锁方法就不能使用。go语言让你可以根据实际问题进行选择:创建一个优雅、简单、可读性强、在大多数场景性能表现都能很好的方案。如果你的问题适合使用锁,也不要忌讳使用它。go语言注重实用,什么方式最能解决你的问题就用什么方式,而不是强迫你使用一种编码风格。下面列出一个普遍的经验法则:
使用锁的情景:
访问共享数据结构中的缓存信息
保存应用程序上下文和状态信息数据
使用通道的情景:
与异步操作的结果进行交互
分发任务
传递数据所有权
当你发现你的锁使用规则变得很复杂时,可以反省使用通道会不会使问题变得简单些。