CodeGym /Kurse /C++ SELF /std::thread — Threadstart, Argumente, join

std::thread — Threadstart, Argumente, join

C++ SELF
Level 68, Lektion 1
Verfügbar

1. Minimaler Threadstart: Funktion und Lambda

Wenn man zum ersten Mal hört „wir erstellen einen Thread“, wirkt das oft abstrakt: so nach dem Motto „lass irgendwo parallel Code laufen“. In der Praxis ist ein Thread in C++ eine Ressource des Betriebssystems, und die Standardbibliothek gibt Ihnen ein Objekt, das diese Ressource besitzt. Wie bei std::unique_ptr: Besitz bedeutet Verantwortung.

std::thread ist ein Objekt, das beim Erzeugen einen neuen Ausführungsthread startet und dafür die „Verantwortung“ trägt. Ab diesem Zeitpunkt haben Sie zwei unabhängige Ausführungspfade innerhalb eines Prozesses: main lebt weiter, und der neue Thread führt aus, was Sie ihm gegeben haben. Wichtig: Threading ist keine Magie, sondern Disziplin. Haben Sie einen Thread erstellt, müssen Sie sein korrektes Beenden sicherstellen, zum Beispiel mittels join() (zumindest in dieser Vorlesung).

Der einfachste Weg, std::thread zu erleben, ist, etwas Kurzes zu starten und zu sehen, dass die Ausführungsreihenfolge „wackeln“ kann. Benötigte Header: <thread>. Für Ausgabe verwenden wir <iostream>.

Threadstart mit einer normalen Funktion

Der Thread startet direkt im Konstruktor von std::thread: Sie übergeben ein Callable (z. B. eine Funktion), und er beginnt auszuführen.

#include <iostream>
#include <thread>

void say_hello() {
    std::cout << "hello from worker\n";
}

int main() {
    std::thread t(say_hello); // Thread startet hier
    t.join();                 // warten auf Fertigstellung
    std::cout << "done\n";    // done
}

Beachten Sie den Gedanken: join() ist keine „Option“, sondern Teil des Besitzvertrags. Wir haben einen Thread erzeugt — wir müssen warten, bis er fertig ist.

Threadstart mit einer Lambda

Lambdas sind praktisch, weil man keine separate Funktion definieren muss, besonders wenn die Arbeit klein ist.

#include <iostream>
#include <thread>

int main() {
    std::thread t([] {
        std::cout << "worker: hi\n";
    });

    std::cout << "main: hi\n"; // kann vor oder nach worker erscheinen
    t.join();
}

Hier sehen wir wieder Nichtdeterminismus: die Zeile "main: hi" kann erst oder danach erscheinen. Das ist kein „Compiler‑Bug“, das ist normales Thread‑Verhalten.

3. Thread‑Argumente: Kopien, Referenzen und Move

Wenn ein Thread eine Funktion ausführt, muss man meist Daten übergeben: String, Zahl, Container, Einstellungen. Anfänger erleben oft die klassische Überraschung: „Ich habe die Variable übergeben, aber im Thread hat sie sich nicht geändert“. Meist liegt es daran, dass Sie eine Kopie übergeben haben, aber eine Arbeit am Original erwartet haben.

Merken Sie sich das Modell: std::thread speichert intern kopierte/verschobene Werte der Argumente (oft als decay-copy beschrieben). Im Standard gab es Diskussionen über dieses Verhalten (z. B. über „überflüssiges decay“), was zeigt: das Verhalten beim Kopieren/Transformieren von Argumenten ist ein signifikanter Designpunkt.

Übergabe per Wert

Geben wir einen String und eine Zahl weiter:

#include <iostream>
#include <string>
#include <thread>

void repeat(std::string text, int n) {
    for (int i = 0; i < n; ++i) {
        std::cout << text << '\n';
    }
}

int main() {
    std::thread t(repeat, std::string{"Hi"}, 2);
    t.join();
}

Hier ist text eine Kopie des Strings (oder ein verschobener String, je nachdem, was Sie übergeben). Das ist praktisch: der Thread hängt nicht von der Lebensdauer der Variable in main ab.

„Warum sind die Änderungen nicht zurückgekommen?“ — weil es eine Kopie war

#include <iostream>
#include <thread>

void inc(int x) {
    ++x; // erhöhen der Kopie
}

int main() {
    int value = 10;
    std::thread t(inc, value);
    t.join();

    std::cout << value << '\n'; // 10
}

Die Logik ist simpel: value wird in das Argument x kopiert. Der Thread erhöht x, aber value bleibt unverändert.

Übergabe per Referenz: std::ref und std::cref

