Performancevergleich in C++: Atomic vs. Mutex
Mit der neuen Sprachversion aus 2011 unterstützt C++ nun endlich von sich aus Multi-Threading. Die Unterstützung geht über die Minimal-Funktionalität von Thread
, Mutex
und Condition
deutlich hinaus. In diesem Artikel werfe ich einen genaueren Blick auf Atomic-Variablen. Dabei interessiert mich insbesondere, welches Performance-Potential diese Datentypen gegenüber der klassischen Synchronisation bieten.
Mutex
Unter klassischer Synchronisation verstehe ich die Absicherung kritischer Bereiche durch Mutex-Variablen, wie sie beispielsweise auch POSIX unterstützt. In C++11 gibt es dafür die Klasse std::mutex
. Im folgenden Beispiel wird damit der Zugriff auf die Member-Variable _current
abgesichert.
class counter_mutex
{
std::mutex _mutex;
std::size_t _current{0};
public:
auto operator()() -> std::size_t
{
std::lock_guard<std::mutex> lock{_mutex};
return ++_current;
}
};
Ich habe mich bewusst für dieses sehr einfache Beispiel entschieden. Denn damit möchte ich später das Potential abschätzen, das Atomic-Variablen gegenüber dieser klassischen Synchronisation bieten. Die Instanzen dieser Klasse repräsentieren im Wesentlichen Zähler mit einer einzigen Member-Funktion. Die Funktion kann aus mehreren Threads parallel aufgerufen werden. Dabei erhöht sie jeweils den Zähler um eins und gibt den neuen Wert zurück.
Atomic
Eine entsprechende Klasse habe ich auch für Atomic-Variablen implementiert. Aus funktionaler Sicht ist sie zu obiger Klasse identisch. Der Variablenzugriff muss dabei nicht mehr durch eine Mutex-Variable synchronisiert werden, weil der Zugriff durch den verwendeten Datentyp bereits atomar ist.
class counter_atomic
{
std::atomic<std::size_t> _current{0};
public:
auto operator()() -> std::size_t
{
// implicitly uses memory model 'sequential consistency'
return ++_current;
}
};
Darüber hinaus bieten die Atomic-Variablen über einen zusätzlichen Parameter die Wahl zwischen verschiedenen Konsistenzmodellen an. In diesem Fall habe ich den Parameter nicht angegeben, wodurch implizit das strikteste Modell Sequential Consistency
verwendet wird. Das ist in diesem Fall zwar nicht notwendig, dafür wird der Vergleich mit obiger Klasse fairer, denn die Verwendung der Mutex-Variable impliziert ebenfalls dieses Konsistenzmodell.
Driver
Der Testtreiber für meinen Performance-Vergleich macht im Wesentlichen nichts anderes, als die Member-Funktion einer Counter-Instanz aus mehreren, parallel laufenden Threads heraus aufzurufen. Dabei wird die Gesamtanzahl der Aufrufe angegeben, wobei ich ignoriere, dass die Funktion für jeden Thread möglicherweise einmal zu häufig ausgeführt wird.
Die Implementierung des Testtreibers ist in keiner Weise ungewöhnlich. Aber gerade deswegen finde ich es interessant sie zu zeigen, um mal wieder darauf hinzuweisen, wie einfach es mit C++11 geworden ist, solche Funktionen zu schreiben – wenn man mal von der Ausnahmebehandlung absieht, die in diesem Beispiel nicht ganz sauber ist.
template <typename Counter>
void run(std::size_t concurrency, size_t bound, Counter&& counter)
{
auto&& worker = [&counter,bound] {
while (counter() < bound) {
// empty body
}
};
std::vector<std::thread> threads;
for (std::size_t i = 0; i < concurrency; ++i) {
threads.emplace_back(worker);
}
for (auto&& thread : threads) {
thread.join();
}
}
Der erste Parameter gibt die Anzahl der parallel laufenden Threads an, und durch den zweiten Parameter wird die Gesamtanzahl der Aufrufe beschränkt. Schließlich erwartet die Funktion als dritten und letzten Parameter die Zähler-Instanz, die aus allen Threads gemeinsam benutzt wird. Die Funktion wartet vor der Rückkehr auf das Ende aller gestarteten Threads. Da in diesem Beispiel alle Threads fast zeitgleich enden sollten, ist die Realzeit der Ausführung bereits ein guter Indikator für die Performance.
Performance
Die Tests führe ich auf einem Rechner mit Intel-Atom-CPU aus. Das ist der einzige Rechner, der mir gerade mit einer passenden Laufzeitumgebung zur Verfügung steht. Und obwohl er eine Dual-Core-CPU mit Hyper-Threading besitzt, so muss ich leider anmerken, dass er nicht repräsentativ für ein High-Performance-System ist. Allerdings finde ich das auch nicht wichtig. Denn auch der eigentliche Test ist aus meiner Sicht nicht repräsentativ. Und das Ziel der Messung ist lediglich eine Indikation für das Performance-Potential zu erhalten.
Zum Vergleich der beiden Implementierungen führe ich sie jeweils mit acht parallel laufenden Threads und insgesamt 100 Millionen Operation aus. Für die Mutex-Variante sieht das so aus:
run(8, 100000000, counter_mutex{});
real 45.887s
user 40.319s
sys 20.025s
Die Messung der Laufzeit und des CPU-Verbrauchs übernimmt die bash und bezieht sich auf die komplette Ausführung des Programms. In diesem Fall lief das Programm fast 46 Sekunden, wobei auffallend ist, dass ein signifikanter Anteil des CPU-Verbrauchs auf das System (Kernel) fällt und die Parallelität des Systems nicht voll ausgeschöpft wird.
run(8, 100000000, counter_atomic{});
real 3.288s
user 12.985s
sys 0.008s
Die Implementierung mit Atomic-Variable benötigt dagegen nur knapp drei Sekunden und ist damit ungefähr 14 Mal so schnell. Im Gegensatz zu der Implementierung mit Mutex ist der CPU-Verbrauch des Systems dabei vernachlässigbar. Außerdem ist erkennbar, dass die Parallelität der Hardware anscheinend weitgehend ausgenutzt wurde.
Natürlich hängt das Verhalten im Allgemeinen sehr stark von der Hardware und der Laufzeitumgebung ab. In diesem Fall habe ich allerdings eine relativ einfache Erklärung für die Performance-Unterschiede. Der große Overhead der Mutex-Variante führe ich darauf zurück, dass das Betriebssystem Threads im Falle eines Konflikts schlafen legt, bis die Mutex-Variable wieder freigeben wird. Dieses häufige Aktivieren und Deaktivieren von Threads verursacht den System-Overhead und reduziert gleichzeitig die Parallelität. Im Gegensatz dazu sind die Atomic-Variablen vollkommen unabhängig vom Betriebssystem. Lediglich die CPU muss sich darum kümmern, dass die Zugriffe atomar erfolgen. Und in die x86‑Architektur wurde in diesem Bereich relativ viel Arbeit investiert, um diesen Overhead möglichst klein zu halten.
Fazit
Das Beispiel ist nicht repräsentativ. Dennoch zeigt es deutlich, dass Atomic-Variablen im Gegensatz zu der klassischen Synchronisation mit Mutex-Variablen ein großes Potential bieten. Andererseits zeigt es aber auch, in welchen Bereichen solche Optimierungen überhaupt erst interessant werden: Maßnahmen dieser Art rechnen sich erst bei weit über 10.000 Operationen pro Sekunde, was sie für die meisten Anwendungen bedeutungslos macht.
Atomic-Variablen können die Performance verbessern – sie können jedoch nicht alle Skalierungsprobleme auf Rechnern mit vielen CPU-Kernen lösen. Denn trotz der Hardware-Unterstützung führt die Serialisierung der Ausführung zu einem beträchtlichen Overhead. In vielen Fällen hilft nur, gemeinsam benutzten Speicher zu vermeiden. Doch dazu später mehr ...