Zum Inhalt springen

std::async, std::future & std::promise

Nutze höhere Abstraktionen für Nebenläufigkeit: Starte asynchrone Aufgaben mit std::async, hole Ergebnisse über std::future ab und setze Werte oder Exceptions manuell mit std::promise.

Aufgabenbasierte Nebenläufigkeit

std::thread gibt dir zwar Kontrolle auf niedriger Ebene, doch aufgabenbasierte Nebenläufigkeit mit std::async und std::future ist oft einfacher und weniger fehleranfällig. Statt Threads und gemeinsamen Zustand von Hand zu verwalten, formulierst du die Arbeit als Aufgaben, die Ergebnisse liefern. Wie sie eingeplant werden, entscheidet die Laufzeitumgebung.

Ein std::future repräsentiert einen Wert vom Typ T, der in der Zukunft verfügbar sein wird. Mit get() blockierst du, bis das Ergebnis bereitsteht. Hat die Aufgabe eine Exception geworfen, wirft get() sie erneut — du bekommst also automatische Exception-Weitergabe über Thread-Grenzen hinweg.

std::async — asynchrone Aufgaben starten

std::async startet ein aufrufbares Objekt und liefert ein std::future mit dem Ergebnis zurück. Du kannst eine Startrichtlinie angeben: std::launch::async erzwingt einen neuen Thread, während std::launch::deferred die Ausführung bis zum Aufruf von get() hinauszögert.

async_parallel_sum.cpp
#include <iostream>
#include <future>
#include <chrono>
#include <numeric>
#include <vector>

long long parallel_sum(const std::vector<int>& v, size_t start, size_t end) {
    if (end - start < 1000) {
        return std::accumulate(v.begin() + start, v.begin() + end, 0LL);
    }
    size_t mid = start + (end - start) / 2;

    // Launch left half asynchronously
    auto left = std::async(std::launch::async,
                           parallel_sum, std::cref(v), start, mid);

    // Compute right half in this thread
    long long right_sum = parallel_sum(v, mid, end);

    return left.get() + right_sum;  // .get() blocks until left is done
}

int main() {
    std::vector<int> data(1'000'000, 1);

    auto start = std::chrono::high_resolution_clock::now();
    long long result = parallel_sum(data, 0, data.size());
    auto end = std::chrono::high_resolution_clock::now();

    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    std::cout << "Sum: " << result << " in " << ms.count() << "ms\n";
    return 0;
}

Startrichtlinien: async vs. deferred

std::launch::async — Die Funktion wird auf einem neuen Thread ausgeführt (oder auf einem aus einem Thread-Pool, das ist implementierungsabhängig). Die Arbeit beginnt sofort.

std::launch::deferred — Die Funktion wird nicht ausgeführt, bis get() oder wait() auf dem Future aufgerufen wird. Sie läuft dann im aufrufenden Thread (Lazy Evaluation). Nützlich für Berechnungen, die du vielleicht gar nicht brauchst.

Voreinstellung (keine Richtlinie angegeben) — Die Implementierung entscheidet. Das kann async, deferred oder beides sein (das heißt, sie entscheidet zur Laufzeit). Wenn du garantierte Parallelität brauchst, gib immer std::launch::async an.

std::promise — Ergebnisse von Hand setzen

std::promise ist das Schreibende eines Future-Promise-Kanals. Du erzeugst ein Promise, holst dir sein Future und rufst dann aus einem beliebigen Thread entweder set_value() oder set_exception() auf.

promise_demo.cpp
#include <iostream>
#include <thread>
#include <future>
#include <stdexcept>

void compute(std::promise<int> prom, int x) {
    try {
        if (x < 0) {
            throw std::invalid_argument("negative input");
        }
        int result = x * x;
        prom.set_value(result);  // fulfil the promise
    } catch (...) {
        prom.set_exception(std::current_exception());  // propagate error
    }
}

int main() {
    std::promise<int> prom;
    std::future<int> fut = prom.get_future();  // get the read end

    std::thread t(compute, std::move(prom), 7);

    try {
        int result = fut.get();  // blocks until value or exception
        std::cout << "Result: " << result << "\n";  // prints 49
    } catch (const std::exception& e) {
        std::cout << "Error: " << e.what() << "\n";
    }

    t.join();
    return 0;
}

