C++

Neue Parallelitätsfunktionen in Visual C++ 11

Diego Dagum

Die neueste C++-Iteration, die auch als C++11 bezeichnet wird und im vergangenen Jahr von der ISO (International Organization for Standardization; Internationale Organisation für Normung) genehmigt wurde, macht einen neuen Satz von Bibliotheken und einige reservierte Wörter zum Behandeln von Parallelität zur reinen Formsache. Viele Entwickler haben Parallelität bereits in C++ verwendet, jedoch stets über eine Drittanbieterbibliothek, wobei die APIs des Betriebssystems unmittelbar offengelegt werden.

Herb Sutter hat im Dezember 2004 erklärt, dass Schluss war mit dem „kostenlosen Lunchkonzept für mehr Leistungsfähigkeit“, weil CPU-Hersteller aufgrund des physischen Energieverbrauchs und stärkerer Wärmeentwicklung keine schnelleren CPUs ausliefern konnten. Dies führte zur Entwicklung der aktuellen, etablierten Mehrkern-CPUs – eine neue Realität, auf die C++ als Standardprogramm gerade mit großen Schritten zuging.

Der Rest dieses Artikels ist in zwei Hauptabschnitte sowie kleinere Unterabschnitte eingeteilt. Der erste Hauptabschnitt, der mit der parallelen Ausführung beginnt, behandelt Technologien, mit denen die Anwendungen unabhängige oder semi-unabhängige Aktivitäten parallel ausführen können. Der zweite Hauptabschnitt, der mit der Synchronisierung der gleichzeitigen Ausführung beginnt, untersucht Mechanismen zur Synchronisierung der Datenverarbeitungsmethode durch diese Aktivitäten, um somit zur Vermeidung von Racebedingungen beizutragen.

Dieser Artikel basiert auf Funktionen, die in der kommenden Version von C++ (vorläufig als Visual C++ 11 bezeichnet) enthalten sind. Einige Funktionen stehen bereits in der aktuellen Version, Visual C++ 2010, zur Verfügung. Obwohl dieser Artikel weder als Richtlinie zum Erstellen paralleler Algorithmen noch als vollständige Dokumentation zu allen verfügbaren Optionen zu verstehen ist, stellt er eine fundierte Einleitung in die neuen Parallelitätsfunktionen von C++11 dar.

Parallele Ausführung

Beim Entwickeln von Prozessen und Erstellen von Algorithmen für Daten besteht die natürliche Tendenz darin, sie in einer Schrittfolge anzugeben. So lange die Leistung innerhalb der zulässigen Grenzen liegt, ist dies das empfehlenswerteste Schema, da es normalerweise einfacher zu verstehen ist – eine Voraussetzung für verwaltbare Codebasen.

Wenn der Leistungsfaktor allmählich besorgniserregende Ausmaße annimmt, wird als klassischer erster Ansatz zur Bewältigung der Situation der sequenzielle Algorithmus optimiert, um die verbrauchten CPU-Zyklen zu reduzieren. Dies kann so lange durchgeführt werden, bis Sie einen Punkt erreichen, an dem keine weitere Optimierung möglich oder nur schwer zu erzielen ist. Und dann ist die Zeit gekommen, die sequenzielle Schrittfolge in Aktivitäten gleichzeitiger Instanzen zu teilen.

Im ersten Abschnitt erhalten Sie Informationen zu den folgenden Themen:

  • Asynchrone Aufgaben: die kleineren Teile des ursprünglichen Algorithmus, der nur durch die Daten verknüpft ist, die von diesen Teilen erstellt oder genutzt werden.
  • Threads: Ausführungseinheiten, die von der Laufzeitumgebung verwaltet werden. Sie beziehen sich auf die Aufgaben dahingehend, dass die Aufgaben auf Threads ausgeführt werden.
  • Interne Threads: threadgebundene Variablen, die von Threads usw. übertragenen Ausnahmen.

Asynchrone Aufgaben

Im Begleitcode zu diesem Artikel finden Sie ein Projekt mit dem Titel „Sequenzieller Fall“, wie in Abbildung 1 dargestellt.

