C++ コア ガイドライン:同時実行と並列処理のルール

C++11 は、同時実行性を扱う最初の C++ 標準です。並行性の基本的な構成要素はスレッドです。したがって、ほとんどのルールは明示的にスレッドに関するものです。これは C++17 で劇的に変わりました。

C++17 を使用 標準テンプレート ライブラリ (STL) の並列アルゴリズムを取得しました。つまり、STL のほとんどのアルゴリズムは、順次、並列、またはベクトル化して実行できます。好奇心旺盛な読者のために:私はすでに並列 STL について 2 つの記事を書いています。標準テンプレート ライブラリの並列アルゴリズムの投稿では、既存のアルゴリズムを順次、並列、または並列に実行してベクトル化するために使用できる実行ポリシーについて説明しています。 C++17 では、並列実行またはベクトル化を目的とした新しいアルゴリズムも提供されました。詳細は次のとおりです:C++17:標準テンプレート ライブラリの新しい並列アルゴリズム。

C++ での並行処理の話は続きます。 C++20 では、Future、コルーチン、トランザクションなどの拡張が期待できます。俯瞰すると、C++11 と C++14 の同時実行機能は、C++17 と C++20 の高度な抽象化が基づいている実装の詳細にすぎません。これは、C++20 でのコンカレント フューチャーに関する一連の投稿です。

GCCもClangもMSVCもSTLの並列アルゴリズムを完全に実装していないため、ルールは主にスレッドに関するものであると述べました。利用できない機能 (並列 STL) や標準化されていない機能に対して書かれたベスト プラクティスは存在しません。

これは、ルールを読むときに覚えておくべき最初のルールです。これらのルールは、C++11 および C++14 で利用可能なマルチスレッドに関するものです。心に留めておくべき 2 番目のルールは、マルチスレッドは非常に難しいということです。これは、ルールがこの分野の専門家ではなく、初心者にガイダンスを提供することを意味します。メモリ モデルへのルールは、将来に従います。

それでは、最初のルールに飛び込みましょう。

CP.1:コードが一部として実行されると仮定するマルチスレッドプログラムの

初めてこのルールを読んだときは驚きました。特殊なケースに合わせて最適化する必要があるのはなぜですか?明確にするために、このルールは主に、アプリケーションではなくライブラリで使用されるコードに関するものです。また、経験から、ライブラリ コードは再利用されることが多いことがわかります。これは、一般的なケースに合わせて最適化する可能性があることを意味しますが、これで問題ありません。

ルールの要点を明確にするために、ここに小さな例を示します。

double cached_computation(double x)
{
 static double cached_x = 0.0; // (1)
 static double cached_result = COMPUTATION_OF_ZERO; // (2)
 double result;

 if (cached_x == x) // (1)
 return cached_result; // (2)
 result = computation(x);
 cached_x = x; // (1)
 cached_result = result; // (2)
 return result;
}

関数 cached_computation は、シングルスレッド環境で実行される場合はまったく問題ありません。静的変数 cached_x (1) および cached_result (2) は多くのスレッドで同時に使用でき、使用中に変更されるため、これはマルチスレッド環境には当てはまりません。 C++11 標準は、cached_x や cached_result などのブロック スコープを持つ静的変数にマルチスレッド セマンティクスを追加します。 ブロック スコープを持つ静的変数は、スレッド セーフな方法で C++11 で初期化されます。

これは問題ありませんが、私たちの場合には役に立ちません。多くのスレッドから同時に cached_computation を呼び出すと、データ競合が発生します。 C++ のマルチスレッドでは、データ競合の概念が非常に重要です。したがって、それについて書きましょう。

データ競争 少なくとも 2 つのスレッドが同時に共有変数にアクセスする状況です。少なくとも 1 つのスレッドが変数を変更しようとしています。

残りは非常に簡単です。プログラムにデータ競合がある場合、プログラムは未定義の動作をします。未定義の動作とは、すべてが発生する可能性があるため、プログラムについてこれ以上推論できないことを意味します。私はすべてを意味します。私のセミナーでは、「プログラムに未定義の動作がある場合、キャッチファイアのセマンティクスがある」とよく言います。コンピューターでさえ発火する可能性があります。

