Race Conditions versus Data Races

Modernes C++  –  13 Kommentare

Race Conditions und Data Races sind ähnliche, aber doch verschiedene Konzepte. Da sie ähnlich sind, werden sie häufig verwechselt. Verschärfend kommt hinzu, dass beide Begriff ins Deutsche mit kritischem Wettlauf übersetzt werden. Um ehrlich zu sein, verwirrender kann es nicht sein. Gerade aber, wenn es um Gleichzeitigkeit geht, ist eine eindeutige Ausdrucksweise unentbehrlich.

Zuerst gilt es, die beiden Begriffe in der Softwaredomäne zu definieren.

  • Race Condition: Eine Race Condition ist eine Konstellation, in dem das Ergebnis einer Operation von der zeitlich verschränkten Ausführung von bestimmten anderen Operationen abhängt.
  • Data Race: Ein Data Race ist eine Konstellation, in der mindestens zwei Threads zu selben Zeit auf eine gemeinsame Variable zugreifen. Zumindest ein Thread versucht diese zu verändern.

Eine Race Condition ist per se nicht bösartig. Ein Race Condition kann die Ursache eines Data Race sein. Im Gegensatz dazu stellt ein Data Race undefiniertes Verhalten dar. Daher erübrigt sich jede weitere Analyse des Programms.

Bevor ich die verschiedenen Formen von Race Conditions vorstelle, die alle nicht gutartig sind, will ich ein Programm mit einer Race Condition und einem Data Race vorstellen.

Eine Race Condition und ein Data Race

Los geht es mit dem Klassiker. Ein Programm, das es erlaubt, Geld zwischen zwei Konten zu verschieben. Diese Programm besitzt sowohl ein Data Race als auch eine Race Condition.

// 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;

}

Ich habe den Workflow bewusst einfach gehalten, um meinen Punkt klarzumachen. Jeder Account startet mit einer Summe von 100 US-Dollar (1). Um Geld abzuheben, muss das Konto natürlich ausreichend gedeckt sein (2). Falls ausreichend Guthaben vorhanden ist, wird der Betrag zuerst vom alten Konto entfernt und dann zum neuen Konto hinzugefügt. Zwei Überweisungen finden statt (3). Eine von account1 auf account2 und eine mit vertauschten Rollen. Jede Überweisung findet nach der anderen statt. Die Überweisungen sind eine Art Transaktion und etablieren eine totale Ordnung. Das ist gut so.

Beide Accounts enthalten den richtigen Kontostand.

Im wahren Leben wird die Funktion transferMoney natürlich gleichzeitig ausgeführt.

Multithreading

Nun haben wir ein Data race und eine Race condition.

// 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;

}

Die Aufrufe von transferMoney (1) werden gleichzeitig ausgeführt. Die Argumente an eine Funktion, die in einem Thread verwendet werden, sind zu verschieben oder zu kopieren. Falls eine Referenz wie account1 oder account2 an die Thread-Funktion übergeben wird, ist diese in einen Referenz-Wrapper mithilfe von std::ref zu verpacken.

Nun haben wir ein Data Race und eine Race Condition. Die Aufrufe von transferMoney (1) werden gleichzeitig ausgeführt. Daher gibt es ein Data Race auf dem Kontostand balance in der Funktion transferMoney (2). Aber wo ist die Race Condition? Um sie sichtbar zu machen, lege ich die Threads einen kleinen Zeitraum schlafen (3). Das Built-in-Literal 1ns in dem Ausdruck std::this_thread::sleep_for(1ns) steht für eine Nanosekunde. In dem Artikel "Raw und Cooked" stelle ich die Details zu den neuen Built-in-Literalen vor. Seit dem C++14-Standard besitzen wir sie in C++ für Zeitdauern.

Nebenbei gesagt: Oft hilft das kurze Schlafenlegen eines Threads, um eine Race Condition sichtbar zu machen. Hier kommt die Ausgabe des Programms.

Hier ist das Problem. Nur der erste Aufruf von transferMoney wurde ausgeführt. Der zweite nicht, da der Kontostand zu niedrig war. Der Grund ist, dass die zweite Überweisung ausgeführt werden sollte, bevor die erste vollständig abgeschlossen war. Hier ist unsere Race Condition.

Das Data Race aufzulösen ist recht einfach. Die Operationen auf dem Kontostand müssen geschützt werden. Ich setze dazu atomare Variablen ein.

// 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;

}

Klar, die atomare Variable wird die Race Condition nicht in Wohlgefallen auflösen. Nur das Data Race existiert nicht mehr.

Wie geht's weiter?

Ich habe ein kleines Programm vorgestellt, dass sowohl ein Data Race als auch eine Race Condition besitzt. Aber es gibt viel mehr Varianten bösartiger Race Conditions. Den Bruch von Invarianten des Programms, Locking-Probleme wie Dead- oder Livelocks oder auch Lebenszeitprobleme von Hintergrund-Threads sind wohl die prominentesten Vertreter. Es gibt auch Data Races ohne Race Conditions. Im nächsten Artikel schreibe ich über bösartige Race Conditions.