C++ Multithreading

Threads sind leichtgewichtige Ausführungseinheiten in einem Programm, die es dem Programm ermöglichen, mehrere Aufgaben gleichzeitig auszuführen.

Multithreading ist eine spezielle Form des Multitaskings, die es einem Computer ermöglicht, zwei oder mehr Programme gleichzeitig auszuführen.

Im Allgemeinen gibt es zwei Arten des Multitaskings:prozessbasiert und threadbasiert。

  • Prozessbasiertes Multitasking ist die gleichzeitige Ausführung von Programmen.
  • Threadbasiertes Multitasking ist die gleichzeitige Ausführung von Fragmenten desselben Programms.

Multithread-Programmierung in C++ umfasst das Erstellen und Verwalten mehrerer gleichzeitig ausgeführter Threads in einem Programm.

C++ bietet leistungsstarke Multithreading-Unterstützung, insbesondere im C++11-Standard und später, durch<thread>die Standardbibliothek, wodurch Multithread-Programmierung einfacher und sicherer wird.


Konzeptbeschreibung

Thread

  • Ein Thread ist ein einzelner sequenzieller Kontrollfluss in der Programmausführung, und mehrere Threads können unabhängig voneinander innerhalb desselben Prozesses ausgeführt werden.
  • Threads teilen Ressourcen wie den Adressraum des Prozesses, Dateideskriptoren, Heap und globale Variablen, aber jeder Thread hat seinen eigenen Stack, eigene Register und einen eigenen Programmzähler.

Nebenläufigkeit und Parallelität

  • Nebenläufigkeit: Mehrere Aufgaben werden abwechselnd innerhalb von Zeitscheiben ausgeführt, wodurch der Effekt einer gleichzeitigen Ausführung entsteht.
  • Parallelität: Mehrere Aufgaben werden gleichzeitig auf mehreren Prozessoren oder Prozessorkernen ausgeführt.

Die Standards C++11 und später bieten Unterstützung für Multithreading, deren Kernkomponenten Folgendes umfassen:

  • std::thread: Wird zum Erstellen und Verwalten von Threads verwendet.
  • std::mutex: Wird zur gegenseitigen Ausschließung zwischen Threads verwendet und verhindert, dass mehrere Threads gleichzeitig auf gemeinsam genutzte Ressourcen zugreifen.
  • std::lock_guardundstd::unique_lock: Wird zur Verwaltung des Erwerbs und der Freigabe von Sperren verwendet.
  • std::condition_variable: Wird für Bedingungsvariablen zwischen Threads verwendet, um Warten und Benachrichtigung zwischen Threads zu koordinieren.
  • std::futureundstd::promise: Wird zur Implementierung von Wertübergabe und Aufgabensynchronisation zwischen Threads verwendet.

Threads erstellen

Nach C++11 wurde eine neue Standard-Thread-Bibliothek hinzugefügt.std::thread,std::thread in <thread>In der Header-Datei deklariert, sodass bei Verwendung vonstd::threadmuss manin die <thread>Header-Datei einbinden.

Hinweis:Früher verwendeten einige Compiler das Kompilierungsflag für C++11 als-std=c++11:

g++ -std=c++11 test.cpp 

std::thread

#include<thread>
std::thread thread_object(callable, args...);
  • callable: Aufrufbare Objekte, die Funktionszeiger, Funktionsobjekte, Lambda-Ausdrücke usw. sein können.
  • args...: übergeben ancallableDie Parameterliste von.

Verwendung von Funktionszeigern

Das Erstellen eines Threads über einen Funktionszeiger ist die grundlegendste Methode:

Beispiel

#include <iostream>
#include <thread>

void printMessage(int count) {
    for (int i = 0; i < count; ++i) {
        std::cout << "Hello from thread (function pointer)!\n";
    }
}

int main() {
    std::thread t1(printMessage, 5); // Einen Thread erstellen, wobei ein Funktionszeiger und Parameter übergeben werden
    t1.join(); // Warten, bis der Thread abgeschlossen ist
    return 0;
}

