Die Boost C++ Bibliotheken

Kapitel 45. Boost.Atomic

Boost.Atomic bietet eine Klasse boost::atomic an, mit der atomare Variablen erstellt werden können. Atomare Variablen heißen so, weil Zugriffe auf diese atomar sind. Boost.Atomic wird in Multithreaded-Programmen verwendet, wenn der Zugriff auf eine Variable in einem Thread nicht von einem Zugriff auf dieselbe Variable in einem anderen Thread unterbrochen werden können soll. Ohne boost::atomic müssten Zugriffe auf von Threads geteilte Variablen mit Locks synchronisiert werden.

Beachten Sie, dass boost::atomic darauf angewiesen ist, dass Zielplattformen atomare Variablenzugriffe unterstützen. Andernfalls greift boost::atomic auf Locks zu. Die Bibliothek bietet eine Möglichkeit herauszufinden, ob atomare Variablenzugriffe auf einer Zielplattform unterstützt werden.

Wenn Ihre Entwicklungsumgebung C++11 unterstützt, können Sie Boost.Atomic links liegen lassen. Die C++11-Standardbibliothek enthält eine Headerdatei atomic, die die gleichen Klassen und Funktionen wie Boost.Atomic zur Verfügung stellt. So finden Sie dort zum Beispiel eine Klasse std::atomic.

Boost.Atomic unterstützt annähernd den gleichen Funktionsumfang wie die Standardbibliothek. Während einige Funktionen in Boost.Atomic mehrfach überladen sind, können sie in der Standardbibliothek unterschiedliche Namen haben. Mit std::kill_dependency() unterstützt die Standardbibliothek außerdem eine Funktion, die in Boost.Atomic fehlt.

Beispiel 45.1. boost::atomic in Aktion
#include <boost/atomic.hpp>
#include <thread>
#include <iostream>

boost::atomic<int> a{0};

void thread()
{
  ++a;
}

int main()
{
  std::thread t1{thread};
  std::thread t2{thread};
  t1.join();
  t2.join();
  std::cout << a << '\n';
}

Im Beispiel 45.1 greifen zwei Threads auf die int-Variable a zu, um sie zu inkrementieren. Anstatt auf einen Lock zuzugreifen, wird boost::atomic verwendet, um den Zugriff auf a zu synchronisieren. Wenn Sie das Beispielprogramm ausführen, wird 2 ausgegeben.

Der Trick hinter boost::atomic ist, dass es Prozessoren gibt, die atomare Zugriffe auf Variablen unterstützen. Ist die Inkrementierung einer int-Variablen eine atomare Operation, ist ein Lock nicht notwendig. Wird das Beispielprogramm auf einer Plattform ausgeführt, die keine atomare Inkrementierung einer int-Variablen kennt, verwendet boost::atomic einen Lock.

Beispiel 45.2. boost::atomic mit oder ohne Lock
#include <boost/atomic.hpp>
#include <iostream>

int main()
{
  std::cout.setf(std::ios::boolalpha);

  boost::atomic<short> s;
  std::cout << s.is_lock_free() << '\n';

  boost::atomic<int> i;
  std::cout << i.is_lock_free() << '\n';

  boost::atomic<long> l;
  std::cout << l.is_lock_free() << '\n';
}

Sie können für eine atomare Variable is_lock_free() aufrufen, um zu überprüfen, ob der Zugriff auf diese Variable ohne Lock stattfindet. Wenn Sie das Beispielprogramm auf einem Intel x86-Prozessor ausführen, wird dreimal true ausgegeben. Führen Sie es auf einem Prozessor aus, der keinen lock-freien Zugriff auf short-, int- und long-Variablen bietet, wird false ausgegeben.

Boost.Atomic bietet mit BOOST_ATOMIC_INT_LOCK_FREE oder BOOST_ATOMIC_LONG_LOCK_FREE Makros an, um auch zur Kompilierung festzustellen, welche Typen einen lock-freien Zugriff unterstützen.

Beachten Sie, dass im Beispiel 45.2 ausschließlich integrale Typen verwendet werden. Sie dürfen boost::atomic nicht mit Klassen wie std::string oder std::vector verwenden. Boost.Atomic unterstützt integrale Typen, triviale Klassen, Zeiger und bool. Beispiele für integrale Typen sind short, int oder long. Triviale Klassen definieren Objekte, die mit std::memcpy() kopiert werden können.

