善用并行,让构建倍速进行

Blog
Author:
Ori HochOri Hoch
Published On:
5月 24, 2021
Estimated reading time:
1 minutes

在之前的博客中,Renana 讨论了“为什么 slow builds 是发布人员永远的噩梦”。顺着这个问题,在这篇博客中,我将深入探讨一个可能有效的解决方案。这个方案在之前的博客中也有提到——并行化。

我们先来回顾一下独立于 CI 系统的并行化解决方案案例。这种方案的特点是,无论构建系统如何,都能快速、便捷地解决问题。接下来,我将重点介绍 Linux 环境中的常用工具,这些工具包括 BashPython GNU Parallel,想必大家也都不陌生。

这些工具相对容易操作,依赖项少。不过,也有一些局限性,我将重点指出。在后续的博客中,我会详细介绍如何使用主流 CI 系统中的功能来打破这些限制。

为何并行?

机器性能越来越强大,充分利用机器能力(例如调用所有内核的计算能力)的需求也变得越来越重要。这几年,在同一台机器上运行多个实例(例如 Docker)的趋势在不断上升,高效利用资源,意味着在相关的情况下(尽管不一定在构建中)有效地使用这些实例。当有一个瓶颈任务阻碍了其他任务的正常运行,例如,构建阻碍了测试,这些额外的资源就变得至关重要。在这种情况下,我们应该尽量调用所有资源来协助多项任务尽快完成,如果可能的话,可以把任务划分为并行的子任务。

并行什么?

在开始编写代码之前,让我们先了解一下自身的需求,即我们想要从任务并行中得到什么。最简单的用例是 Makefile,其包含几个可以并行运行的任务。例如,每个任务产生一个不同的二进制文件。更高级的并行化需求中,任务之间可能具有复杂的依赖关系,或者任务的数量将根据之前的任务输出情况而改变。所有这些需求都可以用以下的方案解决。不过,这些解决方案在复杂度、易用性和可维护性中也各有千秋,下面将为大家详细解答。

  

Bash 方案“快刀斩乱麻”

大多数构建进程都以 Bash 开始和结束,所以这是开始进行并行化的常见方式。Bash 中最简单的并行方法是使用“&”后缀。只需在命令后追加“&”,任务则将在后台运行,与此同时,其他任务可以同步进行。一旦任务在后台运行,我们还需要等待它们完成,这个过程由“wait”命令完成。

下面的示例脚本并行运行 3 make 任务,并等待所有任务完成:

make task_a &
make task_b &
make task_c &
wait

这适用于提前知道所有任务的简单场景,也可以使用循环方式完成文件列表任务:

for FILE in `ls`; do
  do_something $FILE &
done
wait

或者,如果文件中有一个任务列表,每行表示一个任务:

while read TASK; do
  $TASK &
done < $FILE
wait

虽然上面的例子可以组合成更复杂的脚本,但是如果用例更高级、复杂,我建议使用下一个解决方案。

Python 实现高级并行

Python DevOps 的通用语言,在 CI 环境中使用简单、方便。Python multiprocessing 模块提供了所需的并行功能。不过,对比前面的 BashPython 使用难度较高,灵活性更强。Multiprocessing Pool 类允许在一组已定义的进程之间分配工作(根据默认的可用 CPU 内核数量):

以下示例,表示在所有可用 CPU 内核中,运行列表中定义的任意数量任务

from multiprocessing import Pool
from subprocess import check_call

scripts_to_run = [
  "make task_a",
  "make task_b",
  "make task_c"
  # add more tasks here
]

def run_script(script):
  check_call(script, shell=True)

with Pool() as pool:
  pool.map(run_script, scripts_to_run)

在前面的示例中,我们使用 subprocess.check_call 函数来执行每个脚本,如果任何脚本失败,这将引发异常,最终导致整个脚本失败。这种情况经常在脚本构建时出现,我们需要确保脚本中的任务都能成功完成。 

下面的示例显示了一个稍微复杂一些的脚本,该脚本显示每个任务的状态,并允许在某些任务失败时,构建在某些条件下可以继续进行:

from subprocess import call

scripts_to_run = {
  "a": "make task_a",
  "b": "make task_b",
  "c": "make task_c",
  # add more tasks here
}

def run_script(name_script):
  name, script = name_script
  return name, call(script, shell=True) == 0

with Pool() as pool:
  results = dict(pool.map(run_script, scripts_to_run.items()))

print(results)

结果变量将包含一个 dict,其键为脚本名(“a”、“b”、“c”),值为True/False,表示任务是否成功。

上面的例子可以扩展到不同类型的任务,也适用于更高级的场景。有关更多可用功能,请参阅非常全面的 Python multiprocessing 文件。

Parallel GNU Parallel 让并行更上一层