Verwendungg++ -std=c++11Nach der Kompilierung ist die Ausgabe bei der Ausführung:

Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!

Verwendung von Funktionsobjekten

durch die der Klasseoperator()Definiere eine Funktionsobjekt-Methode, um einen Thread zu erstellen:

Beispiel

#include <iostream>
#include <thread>

class PrintTask {
public:
    void operator()(int count) const {
        for (int i = 0; i < count; ++i) {
            std::cout << "Hello from thread (function object)!\n";
        }
    }
};

int main() {
    std::thread t2(PrintTask(), 5); // Einen Thread erstellen, indem ein Funktionsobjekt und Parameter übergeben werden
    t2.join(); // Warten, bis der Thread abgeschlossen ist
    return 0;
}

Verwendungg++ -std=c++11Nach der Kompilierung ist die Ausgabe bei der Ausführung:

Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!

Lambda-Ausdrücke verwenden

Lambda-Ausdrücke können Inline-Code für die Thread-Ausführung direkt definieren:

Beispiel

#include <iostream>
#include <thread>

int main() {
    std::thread t3([](int count) {
        for (int i = 0; i < count; ++i) {
            std::cout << "Hello from thread (lambda)!\n";
        }
    }, 5); // Einen Thread erstellen, indem ein Lambda-Ausdruck und Parameter übergeben werden
    t3.join(); // Warten, bis der Thread abgeschlossen ist
    return 0;
}

Verwendungg++ -std=c++11Nach der Kompilierung ist die Ausgabe bei der Ausführung:

Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!

Thread-Verwaltung

join()

join() wird verwendet, um zu warten, bis ein Thread die Ausführung beendet hat. Wenn ein Thread-Objekt direkt zerstört wird, ohne join() oder detach() aufzurufen, führt dies zum Absturz des Programms.

t.join();

detach()

detach() trennt den Thread vom Hauptthread; der Thread läuft unabhängig im Hintergrund, und der Hauptthread wartet nicht mehr auf ihn.

t.detach();

Thread-Parameterübergabe

Übergabe per Wert

Parameter können per Wert an Threads übergeben werden:

std::thread t(func, arg1, arg2);

Übergabe per Referenz

Wenn Sie Referenzparameter übergeben müssen, müssen Sie std::ref verwenden:

Beispiel

#include <iostream>
#include <thread>

void increment(int& x) {
    ++x;
}

int main() {
    int num = 0;
    std::thread t(increment, std::ref(num)); // Verwenden Sie std::ref, um Referenzen zu übergeben
    t.join();
    std::cout << "Wert nach Inkrement: " << num << std::endl;
    return 0;
}

Umfassendes Beispiel

Das Folgende ist ein vollständiges Beispiel, das zeigt, wie die oben genannten drei Methoden verwendet werden, um Threads zu erstellen und Thread-Verwaltung durchzuführen.

Beispiel

#include <iostream>
#include <thread>
using namespace std;

// Eine einfache Funktion, die als Einstiegsfunktion des Threads dient
void foo(int Z) {
    for (int i = 0; i < Z; i++) {
        cout << "Thread verwendet einen Funktionszeiger als aufrufbaren Parameter\n";
    }
}

// Definition der Klasse des aufrufbaren Objekts
class ThreadObj {
public:
    void operator()(int x) const {
        for (int i = 0; i < x; i++) {
            cout << "Thread verwendet ein Funktionsobjekt als aufrufbaren Parameter\n";
        }
    }
};

int main() {
    cout << "Threads 1, 2 und 3 laufen unabhängig voneinander" << endl;

    // Einen Thread mit einem Funktionszeiger erstellen
    thread th1(foo, 3);

    // Thread mit einem Funktionsobjekt erstellen
    thread th2(ThreadObj(), 3);

    // Thread mit einem Lambda-Ausdruck erstellen
    thread th3([](int x) {
        for (int i = 0; i < x; i++) {
            cout << "Thread verwendet einen Lambda-Ausdruck als aufrufbaren Parameter\n";
        }
    }, 3);

    // Warten, bis alle Threads abgeschlossen sind
    th1.join(); // Warten, bis Thread th1 abgeschlossen ist
    th2.join(); // Warten, bis Thread th2 abgeschlossen ist
    th3.join(); // Warten, bis Thread th3 abgeschlossen ist

    return 0;
}

