C++ コア ガイドライン:同時実行と並列処理に関するその他のルール

マルチスレッド プログラムを作成するのは難しく、プログラムが正しい場合はさらに困難です。 C++ コア ガイドラインのルールは、正しいプログラムを作成するためのガイドです。この投稿のルールは、データ競合、データの共有、タスク、および悪名高いキーワード volatile を扱います。

詳細については、次の 5 つのルールをご覧ください。

  • CP.2:データ競合を避ける
  • CP.3:書き込み可能なデータの明示的な共有を最小限に抑える
  • CP.4:スレッドではなく、タスクの観点から考える
  • CP.8:volatile を使用しないでください 同期用

最初のルールに直接ジャンプさせてください。

CP.2:データ競合を避ける

前回の投稿で、データ競合という用語を定義しました。したがって、私はそれを短くすることができます。データ競合とは、データの書き込みと読み取りが同時に行われることです。結果は未定義の動作です。 C++ コア ガイドラインは、データ競合の典型的な例である静的変数を提供しています。

int get_id() {
 static int id = 1;
 return id++;
}

何が問題になる可能性がありますか?たとえば、スレッド A とスレッド B は、id に対して同じ値 k を読み取ります。その後、スレッド A とスレッド B は値 k + 1 を書き戻します。結局、id k + 1 は 2 つ存在します。

次の例は非常に驚くべきものです。ここに小さなスイッチ ブロックがあります:

unsigned val;

if (val < 5) {
 switch (val) {
 case 0: // ...
 case 1: // ...
 case 2: // ...
 case 3: // ...
 case 4: // ...
 }
}

コンパイラは、多くの場合、スイッチ ブロックをジャンプ テーブルとして実装します。概念的には、次のようになります。

if (val < 5){
 // (1)
 functions[val]();
}

この場合、functions[3]() は、val が 3 に等しい場合の switch ブロックの機能を表します。別のスレッドが起動して (1) の値を変更し、有効な範囲外になる可能性があります。範囲。もちろん、これは未定義の動作です。

CP.3:書き込み可能なデータの明示的な共有を最小限に抑える

これは従うのは簡単ですが、非常に重要なルールです。共有データの場合、一定である必要があります。

あとは、共有データがスレッドセーフな方法で初期化されるという課題を解決するだけです。 C++11 では、これを実現する方法がいくつかサポートされています。

<オール>
  • スレッドを開始する前にデータを初期化します。これは C++11 によるものではありませんが、多くの場合、非常に簡単に適用できます。
    const int val = 2011;
    thread t1([&val]{ .... };
    thread t2([&val]{ .... };
    
  • コンパイル時に初期化されるため、定数式を使用します。
    constexpr auto doub = 5.1;
    
  • 関数 std::call_once を std::once_flag と組み合わせて使用​​します。重要な初期化を関数 onlyOnceFunc に入れることができます。 C++ ランタイムは、この関数が 1 回だけ正常に実行されることを保証します。
    std::once_flag onceFlag;
    void do_once(){
     std::call_once(onceFlag, [](){ std::cout << "Important initialisation" << std::endl; });
    }
    std::thread t1(do_once);
    std::thread t2(do_once);
    std::thread t3(do_once);
    std::thread t4(do_once);
    
  • C++11 ランタイムは、静的変数がスレッドセーフな方法で初期化されることを保証するため、ブロック スコープで静的変数を使用します。
    void func(){
     .... 
    static int val 2011;
    .... } thread t5{ func() }; thread t6{ func() };
  • CP.4:スレッドではなくタスクの観点から考える

    初めに。タスクとはタスクは C++11 の用語で、約束と未来の 2 つのコンポーネントを表します。 C++ には、std::async、std::packaged_task、std::promise の 3 つのバリエーションがあります。タスクに関するいくつかの記事を既に書いています。

    スレッド、std::packaged_task、または std::promise には、非常に低レベルであるという共通点があります。したがって、std::async について書きます。

    以下は、3 + 4 の合計を計算するためのスレッドと、未来と約束のペアです。

    // thread
    int res;
    thread t([&]{ res = 3 + 4; });
    t.join();
    cout << res << endl;
    
    // task
    auto fut = async([]{ return 3 + 4; });
    cout << fut.get() << endl;
    

    スレッドとフューチャーとプロミスのペアの根本的な違いは何ですか? スレッドとは、何かを計算する方法に関するものです。タスクは何を計算するかに関するものです。

    もっと具体的に言ってみましょう。

    • スレッドは共有変数 res を使用して結果を提供します。対照的に、promise std::async は安全なデータ チャネルを使用して、その結果を将来の fut に伝達します。これは、特にスレッドにとって、res を保護する必要があることを意味します。
    • スレッドの場合は、明示的にスレッドを作成します。計算対象を指定するだけなので、これは promise には当てはまりません。

    CP.8:volatile を使用しないでください 同期用

    Java または C# でアトミックを使用する場合は、それを volatile として宣言します。 C++ では非常に簡単ですか? C++ でアトミックが必要な場合は、volatile を使用します。完全に間違っています。 volatile には、C++ のマルチスレッド セマンティックはありません。アトミックは C++11 では std::atomic と呼ばれます。

    さて、私は興味があります:C++ での volatile の意味は何ですか? volatile は、最適化された読み取りまたは書き込み操作が許可されていない特別なオブジェクト用です。

    volatile は通常、通常のプログラム フローとは無関係に変更できるオブジェクトを表すために組み込みプログラミングで使用されます。これらは、たとえば、外部デバイス (メモリ マップド I/O) を表すオブジェクトです。これらのオブジェクトは通常のプログラムから独立して変更できるため、その値は直接メイン メモリに書き込まれます。そのため、最適化されたキャッシュへの格納はありません。

    次は?

    正しいマルチスレッド化は難しいです。これが、考えられるすべてのツールを使用してコードを検証する必要がある理由です。動的コード アナライザー ThreadSanitizer と静的コード アナライザー CppMem には、本格的なマルチスレッド プログラマーのツールボックスに入れるべき 2 つのツールがあります。次の投稿で、その理由がわかります。