C++ コア ガイドライン:スレッド間でデータを共有する

スレッドを楽しみたい場合は、変更可能なデータをスレッド間で共有する必要があります。データ競合を起こさず、したがって未定義の動作を発生させないようにするには、スレッドの同期について考える必要があります。

この投稿の 3 つのルールは、経験豊富なマルチスレッド開発者にとっては非常に明白かもしれませんが、マルチスレッド ドメインの初心者にとっては非常に重要です。

  • CP.20:プレーンな lock() ではなく、RAII を使用してください /unlock()
  • CP.21:std::lock() を使用 または std::scoped_lock 複数の mutex を取得する え
  • CP.22:ロックを保持している間は未知のコードを呼び出さないでください (コールバックなど)

最も明白なルールから始めましょう。

CP.20:プレーン lock() ではなく RAII を使用 /unlock()

裸のミューテックスはありません! ミューテックスを常にロックしてください。ミューテックスが範囲外になると、ロックは自動的にミューテックスを解放 (ロック解除) します。 RAII は R の略です リソース A 買収 初期化とは、リソースの有効期間をローカル変数の有効期間にバインドすることを意味します。 C++ は、ローカルの有効期間を自動的に管理します。

std::lock_guard、std::unique_lock、std::shared_lock (C++14)、または std::std::scoped_lock (C++17) はこのパターンを実装しますが、スマート ポインター std::unique_ptr および std も実装します。 ::shared_ptr.以前の投稿 Garbage Collection - No Thanks で詳細を RAII に説明しています。

これはマルチスレッド コードにとって何を意味しますか?

std::mutex mtx;

void do_stuff()
{
 mtx.lock();
 // ... do stuff ... (1)
 mtx.unlock();
}

(1) で例外が発生した場合でも、mtx のロックを解除するのを忘れただけでも問題ありません。どちらの場合も、別のスレッドが std::mutex mtx を取得 (ロック) しようとすると、デッドロックが発生します。救いは明らかです。

std::mutex mtx;

void do_stuff()
{
 std::lock_guard<std::mutex> lck {mtx};
 // ... do stuff ...
} // (1)

ミューテックスをロックすると、(1) で lck が範囲外になるため、ミューテックスは自動的にロック解除されます。

CP.21:std::lock() を使用 または std::scoped_lock mutexを複数取得する エス

スレッドに複数のミューテックスが必要な場合は、同じ順序でミューテックスをロックするように細心の注意を払う必要があります。そうでない場合、スレッドの不適切なインターリーブによってデッドロックが発生する可能性があります。次のプログラムはデッドロックを引き起こします。

// lockGuardDeadlock.cpp

#include <iostream>
#include <chrono>
#include <mutex>
#include <thread>

struct CriticalData{
 std::mutex mut;
};

void deadLock(CriticalData& a, CriticalData& b){

 std::lock_guard<std::mutex>guard1(a.mut); // (2) 
 std::cout << "Thread: " << std::this_thread::get_id() << std::endl;

 std::this_thread::sleep_for(std::chrono::milliseconds(1));
 
 std::lock_guard<std::mutex>guard2(b.mut); // (2)
 std::cout << "Thread: " << std::this_thread::get_id() << std::endl;
 
 // do something with a and b (critical region) (3)
}

int main(){

 std::cout << std::endl;

 CriticalData c1;
 CriticalData c2;

 std::thread t1([&]{deadLock(c1, c2);}); // (1)
 std::thread t2([&]{deadLock(c2, c1);}); // (1)

 t1.join();
 t2.join();

 std::cout << std::endl;

}

スレッド t1 と t2 は、ジョブを実行するために 2 つのリソース CriticalData を必要とします (3)。 CriticalData には、アクセスを同期するための独自のミューテックス mut があります。残念ながら、どちらも引数 c1 と c2 を使用して関数デッドロックを呼び出す順序が異なります (1)。これで競合状態が発生しました。スレッド t1 が最初のミューテックス a.mut をロックできても、2 番目のミューテックス b.mut をロックできない場合、その間にスレッド t2 が 2 番目のミューテックスをロックすると、デッドロックが発生します (2)。

デッドロックを解決する最も簡単な方法は、両方のミューテックスをアトミックにロックすることです。

C++11 では、std::lock と一緒に std::unique_lock を使用できます。 std::unique_lock そのミューテックスのロックを延期できます。アトミックな方法で任意の数のミューテックスをロックできる関数 std::lock は、最後にロックを行います。

void deadLock(CriticalData& a, CriticalData& b){
 std::unique_lock<mutex> guard1(a.mut, std::defer_lock);
 std::unique_lock<mutex> guard2(b.mut, std::defer_lock);
 std::lock(guard1, guard2);
 // do something with a and b (critical region)
}

C++17 では、std::scoped_lock は 1 回のアトミック操作で任意の数のミューテックスをロックできます。

void deadLock(CriticalData& a, CriticalData& b){
 std::scoped_lock(a.mut, b.mut);
 // do something with a and b (critical region
}

CP.22:ロックを保持している間は未知のコードを呼び出さない (コールバックなど)

このコード スニペットが本当に悪いのはなぜですか?


std::mutex m;
{ std::lock_guard<std::mutex> lockGuard(m); sharedVariable = unknownFunction(); }

私はunknownFunctionについて推測することしかできません。機能が不明な場合

  • ミューテックス m をロックしようとしますが、これは未定義の動作です。ほとんどの場合、デッドロックが発生します。
  • ミューテックス m をロックしようとする新しいスレッドを開始すると、デッドロックが発生します。
  • 別のミューテックス m2 をロックすると、2 つのミューテックス m と m2 を同時にロックするため、デッドロックが発生する可能性があります。別のスレッドが同じミューテックスを別の順序でロックする可能性があります。
  • 直接的または間接的にミューテックス m をロックしようとはしません。すべて問題ないようです。同僚が関数を変更できるか、関数が動的にリンクされているため、別のバージョンが取得されるため、「らしい」と感じます。すべての賭けは、何が起こるかに対して開かれています。
  • 期待どおりに動作しますが、関数 unknownFunction にかかる時間がわからないため、パフォーマンスの問題が発生する可能性があります。マルチスレッド プログラムであることを意味するものは、シングルスレッド プログラムになる可能性があります。

これらの問題を解決するには、ローカル変数を使用してください:

std::mutex m;
auto tempVar = unknownFunction(); { std::lock_guard<std::mutex> lockGuard(m); sharedVariable = tempVar; }

この追加の間接化により、すべての問題が解決されます。 tempVar はローカル変数であり、データ競合の犠牲になることはありません。これは、同期メカニズムなしで unknownFunction を呼び出すことができることを意味します。さらに、ロックを保持する時間は、tempVar の値を sharedVariable に代入することで最小限に短縮されます。

次は?

作成したスレッドの子で join または detach を呼び出さない場合、子はそのデストラクタで std::terminate 例外をスローします。 std::terminate デフォルトの std::abort ごとの呼び出し。この問題を解決するために、ガイドライン サポート ライブラリには gsl::joining_thread があり、そのスコープの最後で join を呼び出します。次の投稿で gsl::joining_thread を詳しく見ていきます。