Abbildung 1 Code des sequenziellen Falls

int a, b, c;
int calculateA()
{
  return a+a*b;
}
int calculateB()
{
  return a*(a+a*(a+1));
}
int calculateC()
{
  return b*(b+1)-b;
}
int main(int argc, char *argv[])
{
  getUserData(); // initializes a and b
  c = calculateA() * (calculateB() + calculateC());
  showResult();
}

Die Hauptfunktion fragt den Benutzer nach einigen Daten und übermittelt diese dann an drei Funktionen: calculateA, calculateB und calculateC. Die Ergebnisse werden zu einem späteren Zeitpunkt miteinander kombiniert, um Ausgangsinformationen für den Benutzer zu erstellen.

Die Berechnungsfunktionen im Begleitmaterial sind so codiert, dass für diese Funktionen eine zufällige Verzögerung zwischen einer und drei Sekunden liegt. In Anbetracht dessen, dass diese Schritte sequenziell ausgeführt werden, ergibt dies nach der Dateneingabe schlimmstenfalls eine gesamte Ausführungszeit von neun Sekunden. Sie können diesen Code ausprobieren, indem Sie F5 drücken und das Beispiel ausführen.

Folglich muss ich die Ausführungssequenz korrigieren und Schritte ausarbeiten, die gleichzeitig ausgeführt werden können. Da diese Funktionen unabhängig sind, kann ich sie mithilfe der asynchronen Funktion parallel ausführen:

int main(int argc, char *argv[])

{

  getUserData();

  future<int> f1 = async(calculateB), f2 = async(calculateC);

  c = (calculateA() + f1.get()) * f2.get();

  showResult();

}

Ich habe hier zwei Konzepte vorgestellt: async und future, die beide in der <future>-Kopfzeile und im std-Namespace definiert sind. Das erste Konzept erhält eine Funktion, eine Lambda-Funktion oder ein Funktionsobjekt (Funktor) und gibt einen future-Wert zurück. Sie können den future-Wert als Platzhalter für ein eventuelles Ergebnis verstehen. Welches Ergebnis denn? Natürlich das Ergebnis, das von der asynchronen Funktion zurückgegeben wurde.

An einem bestimmten Punkt benötige ich die Ergebnisse dieser parallel ausgeführten Funktionen. Durch den Aufruf der get-Methode für jeden future-Wert wird die Ausführung so lange blockiert, bis der Wert verfügbar ist.

Sie können den korrigierten Code testen und mit dem sequenziellen Fall vergleichen, indem Sie das AsyncTasks-Projekt im Begleitbeispiel ausführen. Schlimmstenfalls beträgt die Verzögerung dieser Änderung bis zu drei Sekunden im Vergleich zu neun Sekunden bei der sequenziellen Version.

Dies ist ein vereinfachtes Programmiermodell, bei dem der Entwickler keine Threads mehr erstellen muss. Sie können jedoch Threading-Richtlinien festlegen, auf die ich allerdings an dieser Stelle nicht näher eingehe.

Threads

Das im vorigen Abschnitt vorgestellte asynchrone Aufgabenmodell ist möglicherweise in einigen bestimmten Situationen ausreichend. Für eine tiefer gehende Behandlung und Steuerung der Ausführung von Threads verfügt C++11 jedoch über Thread-Klassen, die in der <thread>-Kopfzeile definiert und im std-Namespace enthalten sind.

Obwohl Threads ein komplexeres Programmiermodell darstellen, bieten sie bessere Methoden zur Synchronisierung und Koordination, sodass sie eine Ausführung an einen anderen Thread abgeben und vor dem Fortsetzen des Vorgangs einen festgelegten Zeitraum warten bzw. so lange warten, bis ein anderer Thread abgeschlossen wurde.

Im folgenden Beispiel (verfügbar im Threads-Projekt des Begleitcodes) verwende ich eine Lambda-Funktion, die angesichts des Ganzzahlarguments ihr Vielfaches von weniger als 100.000 auf der Konsole ausgibt:

auto multiple_finder = [](int n) {

  for (int i = 0; i < 100000; i++)

    if (i%n==0)

      cout << i << " is a multiple of " << n << endl;

};