Wenn die Funktion im Thread wirklich mit der Original-Variable arbeiten soll, müssen Sie explizit sagen: „gib eine Referenz“. Dafür verwendet man typischerweise std::ref (für veränderbare Referenz) und std::cref (für const-Referenz). Sie sind in <functional>.

Wichtig: Referenzübergabe erhöht das Risiko von Lebensdauerfehlern und Race‑Bedingungen. Daher in dieser Vorlesung: der Thread schreibt etwas → wir rufen join() auf → erst danach lesen wir das Ergebnis.

#include <functional>
#include <iostream>
#include <thread>

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

int main() {
    int value = 10;

    std::thread t(inc_ref, std::ref(value)); // WICHTIG: std::ref
    t.join();

    std::cout << value << '\n'; // 11
}

Ohne std::ref wäre wieder eine Kopie da, und value würde sich nicht ändern.

Mini‑Tabelle: wie Argumente übergeben werden

Was Sie beim Threadstart schreiben Was die Funktion tatsächlich erhält Typischer Effekt
t(f, x)
Kopie von x Thread ändert nicht das Original
t(f, std::ref(x))
Referenz auf x Thread kann das Original ändern
t(f, std::cref(x))
const-Referenz auf x Thread liest ohne Kopien

Diese Tabelle ist als „erste Hilfe“ nützlich, wenn Sie unerwartetes Verhalten sehen.

Move‑only und „teure“ Objekte: std::move verwenden

Manchmal wollen Sie einen großen std::vector oder ein nicht kopierbares Objekt in den Thread geben. Dann benötigen Sie einen Move.

#include <iostream>
#include <thread>
#include <utility>
#include <vector>

void process(std::vector<int> v) {
    std::cout << "size = " << v.size() << '\n'; // size = 3
}

int main() {
    std::vector<int> data{1, 2, 3};

    std::thread t(process, std::move(data)); // verschieben in den Thread
    t.join();

    std::cout << "after move, data.size() = " << data.size() << '\n';
}

Nach std::move bleibt das Objekt in main gültig, aber sein Zustand „nach dem Move“ sollte nicht als normal angenommen werden. Bei Vektoren ist oft size() == 0, aber darauf sollte man sich nicht strikt verlassen.

4. join(): wo platzieren und was es garantiert

Wenn Threadstart die „Zweite Förderlinie einschaltet“, ist join() das „Warten, bis die Linie fertig ist“. Klingt einfach, aber im Code ist es wichtig, die Gewohnheit zu haben: Sie sollten klar sehen, wo der Thread abgeschlossen wird.

join() macht zwei praktische Dinge: erstens blockiert es den aktuellen Thread (meist main) bis der Arbeits‑Thread fertig ist. Zweitens verwandelt es das std::thread-Objekt in ein Objekt, das keinen aktiven Thread mehr besitzt. Das ist wichtig, denn join() darf nicht zweimal auf demselben Thread aufgerufen werden.

Blockschema des Lebenszyklus von std::thread

flowchart TD
    A["Erzeugt std::thread t(...)"] --> B["t wurde joinable()"]
    B --> C["Arbeits‑Thread führt die Funktion aus"]
    C --> D["main ruft t.join() auf"]
    D --> E["main wartet auf Fertigstellung des Threads"]
    E --> F["Thread fertig, t ist nicht mehr joinable()"]

Der Kern der Grafik ist: joinable ist ein Zustandsindikator der Verantwortung: solange ein Thread joinable ist, kann man ihn nicht einfach „fallen lassen“.

joinable() als Schutz gegen doppeltes join()

Manchmal schreibt man komplexere Logik (z. B. frühe return) und möchte sicher prüfen, bevor man join aufruft.

#include <iostream>
#include <thread>

int main() {
    std::thread t([] { /* Arbeit */ });

    if (t.joinable()) {
        t.join();
    }

    std::cout << "ok\n"; // ok
}

Wenn Sie versuchen, join() danach noch einmal aufzurufen, gibt es zur Laufzeit einen Fehler (in der Regel eine std::system_error). Praktisch: „join — einmal“.

Was passiert, wenn man join() vergisst

Wenn ein std::thread-Objekt zerstört wird, während es noch einen laufenden Thread besitzt, verlangt der Standard ein sofortiges Beenden des Programms mittels std::terminate. Das ist so gewollt: man soll nicht versehentlich „Hintergrund‑Threads ohne Verantwortung“ erzeugen.

Im Alltag sieht das so aus: das Programm kann abrupt enden, manchmal ohne klare Fehlermeldung (abhängig von Umgebung/IDE). Daher die strenge Regel: erstellt std::thread → unbedingt join() aufrufen.

