0 前言
过去一段时间,我经常涉及使用 Go 语言进行并发编程的一些工作。在完成这些项目的过程中,逐渐了解和积累了 Go 语言中使用 goroutine 和 channel 进行并发编程的一些经验,这里分享给大家,供大家学习和交流。
1 并发编程实践
1.1 按任务划分 goroutine 职责
假设现在有 n 个任务,每个任务的内容都是读取某个文件,然后把文件内容写入数据库。
在其他一些主流的编程语言,可能会启动多个线程,每个线程完成其中的一部分任务,通过这种方式利用多核并行加速完成任务。
但是在 go 语言中,更鼓励的做法是为不同的任务启动不同的 goroutine,在这个任务中,可以启动一个或多个 goroutine来做读取文件操作,一个或多个 goroutine来做写入数据库操作,中间通过 go 程进行通信。
关于如何划分 gorutinue 职责是一个并发模型设计问题,有兴趣的朋友可以读下 Rob Pike(Go作者之一) 于2012年发表的一场演讲,名为《并发不是并行》。
1.2 管道模式
管道模式是一种数据流式处理的方式,其中一系列的 goroutine 每个都处理输入数据的一部分,并将其传递给下一个 goroutine。这种模式非常适合于处理大量数据的批处理任务。
管道模式可以看作是生产者-消费者模式的一种扩展形式,特别是在数据处理流水线中。在传统的生产者-消费者模式中,通常有一个生产者将数据放入一个共享队列(如channel),然后一个或多个消费者从中取出数据并处理。
管道模式则更进一步,它不仅仅是在生产者和消费者之间建立一个简单的队列关系,而是构建了一个由多个阶段组成的流水线。每个阶段都可以视为一个小型的生产者-消费者系统,其中前一阶段的输出作为后一阶段的输入。这种模式特别适用于数据需要经过多步处理的情况,每一步可能由不同的 goroutine 来完成,这样就可以实现数据的流水线式处理。
常见的场景比如说,Extract-Transform-Load,也就是大家常说的 ETL。
1.3 子任务单 goroutine 管道模式使用 close 管理退出
有一些说法强调不要使用 close 来关闭 channel。但事实上,在管道模式,使用 close 来作为通知机制是方便和有效的。
当管道模式中的每个有作为生产者子任务都只有一个 goroutine 在做时,比如说上面的读取文件写入数据库的例子,如果只有一个 goroutine 在读取文件。那么,该 goroutine 在读取完所有文件后,便可以通过调用 close 来关闭 channel,这样便可以通知到写入数据库的 goroutine channel 已经关闭,只要取完所有数据并处理完,就可以退出了。
当然,使用 close 确实有需要注意的:
竞态条件:如果有多个goroutine尝试关闭同一个channel,可能会引发竞态条件。
不可恢复性:一旦channel被关闭,就不能再向其中发送数据。
不能因噎废食,学会理解它们的使用机制并正确合理地使用它们。
1.4 子任务多 goroutine 管道模式使用 close + sync.WaitGroup 来管理退出
现在有一个 Extract-Transform-Load 场景,有 Extract、Transform、Load 三个子任务。
假设现在我们使用了 i 个 goroutine 在进行 Extract 任务,使用了 j 个 goroutine 在进行 Transform 任务,使用了 k 个 goroutine 在进行 Load 任务。
对上面每个有作为生产者的子任务(Extract、Transform)分配一个 sync.WaitGroup。在主 goroutine 中,先把所有子 goroutine 启动起来,然后使用 Wait 方法等待 Extract 的任务完成后,关闭 Extract 到 Transform 的 channel。接着再使用 Wait 方法等待 Transform 任务完成后,关闭 Transform 到 Load 的 channel。
1.5 优雅地中断
一般来讲,既然开了一个 goroutine 来完成任务,计算时间一般会稍长一些,这种时候,常常会要求能快速中断 goroutine。
一种最常见的在 goroutine 中进行中断的方式是,在 goroutine 中寻找最耗时的循环执行代码,然后在这代码中使用 select context.Context 捕获中断信号。
具体地,我们可以在主 goroutine 中创建一个带有取消功能的 context.Context,将该 context.Context 传入子 goroutine。在需要停止时,主 goroutine 调用 cancel 函数。而子 goroutine 通过检查 ctx.Done() 来决定是否继续运行。
1.6 使用 chan error 来传递错误
goroutine 无法直接返回结果,因此常常通过 channel 返回结果。同样地,goroutine 无法直接返回 error,因此常常通过 chan error 来返回结果。
1.7 不要让 goroutine 数量泛滥
虽然 goroutine 的代价很小,但过多的 goroutine 会带来更多的开销。在实际编程中,应该对 goroutine 的数量进行控制,不要让 goroutine 的数量泛滥。
使用固定的或者有上限的 goroutine 数量,而不是让 goroutine 数量随着任务数量增大而不断增大。
在可能的情况下,重用而不是创建新的 goroutine。例如,可以使用工作池(Worker Pool)的方式来管理一定数量的 goroutine。
2 结束
这是目前的一些我在并发编程实践中的一些经验总结,希望对大家有帮助。这篇文章我写地相对简洁,哪里需要代码示例的可以在评论提出,我可以进行补充。
文章评论