Beispiel 45.3. boost::atomic mit boost::memory_order_seq_cst
#include <boost/atomic.hpp>
#include <thread>
#include <iostream>

boost::atomic<int> a{0};

void thread()
{
  a.fetch_add(1, boost::memory_order_seq_cst);
}

int main()
{
  std::thread t1{thread};
  std::thread t2{thread};
  t1.join();
  t2.join();
  std::cout << a << '\n';
}

Beispiel 45.3 inkrementiert a zweimal – diesmal nicht mit operator++, sondern über den Aufruf der Methode fetch_add(). Diese Methode erwartet als Parameter nicht nur eine Zahl, um die a erhöht werden soll. Es muss außerdem eine Memory-Order übergeben werden.

Die Memory-Order bestimmt, in welcher Reihenfolge Zugriffe auf den Speicher erfolgen müssen. Diese Reihenfolge ist standardmäßig nicht festgelegt und ergibt sich nicht aus der Reihenfolge von Code-Zeilen. So dürfen Compiler und Prozessor Reihenfolgen beliebig ändern, solange sich ein Programm von außen betrachtet so verhält als wären Speicherzugriffe in der Reihenfolge erfolgt, wie sie im Quellcode niedergeschrieben sind. Diese Regel gilt jedoch ausschließlich pro Thread. Wird mehr als ein Thread verwendet, kann eine geänderte Reihenfolge von Speicherzugriffen dazu führen, dass sich ein Programm fehlerhaft verhält. Boost.Atomic ermöglicht es, beim Zugriff auf Variablen eine Memory-Order anzugeben, um sicherzustellen, dass Speicherzugriffe in einem Multithreaded-Programm in der gewünschten Reihenfolge erfolgen.

Anmerkung

Beachten Sie, dass Boost.Atomic nicht nur eine höhere Performance ermöglicht, indem genau die Memory-Order angegeben werden können, die benötigt werden. Memory-Order erhöhen den Schwierigkeitsgrad beträchtlich. Code wird durch Memory-Order wesentlich komplizierter.

Im Beispiel 45.3 wird die Memory-Order boost::memory_order_seq_cst verwendet, um a um 1 zu erhöhen. Die Memory-Order steht für sequentielle Konsistenz – auf Englisch sequential consistency. Dies ist die restriktivste Memory-Order. Sie bedeutet, dass alle Speicherzugriffe, die im Code vor dem Aufruf von fetch_add() angegeben sind, auch vor dem Aufruf dieser Methode erfolgen müssen. Alle Speicherzugriffe, die im Code nach dem Aufruf von fetch_add() angegeben sind, müssen nach dem Aufruf dieser Methode erfolgen. Compiler und Prozessor dürfen Speicherzugriffe vor und nach dem Aufruf von fetch_add() beliebig ordnen. Sie dürfen aber zum Beispiel keinen Speicherzugriff, der vor dem Aufruf von fetch_add() im Code angegeben ist, nach dem Aufruf dieser Methode vornehmen. boost::memory_order_seq_cst ist eine scharfe Grenze, die nach oben und unten gilt.

boost::memory_order_seq_cst ist die restriktivste Memory-Order. Sie wird standardmäßig verwendet, wenn Sie auf boost::atomic-Variablen zugreifen und keine Memory-Order explizit angeben. Die Inkrementierung von a mit operator++ im Beispiel 45.1 erfolgte ebenfalls über boost::memory_order_seq_cst.

boost::memory_order_seq_cst ist nicht immer notwendig. So gibt es im Beispiel 45.3 keinen Grund, Speicherzugriffe auf andere Variablen zu synchronisieren – es gibt schließlich keine anderen Variablen, auf die beide Threads zugreifen oder die von a abhängen. a wird in main() auf die Standardausgabe ausgegeben. Dies erfolgt jedoch, nachdem beide Threads beendet wurden. Der Aufruf von join() garantiert, dass der Wert in a erst dann gelesen wird, wenn die Threads beendet wurden.

