并发编程2:数据管道 Pipelining

234 阅读4分钟

一般 Data Parallelism 的短板

前一篇提到了数据并行,并用MergeSort举了例子。而函数式编程的MapReduce中的Map,也是一个非常好的数据并行的例子。

Map可以说是一个绝佳的例子。因为它既可以做数据并行、也可以做并行的数据管道。

假设有一个数组,我们想让所有数据都+1,那么大概会有如下的代码:

array.Map(x => x + 1)

想要让Map并行起来很容易,只要在Map内部将array拆分成N个部分,然后在各自的线程中做处理就行了。这样的方式类似于MergeSort的并发处理。N可以等于CPU核心数量。常规的电脑大概有4-8个CPU核心,所以大概会提速N倍。

但是然我们来设想这么一种情况,

array.Map(f1).Map(f2).Map(f3).Map(f4)

我们要将一个数据序列不断地Map。如果Map是如上的并发实现,从同时运行的线程数来看,我们会经历这么一个过程:

1 -> N -> 1 -> N -> 1 -> N -> 1 -> N -> 1

每一次Map,都会把线程数从1提升到N,然后运算结束后回归到1。下一个Map又会开启新的N个线程。

如果Map的次数足够多,比如有几十上百次,那么这中间带来的额外性能损耗就会比较多。

Concurrent Data Pipeline

这时基于数据管道(Data Pipeline)的并行就比单纯的并行Map高效了。

Data Pipeline的逻辑是这样的,针对每一Map,单独开启一个线程,每个线程使用一个并发安全的队列和上下游通讯。这样如果有K个Map,我们只需要开启K+1次线程。而不是K*N次。当K足够大时,这样做就比较划算了。

为什么是K+1次呢?因为最后我们需要回收最后一个Map的结果,所以一般而言API如下

array.Map(f1).Map(f2).Map(f3).Map(f4).Collect()

最后的Collect通常也需要开启一个线程来运行。

当然,如果我们用算法分析的Big O来写,就是O(K) vs O(K * N)了。

代码和视频已经分别放在了GitHubB站,想了解细节的同学可以看看。

性能分析

在视频中也提到了,Data Pipeline 并不是在所有情况下都比普通并发Map快的。正如之前的文章所述,线程之间的协调越多,速度就越慢。如果协调次数太多,甚至比单线程还慢很多倍。而每一次上游线程将数据塞入队列、以及每一次下游线程从队列中取出数据,都是一次协调操作,也就涉及到了Lock(锁)。而Map(f)中的f如果越快,那么加锁解锁的频率也就越高。

所以,这里其实有3个复杂度需要分析:

  1. f 的时间复杂度
  2. 开启关闭线程的时间复杂度
  3. 锁的时间复杂度

到底是 Parallel Map 快,还是 Parallel Pipeline 快,是这三个复杂度的博弈结果。这个在视频里也提到了。

我们先假设N个CPU核心,有KMap,我们都使用同一个Map(f),而且fO(F)。数据量有M

Parallel MapPipeline
fO(K*F)O(K*F)
线程开销O(K*N)O(K)
锁开销O(K*N)O(K*M)

通常,M >> N 很多很多。所以我们会发现,Parallel Map的最大性能损耗在于线程数。而 Pipeline 的最大性能损耗在于锁数。而只有当 F 远远大于 M 时,多个线程同时被锁住的概率才会下降。这也是为什么当 f 非常快时,Pipeline 会非常慢的原因。

这个在视频最后也有讲到。

无限序列 & 流处理

Pipeline还有一个 Prarallel Map做不到的有点,就是可以针对无限序列(流)进行处理。

这个道理和浅显易懂。如果一个序列是无限的、或者是流,那么就无法预先知道其长度,自然也就无法将数据分为 N 份。

所以,虽然 Pipeline 在编程难度以及优化上都比一般的数据并行困难,但是也有其不可或缺的优势。