std::thread ist nicht kopierbar, aber verschiebbar

Irgendwann versuchen Sie vielleicht Folgendes:

std::thread t1([]{});
std::thread t2 = t1; // das geht nicht

Der Compiler sagt: „Kopieren ist gelöscht“. Das ist logisch: ein Thread ist Besitz. Wenn Kopieren erlaubt wäre, würden zwei Objekte dasselbe übernehmen und am Ende würden beide versuchen, den Thread „vertraglich“ zu beenden — Chaos.

Deshalb ist std::thread move‑only: es kann verschoben werden (wie std::unique_ptr).

#include <iostream>
#include <thread>
#include <utility>

int main() {
    std::thread t1([] { /* Arbeit */ });

    std::thread t2 = std::move(t1);

    std::cout << std::boolalpha
              << "t1.joinable(): " << t1.joinable() << '\n'
              << "t2.joinable(): " << t2.joinable() << '\n';

    t2.join();
}

Nach dem Move ist die Verantwortung zu t2 „umgezogen“. t1 ist leer (nicht joinable). Das ist ein weiterer Grund, joinable() zu mögen: es hilft, den Besitzer nicht zu verwechseln.

5. Mini‑Beispiel TextStats: Textstatistiken berechnen

Jetzt bauen wir eine kleine Lernanwendung, die sich später einfach erweitern lässt. Idee: wir lesen eine Zeile vom Benutzer und berechnen Statistik (Anzahl Zeichen und Wörter). Die Berechnung ist die „Arbeit“, die wir in einen Thread auslagern. Wichtiges Sicherheitsprinzip heute: der Hauptthread liest die Ergebnisse erst, nachdem er join() aufgerufen hat.

Datenmodell: struct Stats

#include <cstddef>

struct Stats {
    std::size_t chars = 0;
    std::size_t words = 0;
};

Nichts Kompliziertes: ein einfacher Container für das Ergebnis.

Wortzählung: einfache Funktion

Um nicht in Unicode‑ und Interpunktionsdetails zu versinken, zählen wir Wörter als „Sequenzen nicht‑Whitespace Zeichen“.

#include <string_view>

std::size_t count_words(std::string_view s) {
    std::size_t words = 0;
    bool in_word = false;

    for (char c : s) {
        const bool is_space = (c == ' ' || c == '\t' || c == '\n');
        if (!is_space && !in_word) { ++words; in_word = true; }
        if (is_space) { in_word = false; }
    }
    return words;
}

Ja, das ist eine „Spielzeug“-Tokenisierung, aber ideal für unser heutiges Ziel: dem Thread eine klar definierte Aufgabe geben.

Arbeitsfunktion für den Thread: schreibt Ergebnis in Stats

Hier verwenden wir eine Referenz auf Stats, daher müssen wir beim Start des Threads std::ref verwenden.

#include <string>
#include <string_view>

void compute_stats(std::string_view text, Stats& out) {
    out.chars = text.size();
    out.words = count_words(text);
}

main: Thread starten und join() aufrufen

#include <functional>
#include <iostream>
#include <string>
#include <thread>

int main() {
    std::cout << "Enter a line:\n";

    std::string line;
    std::getline(std::cin, line);

    Stats stats;

    std::thread worker(compute_stats, std::string_view{line}, std::ref(stats));

    std::cout << "Computing...\n"; // Computing...
    worker.join();                 // warten

    std::cout << "chars = " << stats.chars << '\n';
    std::cout << "words = " << stats.words << '\n';
}

Beachten Sie den sauberen „Lebensdauer‑Vertrag“: line lebt bis zum Ende von main, und wir rufen join() auf, bevor line verschwindet. Damit wird std::string_view{line} im Thread nicht dangling. Für heute ist das ein gutes Muster: Referenz/View übergeben → sicherstellen, dass der Besitzer lange genug lebt → join() aufrufen.

Zwei Threads, zwei Ergebnisse: Arbeit ohne gemeinsame Schreibzugriffe aufteilen

Wir wollen echte Parallelität: zwei Threads zählen Wörter in unterschiedlichen Hälften der Zeile. Dabei vermeiden wir gemeinsamen veränderbaren Zustand: jeder Thread schreibt in seine eigene Ergebnisvariable, und der Hauptthread liest erst nach join().

Das ist eine wichtige pädagogische Technik: wir haben noch keine Synchronisationsmittel behandelt, deshalb entwerfen wir so, dass es kein gleichzeitiges Schreiben in denselben Speicher gibt.

Funktion „Wörter in einem Bereich zählen“ und Ergebnis zurückgeben

