Verwendung des std::mutex-Synchronisationsprimitivs in C++

Jinku Hu 12 Oktober 2023
Verwendung des std::mutex-Synchronisationsprimitivs in C++

Dieser Artikel demonstriert die Verwendung des Synchronisationsprimitiven std::mutex in C++.

Verwendung von std::mutex zum Schutz des Zugriffs auf gemeinsam genutzte Daten zwischen Threads in C++

Im Allgemeinen sind Synchronisationsprimitive Werkzeuge für den Programmierer, um den Zugriff auf gemeinsam genutzte Daten in Programmen, die Parallelität verwenden, sicher zu steuern.

Da die ungeordnete Änderung von gemeinsam genutzten Speicherorten von mehreren Threads zu fehlerhaften Ergebnissen und unvorhersehbarem Programmverhalten führt, liegt es am Programmierer, sicherzustellen, dass das Programm auf deterministische Weise ausgeführt wird. Synchronisation und andere Themen in der nebenläufigen Programmierung sind ziemlich komplex und erfordern oft umfassende Kenntnisse über mehrere Schichten von Software- und Hardwareeigenschaften in modernen Computersystemen.

Daher gehen wir von einigen Vorkenntnissen dieser Konzepte aus, während wir in diesem Artikel einen sehr kleinen Teil des Synchronisierungsthemas behandeln. Wir werden nämlich das Konzept des gegenseitigen Ausschlusses einführen, auch bekannt als mutex (Oft wird den Objektnamen in Programmiersprachen derselbe Name gegeben, z. B. std::mutex).

Ein Mutex ist eine Art Sperrmechanismus, der einen kritischen Abschnitt im Programm umschließen und sicherstellen kann, dass der Zugriff darauf geschützt ist. Wenn wir sagen, dass die gemeinsam genutzte Ressource geschützt ist, bedeutet dies, dass, wenn ein Thread die Schreiboperation für das gemeinsam genutzte Objekt durchführt, andere Threads nicht funktionieren, bis der vorherige Thread die Operation beendet hat.

Beachten Sie, dass ein solches Verhalten für einige Probleme möglicherweise nicht optimal ist, da Ressourcenkonflikte, Ressourcenknappheit oder andere leistungsbezogene Probleme auftreten können. Daher adressieren einige andere Mechanismen diese Probleme und bieten andere Eigenschaften, die über den Rahmen dieses Artikels hinausgehen.

Im folgenden Beispiel zeigen wir die grundlegende Verwendung der Klasse std::mutex, wie sie von der C++-STL bereitgestellt wird. Beachten Sie, dass die Standardunterstützung für Threading seit der C++11-Version hinzugefügt wurde.

Zuerst müssen wir ein std::mutex-Objekt konstruieren, mit dem dann der Zugriff auf die gemeinsam genutzte Ressource gesteuert werden kann. std::mutex hat zwei zentrale Memberfunktionen - lock und unlock. Die Operation lock wird normalerweise aufgerufen, bevor die gemeinsam genutzte Ressource geändert wird, und unlock wird nach der Änderung aufgerufen.

Der zwischen diesen Aufrufen eingefügte Code wird als kritischer Abschnitt bezeichnet. Obwohl die vorherige Reihenfolge des Code-Layouts korrekt ist, bietet C++ eine weitere nützliche Vorlagenklasse - std::lock_guard, die den angegebenen mutex automatisch entsperren kann, wenn der Gültigkeitsbereich verlassen wird. Der Hauptgrund für die Verwendung von lock_guard anstelle der direkten Verwendung der Memberfunktionen lock und unlock besteht darin, sicherzustellen, dass der mutex in allen Codepfaden entsperrt wird, auch wenn die Ausnahmen ausgelöst werden. Daher wird auch unser Codebeispiel die letztere Methode verwenden, um die Verwendung von std::mutex zu demonstrieren.

Das main-Programm ist so konstruiert, dass es zwei Vektor-Container von zufälligen ganzen Zahlen erstellt und dann den Inhalt beider in eine Liste überträgt. Der schwierige Teil ist, dass wir mehrere Threads verwenden möchten, um Elemente zur Liste hinzuzufügen.

Eigentlich rufen wir die Funktion generateNumbers mit zwei Threads auf, aber diese arbeiten mit unterschiedlichen Objekten, und eine Synchronisation ist unnötig. Nachdem wir Integer generiert haben, kann die Liste durch Aufrufen der Funktion addToList gefüllt werden.

Beachten Sie, dass diese Funktion mit der Konstruktion lock_guard beginnt und dann die auszuführenden Operationen in die Liste einfügt. In diesem Fall ruft es nur die Funktion push_back für das angegebene Listenobjekt auf.

#include <chrono>
#include <iostream>
#include <list>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

using std::cout;
using std::endl;
using std::list;
using std::string;
using std::vector;

std::mutex list1_mutex;

const int MAX = 1000;
const int NUMS_TO_GENERATE = 1000000;

void addToList(const int &num, list<int> &l) {
  std::lock_guard<std::mutex> guard(list1_mutex);
  l.push_back(num);
}

void generateNumbers(vector<int> &v) {
  for (int n = 0; n < NUMS_TO_GENERATE; ++n) {
    v.push_back(std::rand() % MAX);
  }
}

int main() {
  list<int> list1;
  vector<int> vec1;
  vector<int> vec2;

  std::thread t1(generateNumbers, std::ref(vec1));
  std::thread t2(generateNumbers, std::ref(vec2));
  t1.join();
  t2.join();

  cout << vec1.size() << ", " << vec2.size() << endl;

  for (int i = 0; i < NUMS_TO_GENERATE; ++i) {
    std::thread t3(addToList, vec1[i], std::ref(list1));
    std::thread t4(addToList, vec2[i], std::ref(list1));
    t3.join();
    t4.join();
  }

  cout << "list size = " << list1.size() << endl;

  return EXIT_SUCCESS;
}

Ausgabe:

1000000, 1000000
list size = 2000000

Im vorherigen Code-Snippet haben wir uns dafür entschieden, in jeder Iteration der for-Schleife zwei separate Threads zu erstellen und sie im selben Zyklus mit dem Haupt-Thread zu verbinden. Dieses Szenario ist ineffizient, da es wertvolle Ausführungszeit benötigt, um Threads zu erstellen und zu zerstören, aber wir bieten es nur an, um die Verwendung von mutex zu demonstrieren.

Wenn es notwendig ist, mehrere Threads während einer beliebigen Zeit im Programm zu verwalten, wird normalerweise das Konzept des Thread-Pools verwendet. Die einfachste Form dieses Konzepts wird zu Beginn der Arbeitsroutine eine feste Anzahl von Threads anlegen und ihnen dann Warteschlangen-artig Arbeitseinheiten zuordnen. Wenn ein Thread seine Arbeitseinheit abgeschlossen hat, kann er für die nächste anstehende Arbeitseinheit wiederverwendet werden. Beachten Sie jedoch, dass unser Treibercode nur als einfache Simulation eines Multithread-Workflows in einem Programm konzipiert ist.

Autor: Jinku Hu
Jinku Hu avatar Jinku Hu avatar

Founder of DelftStack.com. Jinku has worked in the robotics and automotive industries for over 8 years. He sharpened his coding skills when he needed to do the automatic testing, data collection from remote servers and report creation from the endurance test. He is from an electrical/electronics engineering background but has expanded his interest to embedded electronics, embedded programming and front-/back-end programming.

LinkedIn Facebook