Beispiel 45.4. boost::atomic mit memory_order_relaxed
#include <boost/atomic.hpp>
#include <thread>
#include <iostream>

boost::atomic<int> a{0};

void thread()
{
  a.fetch_add(1, boost::memory_order_relaxed);
}

int main()
{
  std::thread t1{thread};
  std::thread t2{thread};
  t1.join();
  t2.join();
  std::cout << a << '\n';
}

Im Beispiel 45.4 wurde die Memory-Order in boost::memory_order_relaxed geändert. Dies ist die am wenigsten restriktive Memory-Order: Sie erlaubt eine beliebige Neuordnung der Speicherzugriffe. Das Beispielprogramm funktioniert auch mit dieser Memory-Order wie gewünscht, weil es außer auf die Variable a keine anderen Speicherzugriffe in den Threads gibt. Es ist keine bestimmte Reihenfolge von Speicherzugriffen nötig.

Beispiel 45.5. boost::atomic mit memory_order_release und memory_order_acquire
#include <boost/atomic.hpp>
#include <thread>
#include <iostream>

boost::atomic<int> a{0};
int b = 0;

void thread1()
{
  b = 1;
  a.store(1, boost::memory_order_release);
}

void thread2()
{
  while (a.load(boost::memory_order_acquire) != 1)
    ;
  std::cout << b << '\n';
}

int main()
{
  std::thread t1{thread1};
  std::thread t2{thread2};
  t1.join();
  t2.join();
}

Zwischen der restriktivsten Memory-Order boost::memory_order_seq_cst und der am wenigsten restriktiven boost::memory_order_relaxed liegen mehrere Abstufungen. Im Beispiel 45.5 werden die Memory-Order boost::memory_order_release und boost::memory_order_acquire vorgestellt.

boost::memory_order_release stellt sicher, dass Speicherzugriffe, die im Code vor boost::memory_order_release stattfinden, auch tatsächlich vorher geschehen. Compiler und Prozessor dürfen Speicherzugriffe von vor boost::memory_order_release nicht danach ausführen. Sie dürfen jedoch Speicherzugriffe, die im Code hinter boost::memory_order_release stehen, vorziehen.

boost::memory_order_acquire funktioniert genauso wie boost::memory_order_release, bezieht sich aber auf Speicherzugriffe nach boost::memory_order_acquire. Während boost::memory_order_release eine Schranke für vorhergehende Speicherzugriffe ist, schränkt boost::memory_order_acquire nachfolgende Speicherzugriffe ein. Diese dürfen bei boost::memory_order_acquire nicht vorgezogen werden, während vorhergehende Speicherzugriffe durchaus nachgezogen werden dürfen.

Im Beispiel 45.5 wird im ersten Thread boost::memory_order_release eingesetzt, um sicherzustellen, dass b auf 1 gesetzt ist, bevor a auf 1 gesetzt wird. boost::memory_order_release garantiert, dass der Speicherzugriff auf b nicht nach dem Speicherzugriff auf a erfolgt.

Um beim Zugriff auf a eine Memory-Order angeben zu können, wird die Methode store() aufgerufen. Sie entspricht einer Zuweisung mit operator=.

Im zweiten Thread wird in einer Schleife a gelesen. Dies erfolgt über die Methode load(). Hier wird ebenfalls nicht auf den Zuweisungsoperator zugegriffen, damit eine Memory-Order angeben werden kann. Dies ist hier boost::memory_order_acquire.

boost::memory_order_acquire stellt im zweiten Thread sicher, dass der Speicherzugriff auf b nicht vor dem Zugriff auf a erfolgt. Der zweite Thread wartet in der Schleife darauf, dass a vom ersten Thread auf 1 gesetzt wird. Ist dies geschehen, wird b ausgegeben.

Wenn Sie das Beispielprogramm ausführen, wird 1 auf die Standardausgabe ausgegeben. Die verwendeten Memory-Order stellen sicher, dass die Speicherzugriffe in der richtigen Reihenfolge erfolgen. Der erste Thread schreibt immer zuerst 1 in b, bevor der zweite Thread auf b zugreift und den Wert ausliest.

Tipp

Weiterführende Informationen rund um atomare Variablen finden Sie zum Beispiel in einem Artikel über Memory Order im GCC-Wiki.