int main(int argc, char *argv[])

{

  thread th(multiple_finder, 23456);

  multiple_finder(34567);

  th.join();

}

Wie in weiteren Beispielen deutlich wird, spielt es keine so große Rolle, dass ich eine Lambda-Funktion an den Thread übergeben habe. Eine Funktion oder ein Funktor hätte auch ausgereicht.

In der Hauptfunktion führe ich diese Funktion in zwei Threads mit verschiedenen Parametern aus. Sehen Sie sich mein Ergebnis an (das aufgrund unterschiedlicher Zeitangaben zwischen verschiedenen Ausführungen variieren kann):

0 is a multiple of 23456
0 is a multiple of 34567
23456 is a multiple of 23456
34567 is a multiple of 34567
46912 is a multiple of 23456
69134 is a multiple of 34567
70368 is a multiple of 23456
93824 is a multiple of 23456

Eventuell implementiere ich das Beispiel zu den im vorherigen Abschnitt genannten asynchronen Aufgaben mit Threads. Hierfür muss ich das promise-Konzept einführen. Ein promise-Wert kann als Senke verstanden werden, durch die ein Ergebnis bei Verfügbarkeit abgelegt wird. Wo wird das Ergebnis ausgegeben, sobald es abgelegt wurde? Jeder promise-Wert wird einem future-Wert zugeordnet.

Der in Abbildung 2 dargestellte Code, der im Promises-Projekt des Beispielcodes verfügbar ist, verknüpft drei Threads (anstatt Aufgaben) mit promise-Werten und wandelt jeden Threadaufruf in eine calculate-Funktion um. Vergleichen Sie diese Angaben mit der leichteren AsyncTasks-Version.

Abbildung 2 Verknüpfen von future-Werten mit promise-Werten

typedef int (*calculate)(void);
void func2promise(calculate f, promise<int> &p)
{
  p.set_value(f());
}
int main(int argc, char *argv[])
{
  getUserData();
  promise<int> p1, p2;
  future<int> f1 = p1.get_future(), f2 = p2.get_future();
  thread t1(&func2promise, calculateB, std::ref(p1)),
    t2(&func2promise, calculateC, std::ref(p2));
  c = (calculateA() + f1.get()) * f2.get();
  t1.join(); t2.join();
  showResult();
}

Threadgebundene Variablen und Ausnahmen

In C++ können Sie globale Variablen definieren, deren Geltungsbereich an die gesamte Anwendung gebunden ist, einschließlich der Threads. Abhängig von den Threads können diese globalen Variablen nun so definiert werden, dass jeder Thread seine eigene Kopie beibehält. Dieses Konzept wird als threadlokaler Speicher bezeichnet und ist wie folgt definiert:

thread_local int subtotal = 0;

Wenn die Definition im Rahmen einer Funktion erfolgt, ist die Sichtbarkeit der Variable auf diese Funktion begrenzt, jedoch behält jeder Thread seine eigene statische Kopie bei. Das heißt, dass Werte der Variable pro Thread zwischen Funktionsaufrufen beibehalten werden.

Obwohl thread_local in Visual C++11 nicht verfügbar ist, kann diese Variable mit einer nicht standardmäßigen Microsoft-Erweiterung simuliert werden:

#define  thread_local __declspec(thread)

Was geschieht, wenn eine Ausnahme in einem Thread ausgelöst werden würde? In manchen Fällen kann die Ausnahme abgefangen und in der Aufrufliste im Thread verarbeitet werden. Wenn der Thread die Ausnahme jedoch nicht verarbeitet, benötigen Sie eine Methode, um die Ausnahme in den Initiator-Thread zu übertragen. C++11 bietet solche Methoden.

In Abbildung 3, die im Begleitcode des ThreadInternals-Projekts verfügbar ist, gibt es die Funktion sum_until_element_with_threshold, die einen Vektor durchquert, bis sie ein spezifisches Element findet. Hierbei werden alle Elemente summiert. Wenn die Summe einen Schwellenwert überschreitet, wird eine Ausnahme ausgelöst.

