JGU Logo JGU Logo JGU Logo JGU Logo

Institut für Informatik

Michael Wand
David Hartmann
Sommersemester 2020DIGITAL

Blatt 10

Anlage: Nebenläufig in C++
Einführung in die Softwareentwicklung




Einfaches Beispiel mit std::threads

Threads sind eine einfache Möglichkeit, um Code parallel zum Hauptfluss laufen zu lassen. Am einfachsten erklärt sich dies an einem konkreten Beispiel:

#include <iostream>
#include <thread>

void nachricht(std::string s) {
    std::cout << "Nachricht im Nebenfluss" << s << std::endl;
}

int main() {

    std::cout << "Nachricht im Hauptfluss" << std::endl;

    // diese können direkt gestartet werden
    std::thread t(nachricht, " (1)");
    int val;
    std::thread t2([&val](int andererwert,std::string s)->void {
        std::cout << "Nachricht im Nebenfluss" << s << std::endl;
        val = andererwert;
    }, 42, " (2)");
    // Bemerke: Die Anzahl an Argumenten der übergebenen Funktion bestimmt die Anzahl an
    // Argumenten nach dessen Definition in std::thread.
    // Beispiel:
    // std::thread([](typ1 var1,typ2 var2,typ3 var3) { ... }, wert1, wert2, wert3);
    //      wert1 muss vom typ1 sein
    //      wert2 muss vom typ2 sein
    //      wert3 muss vom typ3 sein

    std::cout << "weitere Nachricht im Hauptfluss. Val=" << val << std::endl;
    t.join();
    t2.join();
    std::cout << "jetzt ist es aber auf jeden Fall da. Val=" << val << std::endl;
    std::cout << "Thread ist nach einem join auf jeden Fall fertig." << std::endl;
    std::cout << "(Ansonsten warten wir bis er fertig ist.)" << std::endl;
}

Führen Sie diesen Code ruhig mehrfach aus. Sie sollten sehen, dass die Reihenfolge der Ausgaben variieren kann, da bis t.join() nicht klar ist welcher Code zuerst ausgeführt wird. Beachten sie insbesondere Wert von val für verschiedene Ausführungen des Codes.


Übrigens: Wirklich Nebenläufig ist der Code nur dann, wenn Sie auch mehrere Prozessoren (zur Verfügung) haben — mit dem Intel Core 2 Duo, spätestens mit unseren heutigen Smartphones ist ein Einkernprozessor jedoch bereits eine Rarität im Anwendersegment geworden.


Die Anzahl der Threads, die gestartet werden können ist praktisch nur durch den Speicherbedarf (eigener Stack) gegeben, viele Betriebssystem haben jedoch zusätzlich eine konstante Höchstgrenze pro System. In dem Fall würden Sie eine Fehlermeldung bekommen. Durch den folgenden Code können Sie testen wieviele Threads auf ihrem System/ihrer Konfiguration parallel laufen können.

unsigned concurentThreadsSupported = std::thread::hardware_concurrency();

(Mehr zu erstellen, die auf Ihre Ausführung warten ist dennoch möglich, solange es nicht zu viele werden).

Einfaches Beispiel mit std::async

std::async und große Teile der C++-Standardbibliothek kapseln diese low-level Verwendung von threads. Die Idee ist, dass wir eigentlich (wie mit Lambdas auf Blatt 08) Aufgaben parallel lösen möchten, uns aber gar nicht darum kümmern wollen wie dies technisch konkret von statten geht.


Auch hier soll der Beispielcode erklären, wie diese Struktur verwendet wird.

#include <iostream>
#include <thread>
#include <future>
#include <vector>