虽然前面的示例已经可以让我们并行运行任何可能的工作负载,但这些方式需要的代码数量也相当庞大。GNU Parallel 是一个非常复杂但功能丰富的命令行程序,通过简短易读的选项将并行化的全部功能带到命令行。其操作复杂,但选择也相应地增多。我建议大家可以阅读 GNU 并行教程,了解全部功能。

GNU Parallel 可以使用包管理器安装(例如,如果你使用的是 Ubuntu,则可以使用‘sudo apt get install Parallel’安装)。与 Python 相同,GNU Parallel 在默认情况下,会将工作分配到所有可用的 CPU 内核上。我们可以看一些例子。

以下示例是调用所有可用 CPU 内核,并行运行 3 个任务,并等待它们完成的最基本用例:

parallel ::: "make task_a" "make task_b" "make task_c"

我们可以对上面的示例稍加改进,替换字符串。这与上一个示例的运行结果相同,但代码略短,重复次数较少:

parallel make {} ::: "task_a" "task_b" "task_c"

更多高级功能,请参阅 GNU 并行教程替换字符串部分。

GNU Parallel 还可以从标准输入中获取它的输入,因此可以处理不同数量的任务。

将一个列表文件并行运作:

ls | parallel do_something_with_file {}

将文件中的任务列表并行化,每行表示一个任务:

cat make_tasks.txt | parallel make {}

退出状态码会报告出现了多少错误,例如,以下命令的退出状态码为 2,因为命令中有 2 个失败的退出状态码:

parallel eval {} ::: false true false

GNU Parallel 也可以输出每个任务列表,不过这部分不在本文的讨论范围内,可参考 GNU Parallel 教程控制输出部分

GNU Parallel 另一个非常有用的功能,是使用 SSH 在远程机器上运行任务。假设我们在 .ssh/config 文件中配置了SSH 主机,这样就可以通过运行“ssh HOSTNAME”来进行 SSH,具体可以参考以下示例,在多个服务器上分发工作负载:

parallel -S HOSTNAME1,HOSTNAME2,HOSTNAME3 make ::: "task_a" "task_b" "task_c" 

我们需要将相关源文件复制到所有服务器,并聚合来自每个服务器的结果(通常使用scp

以上方案的局限性

Limitations

当进行更复杂的操作时,我们将很快发现上述解决方案的局限性。虽然还是可以克服所有限制,但通常需要更多的代码来开发或添加额外的依赖项。

以下是一些常见的限制:

  • 错误报告和警报–你想知道任务是何时失败,为什么失败,并希望得到相应的警报。
  • 任务输入/输出聚合–如果任务输入/输出较为复杂,则需要编写聚合或解析它的代码。
  • 使用 SSH 在远程机器上运行需要进行设置,并且必须预先知道主机名称。你可能需要自动提取主机名称,并将工作负载分布在任意数量的远程计算机上。

如果你觉得你也可能经历这些限制,你应该考虑使用 CI 系统的并行化功能。我将在下一篇文章中列举一些热门的 CI 系统。

需要注意的是,CI 系统(如 JenkinsBamboo 等)的功能确实解决了上述一些限制。然而,尽管有一些任务容易并行,但是 C++ 的构建更为复杂。通常,看到项目 B 和项目 A 之间存在依赖关系的 CI 管道,会先运行 A 的编译,然后运行链接,再运行 B 的编译,最后运行链接。尽管从依赖角度上看,这种操作并没错,但计算能力并没有得到优化。因为在大多数情况下,只有 B 的链接依赖于 A 的链接,而 A B 的编译进程可以独立执行,因此可以实现并行。CI/CD 系统很难去规避这种低效执行,它不处理生成执行的业务逻辑,因此很难优化这样的用例。要实现优化,我们需要一些与 C++ 构建工具紧密结合的产品,如 Incredibuild

在应用任何并行化技术时,你还是可以感受打破并行瓶颈的快感,因为成千上万的任务可以瞬间并行执行(尤其是在 C++ 编译、测试等多个场景中)。一旦可并行运行的任务达到一定数量,建议考虑使用分布式计算等解决方案突破本地主机资源的限制。这些开创性的解决方案可以横跨你的本地网络或公共云,充分利用空闲 CPU,将主机开发成为具有成百或上千个内核的超级计算机。

总结 

为了跟上计算机的多核体系结构,并获得预期的性能扩展,在任何环境中,并行化的重要性都是不可忽视的。在本文中,我们回顾了一些基本的并行方法,与许多技术挑战一样,并行对于简单任务来说很容易,对于复杂的、紧密耦合的任务来说非常复杂。我们提出了两个重要的观点:(aC++ 的并行化具有一定挑战性;(b)本地机器的并行化是有限的,大部分情况下,将并行任务分发至本地或公共云,将打破限制,带来更多效益。

在下一篇文章中,我们将讨论 CI 系统中的并行性

speed up c++