競合条件とデータ競合

競合状態とデータ競合は関連していますが、概念は異なります。それらは関連しているため、しばしば混同されます。ドイツ語では、kritischer Wettlauf という用語で両方の表現を翻訳しています .正直言って、それはとても悪いことです。並行性について推論するには、言葉遣いが正確でなければなりません。したがって、この投稿は競合状態とデータ競合に関するものです。

最初に、ソフトウェアの領域で両方の用語を定義させてください。

  • 競合状態: 競合状態とは、操作の結果が特定の個々の操作のインターリーブに依存する状況です。
  • データ競合 :データ競合とは、少なくとも 2 つのスレッドが同時に共有変数にアクセスする状況です。少なくとも 1 つのスレッドが変数を変更しようとしています。

競合状態自体は悪くありません。競合状態は、データ競合の原因になる可能性があります。逆に、データ競合は未定義の動作です。したがって、あなたのプログラムに関するすべての推論は、もはや意味をなしません。

良性ではないさまざまな種類の競合状態を紹介する前に、競合状態とデータ競合を含むプログラムを示したいと思います。

競合状態とデータ競合

競合状態とデータ競合の典型的な例は、あるアカウントから別のアカウントに送金する機能です。シングルスレッドの場合、すべて問題ありません。

シングルスレッド

// account.cpp

#include <iostream>

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

void transferMoney(int amount, Account& from, Account& to){
 if (from.balance >= amount){ // 2
 from.balance -= amount; 
 to.balance += amount;
 }
}

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

 Account account1;
 Account account2;

 transferMoney(50, account1, account2); // 3
 transferMoney(130, account2, account1);
 
 std::cout << "account1.balance: " << account1.balance << std::endl;
 std::cout << "account2.balance: " << account2.balance << std::endl;
 
 std::cout << std::endl;

}

私の主張を明確にするために、ワークフローは非常に単純です。各アカウントは 100 $ (1) の残高から始まります。お金を引き出すには、口座に十分なお金がなければなりません (2)。十分な金額が利用可能な場合、その金額は最初に古いアカウントから削除され、次に新しいアカウントに追加されます。 2 回の送金が行われます (3)。アカウント 1 からアカウント 2 へ、およびその逆です。 transferMoney の各呼び出しは、次々に発生します。それらは、全体的な順序を確立する一種のトランザクションです。それは結構です。

両方の口座の残高は良さそうです。

実際には、transferMoney は同時に実行されます。

マルチスレッド

いいえ、データ競合と競合状態があります。

// accountThread.cpp

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

struct Account{
 int balance{100};
};
 // 2
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); // 3
 to.balance += amount;
 }
}

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

 Account account1;
 Account account2;
 // 1
 std::thread thr1(transferMoney, 50, std::ref(account1), std::ref(account2));
 std::thread thr2(transferMoney, 130, std::ref(account2), std::ref(account1));
 
 thr1.join();
 thr2.join();

 std::cout << "account1.balance: " << account1.balance << std::endl;
 std::cout << "account2.balance: " << account2.balance << std::endl;
 
 std::cout << std::endl;

}

transferMoney の呼び出しは同時に実行されます (1)。スレッドによって実行される関数への引数は、値によって移動またはコピーする必要があります。 account1 や account2 などの参照をスレッド関数に渡す必要がある場合は、std::ref などの参照ラッパーでラップする必要があります。スレッド t1 と t2 のために、関数 transferMoney (2) で口座の残高にデータ競合があります。しかし、競合状態はどこにありますか?競合状態を可視化するために、スレッドを短時間スリープさせます (3)。式 std::this_thread::sleep_for(1ns) の組み込みリテラル 1ns は、ナノ秒を表します。この投稿では、Raw と Cooked は新しい組み込みリテラルの詳細です。 C++14 以降、一定期間使用しています。

ところで。多くの場合、並行プログラムでの短いスリープ期間で、問題を可視化するのに十分です。

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

そして、あなたが見る。最初の関数 transferMoney のみが実行されました。バランスが小さすぎたため、2つ目は実行されませんでした。その理由は、最初の送金が完了する前に 2 回目の出金が行われたためです。ここに競合状態があります。

データ競合を解決するのは非常に簡単です。天びんの操作は保護する必要があります。アトミック変数でやった.

// accountThreadAtomic.cpp

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

struct Account{
 std::atomic<int> balance{100};
};

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);
 to.balance += amount;
 }
}

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

 Account account1;
 Account account2;
 
 std::thread thr1(transferMoney, 50, std::ref(account1), std::ref(account2));
 std::thread thr2(transferMoney, 130, std::ref(account2), std::ref(account1));
 
 thr1.join();
 thr2.join();

 std::cout << "account1.balance: " << account1.balance << std::endl;
 std::cout << "account2.balance: " << account2.balance << std::endl;
 
 std::cout << std::endl;

}

もちろん、アトミック変数は競合状態を解決しません。データ競争だけがなくなりました。

次は?

データ競合と競合状態を持つ誤ったプログラムを提示しただけです。しかし、悪意のある競合状態にはさまざまな側面があります。不変条件の解除、デッドロックやライブロックなどのロックの問題、または切り離されたスレッドの存続期間の問題。競合状態のないデッドロックもあります。次の投稿では、競合状態の悪意のある影響について書きます。