悪意のある競合状態とデータ競合

この投稿は、悪意のある競合状態とデータ競合に関するものです。悪意のある競合状態とは、不変条件の破壊、スレッドのブロックの問題、または変数の有効期間の問題を引き起こす競合状態です。

最初に、競合状態とは何かを思い出させてください。

  • 競合状態: 競合状態とは、操作の結果が特定の個々の操作のインターリーブに依存する状況です。

それは出発点として問題ありません。競合状態は、プログラムの不変条件を破る可能性があります。

不変条件の解除

前回の記事「競合状態とデータ競合」では、2 つのアカウント間の送金を使用してデータ競合を示しました。問題のない競合状態が含まれていました。正直なところ、悪意のある競合状態もありました.

悪意のある競合状態は、プログラムの不変条件を破ります。不変条件は、すべての残高の合計が常に同じ金額になることです。各アカウントは 100 (1) から始まるため、この場合は 200 です。簡単にするために、単位はユーロにする必要があります。送金してお金を作りたくないし、壊したくもない。

// breakingInvariant.cpp

#include <atomic>
#include <functional>
#include <iostream>
#include <thread>

struct Account{
 std::atomic<int> balance{100}; // 1
};
 
void transferMoney(int amount, Account& from, Account& to){
 using namespace std::chrono_literals;
 if (from.balance >= amount){
 from.balance -= amount; 
 std::this_thread::sleep_for(1ns); // 2
 to.balance += amount;
 }
}

 void printSum(Account& a1, Account& a2){
 std::cout << (a1.balance + a2.balance) << std::endl; // 3
}

int main(){
 
 std::cout << std::endl;

 Account acc1;
 Account acc2;
 
 std::cout << "Initial sum: "; 
 printSum(acc1, acc2); // 4
 
 std::thread thr1(transferMoney, 5, std::ref(acc1), std::ref(acc2));
 std::thread thr2(transferMoney, 13, std::ref(acc2), std::ref(acc1));
 std::cout << "Intermediate sum: "; 
 std::thread thr3(printSum, std::ref(acc1), std::ref(acc2)); // 5
 
 thr1.join();
 thr2.join();
 thr3.join();
 // 6
 std::cout << " acc1.balance: " << acc1.balance << std::endl;
 std::cout << " acc2.balance: " << acc2.balance << std::endl;
 
 std::cout << "Final sum: ";
 printSum(acc1, acc2); // 8
 
 std::cout << std::endl;

}

当初、アカウントの合計は 200 ユーロです。 (4) 関数 printSum (3) を使用して合計を表示します。行 (5) は、不変条件を可視化します。行 (2) には 1ns の短いスリープがあるため、中間合計は 182 ユーロです。最後に、すべて問題ありません。各口座には適切な残高 (6) があり、合計は 200 ユーロ (8) です。

これがプログラムの出力です。

悪質な話は続きます。述語なしで条件変数を使用してデッドロックを作成しましょう。

競合状態によるブロッキングの問題

私の主張を明確にするためだけに。述語と組み合わせて条件変数を使用する必要があります。詳細については、私の投稿の条件変数をお読みください。そうでない場合、プログラムは偽のウェイクアップまたはウェイクアップの喪失の犠牲になる可能性があります。

条件変数を述語なしで使用すると、待機スレッドが待機状態になる前に、通知スレッドが通知を送信することがあります。したがって、待機中のスレッドは永久に待機します。この現象はロスト ウェイクアップと呼ばれます。

これがプログラムです。

// conditionVariableBlock.cpp

#include <iostream>
#include <condition_variable>
#include <mutex>
#include <thread>

std::mutex mutex_;
std::condition_variable condVar;

bool dataReady;


void waitingForWork(){

 std::cout << "Worker: Waiting for work." << std::endl;

 std::unique_lock<std::mutex> lck(mutex_);
 condVar.wait(lck); // 3
 // do the work
 std::cout << "Work done." << std::endl;

}

void setDataReady(){

 std::cout << "Sender: Data is ready." << std::endl;
 condVar.notify_one(); // 1

}

int main(){

 std::cout << std::endl;

 std::thread t1(setDataReady);
 std::thread t2(waitingForWork); // 2

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

 std::cout << std::endl;
 
}

プログラムの最初の呼び出しは正常に機能します。 2 番目の呼び出しは、スレッド t2 (2) が待機状態 (3) になる前に通知呼び出し (1) が発生するため、ロックされます。

もちろん、デッドロックとライブロックは競合状態の他の影響です。デッドロックは一般に、スレッドのインターリーブに依存し、発生する場合と発生しない場合があります。ライブロックはデッドロックに似ています。デッドロックがブロックしている間、ライブロックが進行しているように見えます。見た目に重点が置かれています。トランザクション メモリのユース ケースにおけるトランザクションについて考えてみましょう。トランザクションをコミットするたびに、競合が発生します。したがって、ロールバックが発生します。トランザクション メモリに関する私の投稿はこちらです。

変数の存続期間の問題を示すことはそれほど難しくありません。

変数の存続期間の問題

生涯発行のレシピはとてもシンプルです。作成したスレッドをバックグラウンドで実行すると、半分は完了です。つまり、作成者スレッドは、その子が完了するまで待機しません。この場合、子供が作成者のものを使用しないように細心の注意を払う必要があります。

// lifetimeIssues.cpp

#include <iostream>
#include <string>
#include <thread>

int main(){
 
 std::cout << "Begin:" << std::endl; // 2 

 std::string mess{"Child thread"};

 std::thread t([&mess]{ std::cout << mess << std::endl;});
 t.detach(); // 1
 
 std::cout << "End:" << std::endl; // 3

}

これは単純すぎる。スレッド t は std::cout と変数 mess を使用しています。どちらもメインスレッドに属しています。その結果、2 回目の実行で子スレッドの出力が表示されなくなります。 "Begin:" (2) と "End:" (3) のみが表示されます。

はっきりと強調したい。この投稿のすべてのプログラムは、データ競合なしでこの時点までです。ご存じのとおり、競合状態とデータ競合について書くのは私の考えでした。これらは関連していますが、異なる概念です。

競合状態なしでデータ競合を作成することもできます.

競合状態のないデータ競合

しかし、最初に、データ競合とは何かを思い出させてください。

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

// addMoney.cpp

#include <functional>
#include <iostream>
#include <thread>
#include <vector>

struct Account{
 int balance{100}; // 1
};

void addMoney(Account& to, int amount){
 to.balance += amount; // 2
}

int main(){
 
 std::cout << std::endl;

 Account account;
 
 std::vector<std::thread> vecThreads(100);
 
 // 3
 for (auto& thr: vecThreads) thr = std::thread( addMoney, std::ref(account), 50);
 
 for (auto& thr: vecThreads) thr.join();
 
 // 4
 std::cout << "account.balance: " << account.balance << std::endl;
 
 std::cout << std::endl;

}

100 スレッドで 50 ユーロ (3) が同じアカウント (1) に追加されます。関数 addMoney を使用します。重要な観察は、アカウントへの書き込みが同期なしで行われることです。したがって、データ競合が発生し、有効な結果が得られません。これは未定義の動作であり、最終残高 (4) は 5000 ユーロと 5100 ユーロの間で異なります。

次は?

コンカレンシー カンファレンスで、ノンブロッキング、ロックフリー、ウェイトフリーという用語についての議論をよく耳にします。それでは、次の投稿でこれらの用語について書きましょう。