Abbildung 3 Threadlokaler Speicher und Thread-Ausnahmen

thread_local unsigned sum_total = 0;
void sum_until_element_with_threshold(unsigned element,
  unsigned threshold, exception_ptr& pExc)
{
  try{
    find_if_not(begin(v), end(v), [=](const unsigned i) -> bool {
      bool ret = (i!=element);
      sum_total+= i;
      if (sum_total>threshold)
        throw runtime_error("Sum exceeded threshold.");
      return ret;
    });
    cout << "(Thread #" << this_thread::get_id() << ") " <<
      "Sum of elements until " << element << " is found: " << sum_total << endl;
  } catch (...) {
    pExc = current_exception();
  }
}

Wenn dies geschieht, wird die Ausnahme über current_exception in einer exception_ptr-Funktion erfasst.

Die Hauptfunktion löst einen Thread für sum_until_element_with_threshold aus, während die gleiche Funktion mit einem anderen Parameter aufgerufen wird. Nachdem beide Aufrufe abgeschlossen wurden (ein Aufruf im Hauptthread und ein Aufruf in dem Thread, der von diesem ausgelöst wird), werden die entsprechenden exception_ptr-Funktionen analysiert:

const unsigned THRESHOLD = 100000;
vector<unsigned> v;
int main(int argc, char *argv[])
{
  exception_ptr pExc1, pExc2;
  scramble_vector(1000);
  thread th(sum_until_element_with_threshold, 0, THRESHOLD, ref(pExc1));
  sum_until_element_with_threshold(100, THRESHOLD, ref(pExc2));
  th.join();
  dealWithExceptionIfAny(pExc1);
  dealWithExceptionIfAny(pExc2);
}

Wenn eine dieser exception_ptr-Funktionen initialisiert wird – ein Zeichen, dass eine Ausnahme aufgetreten ist – werden ihre Ausnahmen mit rethrow_exception zurück ausgelöst:

void dealWithExceptionIfAny(exception_ptr pExc)
{
  try
  {
    if (!(pExc==exception_ptr()))
      rethrow_exception(pExc);
    } catch (const exception& exc) {
      cout << "(Main thread) Exception received from thread: " <<
        exc.what() << endl;
  }
}

Dies ist das Ergebnis unserer Ausführung, da die Summe im zweiten Thread den Schwellenwert überschritten hat:

