7.4. 函数式编程的救援

如我们所见,并行与并发任务的核心挑战之一,就在于多个线程对共享资源的访问与修改所带来的复杂性。

纯函数式编程通过不可变性(immutability)天然地解决了这一问题 —— 在默认情况下,所有数据都不可变,任何值的改变都是通过对原值的转换并返回新值来实现的,而非就地修改。因此,线程永远无需担心其他线程正在使用的数据被意外修改。我们在讨论 C++ 多范式编程时已经阐释过这一机制的优势。

但函数式编程的价值不仅限于此:它还天然支持可并行化的算法结构。C++ 标准委员会在引入函数式风格算法的同时,也加入了执行策略(execution policy)机制,使得开发者可以轻松地将集合操作并行化。

以下是一个简单的示例:计算一个集合中所有数值的平方和。

这类问题非常适合用函数式的方式建模:它本质上是一个典型的 map-reduce 过程:首先,使用 map 操作将原始集合中的每个元素映射为其平方;然后,通过 reduce 操作将所有平方值累加,得到最终结果。在 C++ 的 STL 中,这些操作分别由 std::transform 和 std::reduce 实现。虽然 std::transform_reduce 提供了一个更高效的组合版本,但为了使示例更具教学意义,将使用基本形式进行演示。

函数如下所示:

long long sumOfSquares(const vector<int> numbers){
  vector<long long> squaredNumbers(numbers.size());
  auto squareNumber = [](const long it ){ return it * it; };
  transform(numbers.begin(), numbers.end(), squaredNumbers.begin(), squareNumber);
  return reduce(squaredNumbers.begin(), squaredNumbers.end(), 0);
}
TEST_CASE("sum of squares in parallel") {
  vector<int> numbers{234, 423, 345, 212, 112, 2412};
  CHECK_EQ(6227942, sumOfSquares(numbers));
}

要让这些操作并行运行,唯一需要做的就是向两个函数式算法添加一个指定执行策略的参数。这里使用的执行策略是 std::execution::par,这是标准库提供的 std::execution::parallel_policy 实例,用于指定算法需要并行运行:

long long sumOfSquares(const vector<int> numbers){
  vector<long long> squaredNumbers(numbers.size());
  auto squareNumber = [](const long it ){ return it * it; };
  transform(std::execution::par, numbers.begin(), numbers.end(), squaredNumbers.begin(), squareNumber);
  return reduce(std::execution::par, squaredNumbers.begin(), squaredNumbers.end(), 0);
}

从这个例子中,我们可以注意到几点关键之处。

首先,在使用函数式编程风格时,切换不同的执行策略非常容易。 这使得我们能够更灵活地应对并行化相关的问题,并根据实际需求优化性能。只需更改一行代码,就可以在顺序执行和并行执行之间切换,而无需重构整个算法逻辑。

然而,并非所有情况下并行执行都优于顺序执行。对于小规模数据集或短集合来说,创建和管理线程所带来的开销可能会超过并行化带来的性能收益。因此,选择是否启用并行策略应基于对输入数据量、计算复杂度以及硬件资源的综合评估。

其次,执行策略可以作为参数传入,也可以通过配置进行控制。 可以在开发和测试阶段使用顺序执行来验证逻辑正确性(无需考虑线程同步问题),然后在部署环境中根据实际数据特性动态选择最合适的执行策略。这种灵活性极大地提升了代码的可维护性和适应性。

第三,每种执行策略都会对代码施加特定的限制。 例如,使用并行策略(如 std::execution::par)时,要求迭代器在整个操作过程中保持有效,因此不能在变换过程中修改容器结构(如插入或删除元素)。此外,像 std::back_inserter 这样依赖动态扩展的写入操作也是不允许的。

除了 std::execution::parallel_policy,STL 还提供了其他策略:

  • std::execution::sequenced_policy
  • std::execution::parallel_unsequenced_policy
  • std::execution::unsequenced_policy

值得注意的是,标准化委员会未来可能会为 std::parallel::cuda 和 std::parallel::opencl 添加内置策略,以支持更多样化的并行计算环境。由于每种执行策略都有其特定的限制和适用场景,因此最具可移植性和健壮性的代码通常追求最大程度的不可变性和函数式算法的应用。

第四点,这些算法虽然按顺序调用,但内部可以并行化。如果需要进一步榨取计算资源,可以使用组合算法如 std::transform_reduce 或者自行编写合并逻辑来优化性能。然而,并行化是一种权衡:部分计算资源会消耗在线程启动和同步上,某些情况下这可能导致效率反而下降。

最后第五点,Map-Reduce 模式非常强大。任何一元函数都可以用于 map 阶段,而任何二元函数都可以用于 reduce 阶段。通过绑定参数,甚至可以将多元函数转换为一元或二元形式,从而适应不同的需求。map 和 reduce 可以以多种方式链式组合,形成复杂的处理流程。如果把程序视为“数据输入 -> 处理 -> 输出”的过程,就会发现大多数程序实际上都可以表示为“数据输入 -> 函数式转换 -> 数据输出”,其中许多转换正是基于 map/reduce 操作。这种认知催生了强大的编程模型 —— 可以自由控制整个算法或其部分的并行化开关。虽然有时需要为关键代码编写定制化的并行算法,但大部分优化可以直接利用现有的函数式工具实现。

唯一的前提是:必须使用不可变数据和函数式算法。

至此,我们讨论了以数据为中心的设计风格,它聚焦于数据结构及其转换。但在软件架构中,还存在另一种关注行为的设计范式。这种范式将程序分解为多个独立的行为单元,并支持并发与并行编程。这就是Actor 模型,一种旨在简化并发编程复杂度的设计风格。