Compiler-Flag für die Verwendung von C++11-std=c++11:

g++ -std=c++11 test.cpp 

Die Ausgabe des obigen Codes kann sich je nach Plattform oder zwischen Ausführungen unterscheiden, weil die Ausführungsreihenfolge der Threads durch den Scheduling-Algorithmus des Betriebssystems bestimmt wird. Mehrere Threads werden nebenläufig ausgeführt, und die Ausgabe kann sich verzahnen, zum Beispiel:

线程 1 、2 、3 独立运行
线程使用函数指针作为可调用参数
线程使用函数对象作为可调用参数
线程使用 lambda 表达式作为可调用参数
线程使用函数指针作为可调用参数
...

Thread-Synchronisation und gegenseitiger Ausschluss

In der Multithread-Programmierung sind Thread-Synchronisation und gegenseitiger Ausschluss zwei sehr wichtige Konzepte. Sie werden verwendet, um den Zugriff mehrerer Threads auf gemeinsam genutzte Ressourcen zu steuern, um Datenrennen, Deadlocks und andere Probleme zu vermeiden.

1. Mutex

Ein Mutex ist ein Synchronisationsprimitiv, das verhindert, dass mehrere Threads gleichzeitig auf gemeinsam genutzte Ressourcen zugreifen. Wenn ein Thread auf eine gemeinsam genutzte Ressource zugreifen muss, muss er zuerst den Mutex sperren. Wenn der Mutex bereits von einem anderen Thread gesperrt wurde, wird der Thread, der die Sperre anfordert, blockiert, bis der Mutex entsperrt wird.

std::mutex: Wird zum Schutz gemeinsam genutzter Ressourcen und zur Vermeidung von Datenrennen verwendet.

std::mutex mtx;
mtx.lock();   // 锁定互斥锁
// 访问共享资源
mtx.unlock(); // 释放互斥锁

std::lock_guardundstd::unique_lock: Verwaltet automatisch das Anfordern und Freigeben von Sperren.

std::lock_guard<std::mutex> lock(mtx); // 自动锁定和解锁
// 访问共享资源

Beispiel für die Verwendung eines Mutex:

Beispiel

#include <mutex>

std::mutex mtx; // Globaler Mutex

void safeFunction() {
    mtx.lock(); // Anforderung, den Mutex zu sperren
    // Auf gemeinsam genutzte Ressourcen zugreifen oder sie ändern
    mtx.unlock(); // Mutex freigeben
}

int main() {
    std::thread t1(safeFunction);
    std::thread t2(safeFunction);
    t1.join();
    t2.join();
    return 0;
}

2. Sperren

C++ bietet mehrere Sperrtypen, um die Verwendung und Verwaltung von Mutexen zu vereinfachen.

Übliche Sperrtypen sind:

  • std::lock_guard: Eine bereichsbezogene Sperre, die den Mutex bei der Konstruktion automatisch sperrt und ihn bei der Destruktion automatisch entsperrt.
  • std::unique_lock: Ähnlich wie std::lock_guard, bietet jedoch mehr Flexibilität, wie z. B. die Möglichkeit, den Besitz zu übertragen und manuell zu entsperren.

Beispiel für die Verwendung von Sperren:

Beispiel

#include <mutex>

std::mutex mtx;

void safeFunctionWithLockGuard() {
    std::lock_guard<std::mutex> lk(mtx);
    // Auf gemeinsam genutzte Ressourcen zugreifen oder sie ändern
}

void safeFunctionWithUniqueLock() {
    std::unique_lock<std::mutex> ul(mtx);
    // Auf gemeinsam genutzte Ressourcen zugreifen oder sie ändern
    // ul.unlock(); // Optional: manuell entsperren
    // ...
}

3. Bedingungsvariable

Bedingungsvariablen werden zur Koordination zwischen Threads verwendet und ermöglichen es einem oder mehreren Threads, auf das Eintreten einer bestimmten Bedingung zu warten. Sie werden typischerweise mit Mutexen verwendet, um die Synchronisation zwischen Threads zu erreichen.