(Thread #10164) Sum of elements until 0 is found: 94574
(Main thread) Exception received from thread: Sum exceeded threshold.

Synchronisierung der gleichzeitigen Ausführung

Es wäre wünschenswert, wenn alle Anwendungen in einen zu 100 Prozent unabhängigen Satz asynchroner Aufgaben eingeteilt werden könnten. In der Praxis ist dies jedoch nahezu ausgeschlossen, da es mindestens Abhängigkeiten für die Daten gibt, die alle Parteien gleichzeitig verarbeiten. In diesem Abschnitt werden neue C++11-Technologien vorgestellt, mit denen Racebedingungen vermieden werden können.

Hier erhalten Sie Informationen zu folgenden Themen:

  • Unteilbare Typen: ähnlich dem primitiven Datentyp, ermöglicht jedoch threadsichere Änderungen.
  • Mutexe und Sperren: Elemente, mit denen threadsichere kritische Bereiche definiert werden können.
  • Bedingungsvariablen: eine Methode, Threads von der Ausführung zu sperren, bis bestimmte Kriterien erfüllt sind.

Unteilbare Typen

Die <atomic>-Kopfzeile enthält eine Reihe primitiver Datentypen, z. B. atomic_char, atomic_int usw., die hinsichtlich ineinandergreifender Vorgänge implementiert werden. Folglich sind diese Typen mit ihren Homonymen identisch, zwar ohne das atomic_-Präfix, jedoch mit dem Unterschied, dass alle Zuweisungsoperatoren (==, ++, --, +=, *= usw.) vor Racebedingungen geschützt sind. Daher wird es nicht passieren, dass mitten in einer Zuweisung für diese Datentypen ein anderer Thread den Vorgang unterbricht und Werte ändert, bevor wir fertig sind.

Im folgenden Beispiel gibt es zwei parallele Threads (einer davon ist der Hauptthread), die in demselben Vektor nach verschiedenen Elementen suchen:

atomic_uint total_iterations;
vector<unsigned> v;
int main(int argc, char *argv[])
{
  total_iterations = 0;
  scramble_vector(1000);
  thread th(find_element, 0);
  find_element(100);
  th.join();
  cout << total_iterations << " total iterations." << endl;
 }

Wenn das jeweilige Element gefunden wurde, gibt das Thread eine Meldung aus, die auf die Fundstelle des Elements im Vektor (oder in der Iteration) hinweist:

void find_element(unsigned element)
{
  unsigned iterations = 0;
  find_if(begin(v), end(v), [=, &iterations](const unsigned i) -> bool {
    ++iterations;
    return (i==element);
  });
  total_iterations+= iterations;
  cout << "Thread #" << this_thread::get_id() << ": found after " <<
    iterations << " iterations." << endl;
}

Es gibt auch eine gemeinsame Variable, total_iterations, die mit der zusammengefassten Anzahl der von beiden Threads angewendeten Iterationen aktualisiert wird. Daher muss die total_iterations-Variable unteilbar sein, um zu verhindern, dass beide Threads diese gleichzeitig aktualisieren. Obwohl Sie die Teilanzahl der Iterationen im vorigen Beispiel nicht in der find_element-Variable ausgeben mussten, sammeln sich dennoch Iterationen in dieser lokalen Variable anstatt in total_iterations an, um einen Konflikt hinsichtlich der unteilbaren Variable zu vermeiden.

Das vorige Beispiel finden Sie im Atomics-Projekt des Begleitcodedownloads. Ich habe es ausgeführt und folgendes Ergebnis erhalten:

Thread #8064: found after 168 iterations.
Thread #6948: found after 395 iterations.
563 total iterations.

Gegenseitiger Ausschluss (Mutex) und Sperren

Im vorherigen Abschnitt wurde ein bestimmter Fall des gegenseitigen Ausschlusses für den Schreibzugriff auf primitive Typen erläutert. Die <mutex>-Kopfzeile definiert eine Reihe von sperrbaren Klassen zum Abgrenzen kritischer Bereiche. So können Sie einen Mutex definieren, um einen kritischen Bereich in einer gesamten Funktions- oder Methodenreihe festzulegen. Hierbei kann nur jeweils ein Thread auf ein beliebiges Element in dieser Reihe zugreifen, indem dessen Mutex erfolgreich gesperrt wird.

Ein Thread, der versucht, einen Mutex zu sperren, kann entweder so lange blockiert bleiben, bis das Mutex verfügbar ist, oder ganz fehlschlagen. Als Alternative zu diesen beiden Situationen kann die timed_mutex-Klasse für einen geringen Zeitraum blockiert bleiben, bevor sie fehlschlägt. Wenn Sperrversuche unterlassen werden, lassen sich Deadlocks vermeiden.

Ein gesperrtes Mutex muss für andere Benutzer ausdrücklich entsperrt werden, damit sie es sperren können. Wenn dies nicht geschieht, ist möglicherweise ein unbestimmtes Anwendungsverhalten die Folge, das fehleranfällig sein kann – vergleichbar mit dem Fall, wenn Sie vergessen, den dynamischen Arbeitsspeicher freizugeben. Tatsächlich ist es jedoch schlimmer, wenn Sie vergessen, eine Sperre freizugeben. Dies könnte nämlich bedeuten, dass die Anwendung nicht mehr einwandfrei funktioniert, wenn ein anderer Code weiterhin auf diese Sperre wartet. Glücklicherweise verfügt C++11 auch über Sperrklassen. Eine Sperre wirkt auf einen Mutex, jedoch gewährleistet dessen Destruktor die Freigabe, falls es gesperrt ist.

Der folgende Code, der im Mutex-Projekt des Codedownloads enthalten ist, definiert einen kritischen Bereich um einen mx-Mutex:

mutex mx;
void funcA();
void funcB();
int main()
{
  thread th(funcA)
  funcB();
  th.join();
}

Dieses Mutex gewährleistet, dass die zwei Funktionen funcA und funcB parallel ausgeführt werden können, ohne im kritischen Bereich zusammenzutreffen.

Die funcA-Funktion wartet gegebenenfalls, um in den kritischen Bereich zu gelangen. Damit dies überhaupt möglich ist, benötigen Sie den einfachsten Sperrmechanismus, nämlich lock_guard:

void funcA()
{
  for (int i = 0; i<3; ++i)
  {
    this_thread::sleep_for(chrono::seconds(1));
    cout << this_thread::get_id() << ": locking with wait... " << endl;
    lock_guard<mutex> lg(mx);
    ... // Do something in the critical region.
    cout << this_thread::get_id() << ": releasing lock." << endl;
  }
}

Per Definition sollte funcA drei Mal auf den kritischen Bereich zugreifen. Die funcB-Funktion versucht jedoch, diesen zu sperren. Wenn das Mutex zu diesem Zeitpunkt allerdings bereits gesperrt ist, wartet funcB einfach für eine Sekunde, bevor es erneut versucht, auf den kritischen Bereich zuzugreifen. Als Mechanismus wird unique_lock mit der try_to_lock_t-Richtlinie verwendet, wie in Abbildung 4 dargestellt.

Abbildung 4 Sperre mit Wartefunktion

void funcB()
{
  int successful_attempts = 0;
  for (int i = 0; i<5; ++i)
  {
    unique_lock<mutex> ul(mx, try_to_lock_t());
    if (ul)
    {
      ++successful_attempts;
      cout << this_thread::get_id() << ": lock attempt successful." <<
        endl;
      ... // Do something in the critical region
      cout << this_thread::get_id() << ": releasing lock." << endl;
    } else {
      cout << this_thread::get_id() <<
        ": lock attempt unsuccessful. Hibernating..." << endl;
      this_thread::sleep_for(chrono::seconds(1));
    }
  }
  cout << this_thread::get_id() << ": " << successful_attempts
    << " successful attempts." << endl;
}

Per Definition versucht funcB bis zu fünf Mal, auf den kritischen Bereich zuzugreifen. Abbildung 5 zeigt das Ergebnis der Ausführung. Von den fünf Versuchen sollte funcB der Zugriff auf den kritischen Bereich nur zwei Mal gelingen.

Abbildung 5 Ausführen des Mutex im Beispielprojekt

funcB: lock attempt successful.
funcA: locking with wait ...
funcB: releasing lock.
funcA: lock secured ...
funcB: lock attempt unsuccessful. Hibernating ...
funcA: releasing lock.
funcB: lock attempt successful.
funcA: locking with wait ...
funcB: releasing lock.
funcA: lock secured ...
funcB: lock attempt unsuccessful. Hibernating ...
funcB: lock attempt unsuccessful. Hibernating ...
funcA: releasing lock.
funcB: 2 successful attempts.
funcA: locking with wait ...
funcA: lock secured ...
funcA: releasing lock.

Bedingungsvariablen

Die <condition_variable>-Kopfzeile enthält die letzte in diesem Artikel behandelte Möglichkeit, die für solche Fälle grundlegend ist, bei denen die Koordination zwischen Threads an Ereignisse gebunden ist.

Im folgenden Beispiel, das im CondVar-Projekt des Codedownloads enthalten ist, verschiebt eine producer-Funktion Elemente in eine Warteschlange:

mutex mq;
condition_variable cv;
queue<int> q;
void producer()
{
  for (int i = 0;i<3;++i)
  {
    ... // Produce element
    cout << "Producer: element " << i << " queued." << endl;
    mq.lock();      q.push(i);  mq.unlock();
    cv.notify_all();
  }
}

Die Standardwarteschlange ist nicht threadsicher, daher müssen Sie darauf achten, dass sie beim Queuing von keinem anderen Benutzer verwendet wird (d. h. die consumer-Funktion kein Element abruft).

Die consumer-Funktion versucht, Elemente aus der Warteschlange abzurufen, sobald sie verfügbar sind, oder sie wartet eine Zeitlang auf die Bedingungsvariable, bevor sie es erneut versucht. Nach zwei aufeinanderfolgenden fehlgeschlagenen Versuchen wird die consumer-Funktion beendet (siehe Abbildung 6).

Abbildung 6 Aktivieren von Threads mittels Bedingungsvariablen

void consumer()
{
  unique_lock<mutex> l(m);
  int failed_attempts = 0;
  while (true)
  {
    mq.lock();
    if (q.size())
    {
      int elem = q.front();
      q.pop();
      mq.unlock();
      failed_attempts = 0;
      cout << "Consumer: fetching " << elem << " from queue." << endl;
      ... // Consume elem
    } else {
      mq.unlock();
      if (++failed_attempts>1)
      {
        cout << "Consumer: too many failed attempts -> Exiting." << endl;
        break;
      } else {
        cout << "Consumer: queue not ready -> going to sleep." << endl;
        cv.wait_for(l, chrono::seconds(5));
      }
    }
  }
}

Wenn ein neues Element verfügbar ist, muss die consumer-Funktion stets über notify_all von der producer-Funktion aktiviert werden. So kann die producer-Funktion verhindern, dass die consumer-Funktion während des gesamten Intervalls inaktiv ist, wenn Elemente bereitstehen.

Abbildung 7 zeigt das Ergebnis meiner Ausführung.

Abbildung 7 Synchronisierung mit Bedingungsvariablen

Consumer: queue not ready -> going to sleep.
Producer: element 0 queued.
Consumer: fetching 0 from queue.
Consumer: queue not ready -> going to sleep.
Producer: element 1 queued.
Consumer: fetching 1 from queue.
Consumer: queue not ready -> going to sleep.
Producer: element 2 queued.
Producer: element 3 queued.
Consumer: fetching 2 from queue.
Producer: element 4 queued.
Consumer: fetching 3 from queue.
Consumer: fetching 4 from queue.
Consumer: queue not ready -> going to sleep.
Consumer: two consecutive failed attempts -> Exiting.

Eine allgemeine Übersicht

Kurz zur Wiederholung: Dieser Artikel bietet einen konzeptuellen Überblick über die in C++11 eingeführten Mechanismen, mit denen eine parallele Ausführung auch in Mehrkern-Computerumgebungen gar nicht mehr wegzudenken ist.

Asynchrone Aufgaben sorgen für ein kompaktes Programmiermodell zur Parallelisierung der Ausführung. Die Ergebnisse der jeweiligen Aufgabe können über eine zugeordnete future-Funktion abgerufen werden.

Threads bieten zusammen mit den Mechanismen zum Beibehalten separater Kopien von statischen Variablen und zum Übertragen von Ausnahmen zwischen Threads eine höhere Genauigkeit, auch wenn sie komplizierter sind.

Da parallele Threads sich auf allgemeine Daten auswirken, stellt C++11 Ressourcen zum Vermeiden von Racebedingungen bereit. Unteilbare Typen stellen eine zuverlässige Methode dar, die gewährleistet, dass Daten nur jeweils von einem Thread nacheinander geändert werden.

Mutexe erleichtern die Festlegung kritischer Bereiche im gesamten Code. Dies sind Bereiche, auf die Threads nicht gleichzeitig zugreifen können. Sperren umschließen Mutexe und versuchen, die Threads für den Lebenszyklus der Bereiche zu entsperren.

Und zuletzt gewährleisten Bedingungsvariablen eine höhere Effizienz für die Thread-Synchronisierung, da manche Threads auf Ereignisse warten können, die von anderen Threads benachrichtigt wurden.

Dieser Artikel hat noch längst nicht alle Möglichkeiten abgedeckt, die zur Konfiguration und Verwendung jeder dieser Funktionen zur Verfügung stehen, aber der Leser hat nun einen allgemeinen Einblick gewonnen und kann sich eingehender damit befassen.

Diego Dagum ist Softwareentwickler und verfügt über mehr als 20 Jahre Erfahrung in diesem Bereich. Zurzeit arbeitet er als Programm-Managerin der Visual C++-Community für Microsoft.

Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: David Cravey, Alon Fliess, Fabio Galuppo und Marc Gregoire