データ競合の定義を注意深く読むと、データ競合を発生させるには可変状態を共有する必要があることがわかります。これは、この観察結果を非常に明確にするための写真です。

では、データ競合をなくすにはどうすればよいでしょうか?静的変数 cached_x (1) および cached_result (2) を不変 (const) にすることは意味がありません。これは、両方の static を共有してはならないことを意味します。これを実現する方法をいくつか紹介します。

<オール>
  • 両方の静電気を独自のロックで保護します。
  • 1 つのロックを使用して、重要な領域全体を保護します。
  • 関数 cached_computation の呼び出しをロックで保護します。
  • 両方を静的な thread_local にします。 thread_local は、各スレッドがその変数 cached_x と cached_result を取得することを保証します。静的変数などはメイン スレッドの存続期間にバインドされ、thread_local 変数はそのスレッドの存続期間にバインドされます。
  • ここにバリエーション 1、2、3、および 4 があります。

    std::mutex m_x;
    std::mutex m_result;
    double cached_computation(double x){ // (1)
     static double cached_x = 0.0;
     static double cached_result = COMPUTATION_OF_ZERO;
     
     double result;
     {
     std::scoped_lock(m_x, m_result);
     if (cached_x == x) return cached_result;
     }
     result = computation(x);
     {
     std::lock_guard<std::mutex> lck(m_x);
     cached_x = x;
     }
     { 
     std::lock_guard<std::mutex> lck(m_result); 
     cached_result = result;
     }
     return result;
    }
    
    std::mutex m;
    double cached_computation(double x){ // (2)
     static double cached_x = 0.0;
     static double cached_result = COMPUTATION_OF_ZERO;
     double result;
     {
     std::lock_guard<std::mutex> lck(m);
     if (cached_x == x) return cached_result;
     result = computation(x);
     cached_x = x;
     cached_result = result;
     }
     return result;
    }
    
    std::mutex cachedComputationMutex; // (3)
    {
     std::lock_guard<std::mutex> lck(cachedComputationMutex);
     auto cached = cached_computation(3.33);
    }
    
    
    double cached_computation(double x){ // (4)
     thread_local double cached_x = 0.0;
     thread_local double cached_result = COMPUTATION_OF_ZERO;
     double result;
    
     if (cached_x == x) return cached_result;
     result = computation(x);
     cached_x = x;
     cached_result = result;
     return result;
    }
    

    まず、C++11 標準では、静的変数がスレッドセーフな方法で初期化されることが保証されています。したがって、すべてのプログラムで初期化を保護する必要はありません。

    <オール>
  • このバージョンは、アトミック ステップで両方のロックを取得する必要があるため、少し注意が必要です。 C++17 は、アトミック ステップで任意の数のミューテックスをロックできる std::scoped_lock をサポートします。 C++11 では、関数 std::lock と組み合わせて、std::unque_lock の代わりに使用する必要があります。私の以前の投稿 Prefer Locks to Mutexes で詳細を説明しています。 このソリューションでは、cached_x と cached_result に競合状態が発生します。これらはアトミックにアクセスする必要があるためです。
  • バージョン 2 では、より粗粒度のロックが使用されます。通常、バージョンのような粗粒度のロックを使用するのではなく、細粒度のロックを使用する必要がありますが、このユース ケースでは問題ない場合があります。
  • 関数全体がロックされているため、これは最も粗粒度のソリューションです。もちろん、欠点は、関数のユーザーが同期を担当することです。一般的に、それは悪い考えです。
  • 静的変数を thread_local にすれば完了です
  • 最終的には、パフォーマンスとユーザーの問題です。したがって、各バリエーションを試し、測定し、コードを使用および保守する必要がある人について考えてください。

    次は?

    この投稿は、ルールから C++ の同時実行性への長い旅の出発点にすぎません。次の投稿では、スレッドと共有状態について取り上げます。