
在Golang中领略并发之美
我最近在做一个小型的ETL项目,从数据源读取数据,进行处理,然后写入存储。该软件工作正常,但随着处理的数据量增加,执行时间也会增加。这对企业来说是个问题。
肯特-贝克曾经说过,在构建软件时,优先顺序应该是 "让它工作,让它正确,让它快速"。根据这一理念,到目前为止,我在这个项目上的优先事项是首先让一些东西工作,然后确保它以正确的方式进行。然而,现在是时候让它快速发展了。在这篇文章中,我将向你展示我是如何使用Go来实现的。
问题分析
该系统的基本活动流程是:
- 从源头读取数据
- 根据业务规则进行处理
- 将数据写入存储器
- 继续,直到没有更多的数据需要处理。
串行过程
你可能已经发现了这个流程中的瓶颈。它假定我们必须连续地执行读取、处理和写入。当这些任务中的任何一个在执行时,另外两个任务都被阻断并等待。
如果被任务处理的数据项以某种方式相互连接,那么优化就会很棘手。例如,如果需要按照读入的顺序将项目写出来,这将更难加速。
然而,在这种情况下,我知道每个数据项都可以独立处理,不参考过去或未来的项目。这使得我们的项目成为并发处理的最佳人选。
解决方案的设计
由于解决方案似乎正朝着并发的方向发展,现在是时候考虑我们如何管理并发任务的执行,以及它们之间的任何通信。
要做到这一点,把系统想象成一条生产线是很有帮助的,每个工作单元都必须被读取,然后被处理,然后被写出来。每个数据单元一次只能由一个任务处理;但同时,每个步骤可以忙于处理不同的工作。
在生产线上分配工人时,每个工人只执行全部工作中的一个步骤。当该步骤的产出完成后,工人将其传递给生产线上的下一个工人。有些步骤可以很快完成,而有些则很慢。串行线的问题是,一个慢的工作者会耽误整条线。
为了解决这个问题,我们可以引入某种通信媒介作为工人之间的缓冲。这意味着工人只需要从缓冲区获得下一个工作的输入,而不是直接从另一个工人那里获得。同样地,当一个工作者完成工作后,他们会把他们的输出放入缓冲区,而不是直接传递给另一个工作者。
这种解耦使多个工作者能够同时执行同一类任务。
从这些方面考虑,我们将把这些 "缓冲区 "引入到应用架构中,如下所示。
有通信媒介的串行任务
当然,这还没有真正的价值,因为一切都还在串行化。为了看到好处,我们需要确定哪些工作可以被并行化。
在我们的系统中,进一步的分析发现,处理和写任务是最耗时的,而读取并没有真正拖累我们。因此,为了加快进度,我们可以为这些较慢的任务增加更多的工作者。
有通信介质的并发任务
实施它
在确定了问题的原因和可以处理这个问题的并发模型之后,下一步就是选择一种编程语言并开始编码。
我选择的语言是Go,因为它通过goroutines和channel的概念支持原生的并发性。
一个goroutine是一个可以与其他函数同时运行的函数。goroutine就像线程一样,但与实际的线程相比,创建一个goroutine的成本很小。因此,很容易创建并同时运行成千上万个goroutine。关于goroutine和线程之间的区别的更多详细信息,请看这里。
通道是一种让goroutine相互通信并同步执行的方式。这可能足以让你理解本文的其余部分,但如果想更深入地了解goroutines和通道之间的关系,我建议你阅读这篇文章。
在我们的例子中,goroutines将封装任务,而通道将是数据从一个任务传递到下一个任务的通信媒介。一旦以这种方式设置好,Go运行时就可以处理其他的事情了。
一个实例项目
不幸的是,我不能与你分享我的客户项目的源代码。然而,为了证明基本概念的作用,我建立了一个示例程序,读取一个平面文件,对其进行处理,并将结果写入Redis。这个示例程序的并行化所带来的性能改进与我在客户项目中看到的类似。
该示例程序的平面文件包含了几行假的用户记录。每条记录都包含一个ID、名字和姓氏。下面是该文件的一个样本,看起来像:
dd8ae78452ec471a841c57bc138699ff,Gina,Nicolas
17d2c73fe7854332a253ccca9d863147,Morris,Bartoletti
90ccdf5923d84e0a9d0ff8a59c278d28,Chelsey,Adams
da0d8357555c421b9b6857c609d273cc,Demetris,Welch
bde2139d238545b68c1bb7a4f79e865a,Gracie,Schultz
95417d16e04040fe8fc8f2008a7feef2,Myrna,Daugherty
...
为了处理这个文件,Go程序的结构是这样的:
应用架构
你可以看到,因为我知道从文件中读取数据是非常快的,我只启动了一个 "Reader "goroutine。它将数据发送到一个 "读 "的通道。
接下来,因为我知道处理数据很慢,我创建了几个 "Process "goroutine,它从 "Read "通道接收数据,对其进行处理,并将其发送到 "Write "通道。
最后,因为向Redis写数据也是一项耗时的任务,我催生了多个Writer goroutines,从 "Write "通道拉出数据并写到Redis。
默认情况下,由于主goroutine不会等待其他(非主)goroutine完成,所以一旦主函数调用返回,程序就会立即退出,即使其他goroutine还没有执行完毕。为了让所有其他的goroutine完成,我们有一个 "退出 "通道。主的goroutine会等待,直到所有其他的goroutine都发布到这个通道,然后它才退出进程。
实现这种退出同步的另一种方法是使用sync.WaitGroup,尽管我在这个例子中没有这样做。
测量示例项目
我们的最终目标是验证并发模型的表现比串行处理更好。下面是在四核机器上运行该示例项目时的执行时间,以分钟和秒为单位,该进程能够使用8个逻辑CPU。
| 行数 | 串行处理 | 并发(1个写程序goroutine) | 并发(100个写程序)。 | 并发(1000个写程序)。 |
|---|---|---|---|---|
| 100,000行 | 2:00 | 1:53 | 0:14 | 0:13 |
| 1,000,000行 | 19:34 | 18:31 | 2:32 | 2:29 |
从表中可以看出,在每个任务运行一个goroutine的情况下,没有什么区别。然而,如果有100个goroutine,时间大约快了8倍。考虑到所涉及的最小代码变化,这是一个相当惊人的结果。有趣的是,增加到1000个写程序并没有带来更多的差异。
作为一个额外的实验,我在一台有4个逻辑CPU的双核机器上运行这个应用程序。
| 行数 | 并发性(1个写手程序) | 并发(100个写程序)。 | 并发(1000个写程序)。 |
| 100,000行 | 2:09 | 0:34 | 0:27 |
| 1,000,000行 | 25:44 | 4:36 | 5:07 |
虽然不同的数据源、处理步骤或存储类型的结果会有所不同,但并发性的好处是显而易见的。然而,重要的是要认识到,根据你的应用,你可能需要进行实验来找到最佳的goroutines数量。
总结
并发可以减少你的应用程序的等待和响应时间,并提高CPU的利用率和效率。一个巨大的任务可以被分解成许多小任务,然后可以并发执行。
Golang中并发的好处是,如果你正确地进行了分析,并且正确地理解了这些概念,它就可以直接实现。
然而,重要的是要记住,即使实现起来很容易,分析和理解也需要时间。因此,在跳进去之前,你应该想一想优化的好处是否超过了成本。例如,如果业务任务对时间不敏感,即使你怀疑它的效率不高,也可能不值得优化你的实施。用Donald Knuth的话说
"真正的问题是,程序员在错误的地方和错误的时间花了太多的时间来担心效率;过早的优化是编程中所有罪恶的根源(或者至少是大部分的罪恶)"。
- 唐纳德-克努斯
我希望你能发现这篇博客的好处。