std::shared_future — mehrere Abnehmer

Auf einem std::future kann get() nur einmal aufgerufen werden (es bewegt den Wert heraus). std::shared_future erlaubt es mehreren Threads, auf dasselbe Ergebnis zu warten und es zu lesen.

shared_future.cpp
#include <iostream>
#include <future>
#include <thread>
#include <vector>

int main() {
    auto shared = std::async(std::launch::async, []() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        return 42;
    }).share();  // convert future to shared_future

    // Multiple threads can all read the same result
    std::vector<std::thread> consumers;
    for (int i = 0; i < 5; ++i) {
        consumers.emplace_back([shared, i]() {
            int val = shared.get();  // all 5 threads get 42
            std::cout << "Consumer " << i << " got: " << val << "\n";
        });
    }

    for (auto& t : consumers) t.join();
    return 0;
}
Stolperfalle

Ein std::future, das von std::async zurückgegeben wird, hat eine Besonderheit: Sein Destruktor blockiert, bis die Aufgabe fertig ist (wie ein implizites join). Verwirfst du das zurückgegebene Future, wird der "asynchrone" Aufruf also faktisch synchron:

// ÜBERRASCHUNG — das blockiert! Das temporäre Future wird
// am Semikolon zerstört und wartet dabei auf das Ende der Aufgabe.
std::async(std::launch::async, long_computation);

// RICHTIG — das Future speichern, damit die Arbeit parallel weiterläuft
auto fut = std::async(std::launch::async, long_computation);
// ... andere Arbeit erledigen ...
fut.get();  // jetzt auf das Ergebnis warten

Das ist der häufigste Fallstrick bei std::async. Fange das zurückgegebene std::future immer in einer Variablen auf.

std::packaged_task

std::packaged_task umhüllt ein aufrufbares Objekt und stellt ein std::future für dessen Ergebnis bereit. Anders als std::async führt es die Aufgabe nicht automatisch aus — du rufst sie selbst auf (typischerweise, indem du sie in einen Thread oder eine Aufgaben-Queue verschiebst). Damit eignet es sich hervorragend, um eigene Thread-Pools zu bauen:

1. Verpacke die Arbeit in ein std::packaged_task.
2. Hole das std::future heraus und gib es dem Aufrufer.
3. Verschiebe die Aufgabe in eine Arbeits-Queue.
4. Worker-Threads holen Aufgaben aus der Queue und führen sie aus.

Best Practice

Nimm std::async, wenn du ein Ergebnis aus nebenläufiger Arbeit brauchst und keine feingranulare Kontrolle über Threads benötigst. Es gibt Exceptions automatisch weiter und kümmert sich um die Lebensdauer der Threads. Nimm std::thread (oder std::jthread), wenn du dauerhafte Hintergrund-Worker, eigene Einplanung oder Infrastruktur wie Thread-Pools brauchst. Bei Arbeit nach dem Prinzip "abschicken und vergessen" solltest du das zurückgegebene std::future immer am Leben halten, um versehentliches Blockieren zu vermeiden.

Wichtigste Erkenntnisse
  • std::async startet eine Aufgabe und liefert ein std::future zurück — eine Alternative auf hoher Ebene zur manuellen Thread-Verwaltung
  • std::launch::async erzwingt einen neuen Thread; std::launch::deferred wartet bis zum Aufruf von get()
  • std::promise ist das Schreibende eines Future-Kanals — nutze es, um Werte oder Exceptions von Hand zu setzen
  • std::shared_future lässt mehrere Threads dasselbe Ergebnis lesen
  • Das std::future von std::async blockiert in seinem Destruktor — speichere es immer in einer Variablen
  • std::packaged_task verbindet ein aufrufbares Objekt mit einem Future und ist ideal für eigene Thread-Pools

Quiz — Teste dein Wissen

(15 XP)

1. Was passiert, wenn du das von `std::async(std::launch::async, fn)` zurückgegebene `std::future` verwirfst?

2. Worin unterscheiden sich `std::future` und `std::shared_future`?

3. Wie gibt `std::async` Exceptions aus der Aufgabe an den Aufrufer weiter?