最近のコンピューターは、複数の計算コア(マルチコア)を搭載するのが当たり前になりました。この計算能力を最大限に活用し、重い処理を高速化する強力な仕組みが、標準ライブラリに用意されています。
この記事では、標準アルゴリズムを「並列化」して実行する方法と、その際に陥りがちな重大な罠、そしてそれを回避するためのより良い設計について解説します。
実行ポリシーとは?アルゴリズムの「働き方」を決める
アルゴリズムの並列化は実行ポリシーを指定するだけで行えます。これは、アルゴリズムをどのように実行するかを指示するもので、<execution>
ヘッダで定義されています。
ポリシー | キーワード | 実行方法のイメージ 👨🍳 |
逐次実行 | std::execution::seq | 一人のシェフが、全ての工程を順番にこなす |
並列実行 | std::execution::par | 複数のシェフが、作業を分担して同時に進める |
逐次ベクトル化実行 | std::execution::unseq | 一人のシェフが、特殊な調理器具で一度に複数の材料を切る |
並列ベクトル化実行 | std::execution::par_unseq | 複数のシェフが、それぞれ特殊な調理器具を使って一斉に作業する |
ベクトル化とは、CPUの特別な機能を使い、一度の命令で複数のデータをまとめて処理する技術です。
安全な並列アルゴリズムの実践
使い方は非常にシンプルで、使い慣れたアルゴリズムの最初の引数に実行ポリシーを渡すだけです。sort
やfind_if
のように、他のデータに影響を与えない(副作用のない)処理は、安全に並列化できます。
#include <iostream>
#include <vector>
#include <algorithm>
#include <execution>
// センサーデータを表示するヘルパー関数
void display_readings(const std::vector<float>& readings) {
for (float r : readings) {
std::cout << r << " ";
}
std::cout << std::endl;
}
int main() {
std::vector<float> sensor_readings = {25.3f, 18.1f, 33.9f, 22.0f, 28.5f};
// 並列ポリシーを使ってデータを昇順にソート
std::sort(std::execution::par, sensor_readings.begin(), sensor_readings.end());
std::cout << "Sorted readings: ";
display_readings(sensor_readings);
// 並列ポリシーを使い、30.0以上の異常値を初めて探す
auto it = std::find_if(
std::execution::par,
sensor_readings.begin(), sensor_readings.end(),
[](float r) { return r >= 30.0f; }
);
if (it != sensor_readings.end()) {
std::cout << "High-value reading found: " << *it << std::endl;
}
return 0;
}
並列処理の重大な罠:データ競合
並列アルゴリズムが真価を発揮する一方で、使い方を誤るとデータ競合 (Data Race) という深刻な問題を引き起こします。これは、複数のスレッドが、保護されていない同じメモリ領域に同時に書き込みを行うことで発生し、データ破壊やクラッシュの直接的な原因となります。
危険なコードの例
次のコードは、共有の変数 total_count
に各スレッドが同時に書き込みを行うため、データ競合が発生します。結果は予測不能で、多くの場合、正しい合計値にはなりません。
#include <vector>
#include <algorithm>
#include <execution>
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int total_count = 0;
// 【危険なコード】
std::for_each(std::execution::par, data.begin(), data.end(),
[&](int /* value */) {
// 複数のスレッドが同時に total_count に書き込もうとする
total_count++;
});
// total_count の結果は保証されない!
基本的な解決策:Mutexによる保護
このデータ競合を防ぐ基本的な方法は、std::mutex
を使って共有リソースへのアクセスを一度に一つのスレッドに限定することです。
#include <mutex>
int total_count = 0;
std::mutex count_mutex;
std::for_each(std::execution::par, data.begin(), data.end(),
[&](int /* value */) {
// scoped_lockでロックを取得。このブロック内は一度に一つのスレッドしか入れない
std::scoped_lock lock(count_mutex);
total_count++;
});
これでプログラムは安全になりますが、スレッドがロックの解放を待つため、結局処理が順番待ちになり、並列化による高速化の恩恵はほとんど得られません。
より現代的で高速な解決策
多くの場合、ロックで性能を犠牲にするよりも、並列処理に適した別のアルゴリズムを選択する方が遥かに良い結果をもたらします。例えば、合計値を求めるなら、並列処理に対応したstd::reduce
を使うのが現代的なアプローチです。
#include <numeric> // reduce のために必要
// std::reduceは並列実行に対応しており、ロックなしで安全に合計を計算できる
int total_count = std::reduce(std::execution::par,
data.begin(), data.end(),
0, // 初期値
[](int current_sum, int /* value */) {
return current_sum + 1;
});
std::reduce
は、内部で各スレッドが部分的な合計を計算し、最後にそれらを安全に結合する、という効率的な処理を行います。これにより、ロックを使わずに安全かつ高速な並列計算が実現できます。
まとめ
標準ライブラリの並列アルゴリズムは、マルチコアの性能を引き出す強力なツールです。
sort
のように副作用のない処理は、実行ポリシーを追加するだけで簡単に高速化が期待できます。- 共有データを書き換える処理を並列化すると、データ競合が発生し、プログラムは壊れてしまいます。
- ロック(Mutex)で保護するのは最終手段です。多くの場合、
std::reduce
のように、その目的に特化した並列対応アルゴリズムを選択する方が、はるかに安全で高速なコードになります。
ツールを正しく理解し、問題に適したアルゴリズムを選択することが、効果的な並列プログラミングの鍵です。