std::condition_variableWird verwendet, um Warte- und Benachrichtigungsmechanismen zwischen Threads zu implementieren.

std::condition_variable cv;
std::mutex mtx;
bool ready = false;

std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; }); // 等待条件满足
// 条件满足后执行

Beispiel für die Verwendung von Bedingungsvariablen:

Beispiel

#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void workerThread() {
    std::unique_lock<std::mutex> lk(mtx);
    cv.wait(lk, []{ return ready; }); // Auf Bedingung warten
    // Arbeit ausführen, wenn die Bedingung erfüllt ist
}

void mainThread() {
    {
        std::lock_guard<std::mutex> lk(mtx);
        // Daten vorbereiten
        ready = true;
    } // Beim Verlassen des Gültigkeitsbereichs entsperren
    cv.notify_one(); // Einen wartenden Thread benachrichtigen
}

4. Atomare Operationen

Atomare Operationen stellen sicher, dass der Zugriff auf gemeinsam genutzte Daten unteilbar ist, was bedeutet, dass in einer Multithread-Umgebung eine atomare Operation entweder vollständig oder überhaupt nicht ausgeführt wird, ohne Zwischenzustand.

Beispiel für die Verwendung atomarer Operationen:

Beispiel

#include <atomic>
#include <thread>

std::atomic<int> count(0);

void increment() {
    count.fetch_add(1, std::memory_order_relaxed);
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    return count; // Sollte 2 zurückgeben
}

5. Thread-lokaler Speicher (TLS)

Thread-lokaler Speicher ermöglicht es jedem Thread, eine eigene Kopie der Daten zu haben. Dies kann mit dem Schlüsselwort thread_local erreicht werden, wodurch Konkurrenz um gemeinsam genutzte Ressourcen vermieden wird.

Beispiel für die Verwendung von thread-lokalem Speicher:

Beispiel

#include <iostream>
#include <thread>

thread_local int threadData = 0;

void threadFunction() {
    threadData = 42; // Jeder Thread hat seine eigene Kopie von threadData
    std::cout << "Thread-Daten: " << threadData << std::endl;
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);
    t1.join();
    t2.join();
    return 0;
}

6. Deadlock und Vermeidungsstrategien

Ein Deadlock tritt auf, wenn mehrere Threads darauf warten, dass die jeweils anderen Ressourcen freigeben, aber kein Thread weiter ausgeführt werden kann. Strategien zur Vermeidung von Deadlocks sind:

  • Ressourcen stets in derselben Reihenfolge anfordern.
  • Verwenden Sie ein Timeout, um zu versuchen, Ressourcen zu erwerben.
  • Verwenden Sie Deadlock-Erkennungsalgorithmen.

Inter-Thread-Kommunikation

std::future und std::promise: Implementierung der Wertübergabe zwischen Threads.

Beispiel


std::promise<int> p;
std::future<int> f = p.get_future();

std::thread t([&p] {
    p.set_value(10); // Wert setzen, Future auslösen
});

int result = f.get(); // Wert abrufen

Nachrichtenwarteschlangen (basierend auf std::queue und std::mutex) implementieren eine einfache Inter-Thread-Kommunikation.


C++17 führte die Bibliothek für parallele Algorithmen ein (<algorithm>), wobei einige Algorithmen die parallele Ausführung unterstützen und Multi-Core-CPUs nutzen, um die Leistung zu verbessern.

Beispiel

#include <algorithm>
#include <vector>
#include <execution>

std::vector<int> vec = {1, 2, 3, 4, 5};
std::for_each(std::execution::par, vec.begin(), vec.end(), [](int &n) {
    n *= 2;
});

Parallele Algorithmen in der C++-Standardbibliothek

Weitere Beispiele finden Sie hier:

C++-Multithreading:http://www.example.com/w3cnote/cpp-multithread-demo.html

C++ std::thread: https://www.example.com/w3cnote/cpp-std-thread.html

Weitere Erweiterungen