int main() {
    std::cout << "Nachricht im Hauptfluss" << std::endl;

    // Die Verwendung ist sehr ähnilch zu Threads.
    std::future<int> result = std::async([](int m, int n) {
        std::cout << "Nachricht im Nebenfluss" << std::endl;
        return m + n;
    } , 2, 4);

    // ein Future gibt uns an, dass in diesem Objekt
    // irgendwann ein Wert stehen wird. Dieses können wir mit
    // .get() erfragen. Ist es noch nicht da, wartet das Programm
    // mit der weiteren Ausführung.
    int res = result.get();
    std::cout << res << std::endl;

    std::vector<std::future<int>> v;
    for(int i = 0; i < 10; ++i) {
        v.push_back (std::async([](int m)->int {return m*2;}, i));
    }

    // die Reihenfolge in welcher die Werte abgegriffen werden ist dabei egal.
    std::cout << "v[0] = " << v[0].get() << std::endl;
    std::cout << "v[5] = " << v[5].get() << std::endl;
    std::cout << "v[2] = " << v[2].get() << std::endl;
    std::cout << "v[8] = " << v[8].get() << std::endl;

    // Wichtig: Es ist nicht möglich dies noch einmal abzufragen:
    /* std::cout << "v[8] = " << v[8].get() << std::endl; */
}

Mutexe

Ein Mutex (aus dem engl. Mutual exclusion) ist ein Konzept, dass verhindert dass mehrere nebenläufige Threads gleichzeitig den gleichen Speicher verändern. Etwa hier:

#include <iostream>
#include <thread>

int main() {
    int val = 5;

    // diese können direkt gestartet werden
    std::thread t([&val](int andererwert)->void {
        for (int i=0; i<100; i++) {
            std::cout << "m";
            val *= andererwert;
        }
    }, 3);

    std::thread t2([&val](int andererwert)->void {
        for (int i=0; i<100; i++) {
            std::cout << "p";
            val += andererwert;
        }
    }, 2);

    std::cout << std::endl;
    std::cout << "vor t.join(): val=" << val << std::endl;
    t.join();
    std::cout << "vor t2.join(): val=" << val << std::endl;
    t2.join();
    std::cout << "nach joins(): val=" << val << std::endl;
}

Jede Ausführung des Codes kann zu einem anderen Ergebnis führen. Um also Teile der Threads untereinander zu synchronisieren kann man Mutex-Locks verwenden, die jeweils nur von einem Thread gleichzeitig belegt werden können.

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mutex1;

void Thread(int i) {
    std::string s = "Thread " + std::to_string(i) + " gestartet\n";
    std::cout << s;
    mutex1.lock();
    std::string s2 = ">>> Nur Thread " + std::to_string(i) + " ist hier aktiv\n";
    std::cout << s2;
    std::cout << s2;
    std::cout << s2;
    std::cout << s2;
    std::cout << s2;
    mutex1.unlock();
    std::string s3 = "Thread " + std::to_string(i) + " beendet\n";
    std::cout << s3;
}

int main() {
    std::thread t1( Thread, 1 );
    std::thread t2( Thread, 2 );
    std::thread t3( Thread, 3 );

    t1.join();
    t2.join();
    t3.join();

    std::cout << "Ende" << std::endl;
}

Testen Sie auch hier den Code mehrfach. Kommentieren Sie alle Zeilen mit mutex1 aus, um den Unterschied zu sehen; ohne Mutex kann der Code zwischen lock und unlock von mehreren Threads bearbeitet werden — in den Fall würden sich die Ausgabe von Thread 1, 2 und 3, die mit >>> beginnt überschneiden.


Achtung: Es ist jedoch sehr leicht möglich einen Deadlock zu konstruieren, also Code, bei dem zwei Threads auf die beendigung des jeweils anderen Warten.

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mutex1;

void Thread(int i) {
    std::string s = "Thread " + std::to_string(i) + " gestartet\n";
    std::cout << s;
    mutex1.lock();
    if (i >= 2) {
        std::string s3 = "Thread " + std::to_string(i) + " wird dies nie ausführen.\n";
        std::cout << s3;
    } else {
        std::string s2 = ">>> Nur Thread " + std::to_string(i) + " ist hier aktiv\n";
        std::cout << s2;
        std::string s3 = "Thread " + std::to_string(i) + " beendet\n";
        std::cout << s3;
    }
}

int main() {
    std::thread t1( Thread, 1 );
    std::thread t2( Thread, 2 );
    std::thread t3( Thread, 3 );

    t1.join();
    t2.join();
    t3.join();

    std::cout << "Ende wird somit nie erreicht." << std::endl;
}