Zur Vereinfachung zählen wir Wörter in einer string_view und schreiben das Ergebnis als Zahl in einen Ausgabewert; ein direktes Zurückgeben aus dem Thread behandeln wir später. Daher verwenden wir einen Ausgangsparameter.

#include <cstddef>
#include <string_view>

void count_words_into(std::string_view s, std::size_t& out) {
    out = count_words(s);
}

Zwei Threads starten und aufsummieren

#include <functional>
#include <iostream>
#include <string>
#include <thread>

int main() {
    std::string line;
    std::getline(std::cin, line);

    const std::size_t mid = line.size() / 2;

    std::size_t w1 = 0;
    std::size_t w2 = 0;

    std::thread t1(count_words_into, std::string_view{line}.substr(0, mid), std::ref(w1));
    std::thread t2(count_words_into, std::string_view{line}.substr(mid),     std::ref(w2));

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

    std::cout << "approx words = " << (w1 + w2) << '\n';
}

Das Wort approx (ungefähr) ist ehrlich: wenn der Schnitt mitten in ein Wort fällt, kann die naive Zählung ungenau sein. Als Lehrmodell zur Parallelisierung funktioniert das aber sehr gut: jeder Thread erledigt einen unabhängigen Teil, und wir vereinigen die Ergebnisse nach dem join().

6. Typische Fehler mit std::thread, Argumenten und join()

Fehler Nr. 1: Thread erstellt, aber join() vergessen.
Das ist das häufigste Szenario bei Anfängern: „ich wollte nur kurz ausprobieren“ — und plötzlich beendet sich das Programm abrupt. Wichtig ist die C++‑Philosophie zu akzeptieren: wenn ein Objekt eine Ressource besitzt, muss es einen klaren, lesbaren Pfad zur Freigabe/Vollendung dieser Ressource geben. Für std::thread heißt das heute: der Thread muss bis zum join() leben.

Fehler Nr. 2: man erwartet, dass t(f, x) eine Referenz übergibt, nicht eine Kopie.
Leicht passiert: std::thread(inc_ref, value) schreiben und erwarten, dass value erhöht wird. Standardmäßig werden Argumente aber „verpackt“ und als eigene Werte in den Thread gegeben — Sie arbeiten mit einer Kopie. Wenn Sie eine Referenz brauchen, benutzen Sie std::ref(value) (oder std::cref zum Lesen). Das ist kein Willkür der Bibliothek, sondern ein Schutz vor hängenden Referenzen und unbeabsichtigter Teilung veränderbarer Daten.

Fehler Nr. 3: Man übergibt eine Referenz auf ein Objekt, das nicht bis zum Ende des Threads lebt.
Lambdas mit Referenzcapture, Referenzen als Threadargumente, string_view auf eine lokale Stringvariable — das funktioniert genau so lange, bis der Besitzer aus dem Scope verschwindet. Daher die Grundregel: wenn Sie eine Referenz/View übergeben, stellen Sie sicher, dass der Besitzer länger lebt als der Thread, und dass join() erfolgt, bevor der Besitzer zerstört wird.

Fehler Nr. 4: Man versucht, via sleep_for zu synchronisieren.
Die Versuchung ist groß: „ich mache sleep_for(10ms), dann hat der Thread sicher Zeit, das Ergebnis zu schreiben“. Das führt fast immer zu fragilen Programmen, die „auf meinem Rechner funktionieren“ und unter anderer Last, mit anderer Kernzahl oder einfach später versagen. Delays sind nützlich, um Interleavings sichtbar zu machen, aber sie definieren keine korrekten Zugriffsregeln auf Daten.

Fehler Nr. 5: join() wird zweimal aufgerufen oder auf dem falschen Objekt nach std::move.
join() ist eine Einmaloperation. Danach ist der Thread fertig und das Objekt nicht mehr joinable(). Eine weitere unangenehme Situation: Sie haben std::thread verschoben und rufen aus Gewohnheit join() auf dem alten Objekt auf. Die Lösung: prüfen Sie nach einem Move, welches Objekt joinable() ist, und halten Sie Moves von Threads selten und sichtbar (damit das Auge sie bemerkt).

1
Aufgabe
C++ SELF, Level 68, Lektion 1
Gesperrt
Zahlenreihe
Zahlenreihe
1
Aufgabe
C++ SELF, Level 68, Lektion 1
Gesperrt
Schneller Patch
Schneller Patch
1
Aufgabe
C++ SELF, Level 68, Lektion 1
Gesperrt
Übergabe der Box
Übergabe der Box
1
Aufgabe
C++ SELF, Level 68, Lektion 1
Gesperrt
Suche nach Ziffern
Suche nach Ziffern
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION