1. Wozu braucht man std::promise und std::packaged_task
Wenn std::async «rufe eine Funktion irgendwo auf und gib mir ein future zurück» ist, dann sind std::promise und std::packaged_task Werkzeuge für Fälle, in denen die Berechnung nicht wie ein normaler Funktionsaufruf aussieht. Manchmal entsteht das Ergebnis nicht, weil wir ein return gemacht haben, sondern weil „etwas passiert ist“: eine Antwort kam, ein Warten endete, eine Datei wurde verarbeitet, ein Timer schlug an, ein Benutzer hat einen Knopf gedrückt. In solchen Szenarien ist es praktisch, die Rollen zu trennen: ein Stück Code produziert das Ergebnis, ein anderes wartet und konsumiert.
Und hier wird std::async weniger universell. Es ist gut, wenn Sie ein einzelnes Callable haben, das einen Wert zurückgibt. Wenn der Produzent des Ergebnisses sein eigenes Leben führt, wo „return“ überhaupt nicht wie ein Funktionsrückkehr aussieht, möchte man einen separaten Kanal zur Ergebnislieferung. Genau diesen Kanal liefern das Paar promise/future.
Ein kleiner Tipp aus der Praxis: promise im Namen ist wörtlich ein „Versprechen“. Sie sagen quasi: „Ich verspreche, dass ich später hier das Ergebnis hineinlege“. Dieses Versprechen wird von der Standardbibliothek überprüft (und manchmal recht streng).
Shared state: die Verbindung von promise und future
Bevor wir Code schreiben, ist es nützlich, das innere Modell auf Idee-Ebene zu verstehen. Sowohl std::future als auch std::promise arbeiten um eine einzige Entität: den gemeinsamen Zustand des Ergebnisses (oft „shared state“ genannt). Stellen Sie sich eine kleine „Postbox“ vor, in die entweder ein Wert oder eine Fehler-Information gelegt werden kann, und die andere Seite kann diese Box öffnen, auf ihre Befüllung warten und den Inhalt herausnehmen.
flowchart LR
P["std::promise<T><br/>Anbieter (producer)"] -->|"set_value(...)"| S[(shared state<br/>Ergebniszelle)]
S -->|"get() / wait()"| F["std::future<T><br/>Konsument (consumer)"]
Wichtig: future erzeugt das Ergebnis nicht selbst. Es wartet und entnimmt nur. Und promise wartet nicht; es legt nur das Ergebnis hinein. Beide sind wie zwei Hälften eines Kabels: einzeln fast sinnlos, zusammen sehr praktisch.
2. std::promise<T>: Ergebnis manuell liefern
Minimalprotokoll: promise → future → set_value
Hier kommt das wichtige „Skelett“ zur Verwendung von std::promise. Es ist fast immer gleich und wiederholt sich in vielen Programmen. Zuerst erzeugen Sie ein promise<T>, dann holen Sie daraus ein future<T>, und irgendwo anders rufen Sie set_value(...) auf, um das Ergebnis zu setzen.
#include <future>
#include <iostream>
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
p.set_value(123);
std::cout << f.get() << '\n'; // 123
}
Beachten Sie die etwas „unlogisch“ erscheinende Sache: get_future() wird auf dem promise aufgerufen, nicht auf dem future. Das liegt daran, dass genau das promise das Recht besitzt, den zukünftigen Konsumenten zu erstellen. Und noch ein feiner Punkt: in der Regel kann get_future() nur einmal aufgerufen werden — es ist kein Automat mit unendlich vielen Tickets.
promise im anderen Thread: std::move und mutable
In der Realität lebt das promise meist in einem anderen Thread: dort, wo die Arbeit tatsächlich geschieht. Daher wird das promise häufig in die Thread-Lambda verschoben (std::move). Das ist ein klassisches Muster.
#include <future>
#include <iostream>
#include <thread>
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
std::jthread worker([pr = std::move(p)]() mutable {
pr.set_value(42);
});
std::cout << f.get() << '\n'; // 42
}
Warum hier mutable? Weil Lambda standardmäßig seinen operator() const macht, und set_value den Zustand des promise verändert. Ohne mutable würde der Compiler zu Recht sagen: „Sie versuchen, pr im const-Operator zu ändern“.
Abschluss-Signal: std::promise<void>
Manchmal brauchen Sie keinen Wert, sondern nur die Tatsache des Abschlusses. Das ist wie „wir erreichen das Ziel — wir schwenken die Fahne“. Dafür ist die Spezialisierung auf void praktisch.
#include <future>
#include <iostream>
#include <thread>
int main() {
std::promise<void> p;
std::future<void> f = p.get_future();
std::jthread worker([pr = std::move(p)]() mutable {
// ... Arbeit erledigen
pr.set_value(); // Signal "fertig"
});
f.get(); // einfach warten
std::cout << "done\n"; // done
}
Solcher Code ist gut lesbar: future<void> bedeutet wörtlich „warte auf Abschluss“.
3. TaskConsole: Start der Arbeit vom Ergebniskanal trennen
Lassen Sie uns den Kontext festlegen, damit die Beispiele keine losen Fragmente bleiben. Stellen wir uns eine einfache Konsolen-Lernanwendung TaskConsole vor, die Aufgaben im Hintergrund starten und deren Ergebnis erhalten kann. Wir bauen keinen vollständigen Thread-Pool (das ist ein separates großes Thema), wollen aber lernen, wie man trennt: wer rechnet, und wer auf das Ergebnis wartet.
Fangen wir mit einer „Aufgabe“ an, die etwas berechnet. Nehmen wir eine spielerische, aber klare Funktion: eine „langsame Summe“ (nur damit es etwas zum Ausführen gibt).
#include <chrono>
#include <thread>
int slow_add(int a, int b) {
std::this_thread::sleep_for(std::chrono::milliseconds{200});
return a + b;
}
Machen wir nun eine Funktion, die einen Worker startet und ein future<int> zurückgibt. Das ist das erste Architektur-Stück unserer TaskConsole: submit_* gibt ein future zurück, und main entscheidet, wann es abgefragt wird.
#include <future>
#include <thread>
std::future<int> submit_slow_add(int a, int b) {
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread([pr = std::move(p), a, b]() mutable {
pr.set_value(slow_add(a, b));
}).detach(); // im echten Code ist detach gefährlich; hier nur Demonstration
return f;
}
Hier wird bewusst detach() gezeigt als „Demo-Trick“, um das Beispiel kurz zu halten. In realen Projekten würden wir häufiger den Thread speichern, std::jthread ohne detach verwenden oder eine andere Lebensdauer-Disziplin verfolgen. Die Idee der Vorlesung ist nicht, Threads korrekt zu managen, sondern zu zeigen, wie man das Ergebnis liefert.
Mini-Check in main:
#include <iostream>
int main() {
auto f = submit_slow_add(10, 32);
std::cout << "waiting...\n";
std::cout << f.get() << '\n'; // 42
}
Wenn Sie jetzt denken: „Moment, aber std::async kann das doch auch!“, dann haben Sie recht. Das Beispiel ähnelt async. Aber wir haben einen wichtigen Schritt gemacht: wir haben den Ergebniskanal vom Startmechanismus getrennt. Das ist später nützlich, wenn das Ergebnis nicht als return kommt, sondern durch ein komplexeres Ereignis.
Queue «Aufgabe + future» ohne Thread-Pool
Jetzt machen wir einen kleinen Schritt in Richtung „Dispatching“, aber sehr vorsichtig: ohne Thread-Pool, ohne komplexe Synchronisation und ohne Architektur-Monster. Wir zeigen einfach die Idee, warum std::packaged_task praktisch ist: eine Aufgabe kann als Objekt gespeichert werden, und das Ergebnis läuft über ein future.
Erstellen wir einen „Haufen Aufgaben“ und starten sie einzeln in separaten Threads (nicht effizient, aber verständlich).
#include <future>
#include <thread>
std::future<int> run_detached(std::packaged_task<int()> task) {
auto f = task.get_future();
std::thread([t = std::move(task)]() mutable { t(); }).detach();
return f;
}
Jetzt kann man Aufgaben als Lambdas erzeugen und futures bekommen:
#include <iostream>
int main() {
auto f1 = run_detached(std::packaged_task<int()>([] { return 40; }));
auto f2 = run_detached(std::packaged_task<int()>([] { return 2; }));
std::cout << (f1.get() + f2.get()) << '\n'; // 42
}
Beachten Sie, wie angenehm die API aussieht: „Hier ist eine Aufgabe, gib mir ein future zurück“. Genau dafür wird packaged_task oft verwendet: wenn Sie das Aufgaben-Setup von ihrer Ausführung trennen und trotzdem einen einfachen Ergebniskanal behalten möchten.
4. std::packaged_task: Callable, das das future selbst füllt
Warum packaged_task, wenn es promise gibt?
std::packaged_task ist eine praktische Hülle um ein Callable (Funktion, Lambda, Funktor). Sie macht zwei Dinge gleichzeitig: sie speichert das Callable und verwaltet den gemeinsamen Zustand des Ergebnisses so, dass beim Aufruf der Aufgabe das Ergebnis automatisch in das zugehörige future gelangt.
Wenn promise der „manuelle Kurier“ ist (Sie entscheiden selbst, was und wann gelegt wird), dann ist packaged_task der „Kurier + Anleitung“, wobei die Anleitung das Callable ist. Rufst du das task auf, liefert es das Ergebnis selbst.
Minimalbeispiel: Funktion verpackt und future erhalten
#include <future>
#include <iostream>
#include <thread>
int mul(int a, int b) { return a * b; }
int main() {
std::packaged_task<int(int, int)> task(mul);
std::future<int> f = task.get_future();
std::jthread worker([t = std::move(task)]() mutable {
t(6, 7); // nach dem Aufruf ist das future ready
});
std::cout << f.get() << '\n'; // 42
}
Die Bedeutung ist fast selbsterklärend: „packe die Multiplikation in ein task, nimm ein future, rufe das task irgendwo auf — und du bekommst das Ergebnis“.
Und ja: packaged_task ist wie promise typischerweise ein move-only Objekt. Das ist logisch: es enthält inneren Zustand, der nicht beliebig kopiert werden sollte.
Beispiel: packaged_task mit parameterloser Lambda
Manchmal ist es praktisch, eine Aufgabe ohne Parameter zu haben (z. B. wenn Sie bereits alles Notwendige in der Lambda eingefangen haben). Dann ist die Signatur R().
#include <future>
#include <iostream>
#include <string>
int main() {
std::string name = "C++";
std::packaged_task<std::string()> task([name] {
return "Hello, " + name;
});
auto f = task.get_future();
task(); // kann auch im aktuellen Thread aufgerufen werden
std::cout << f.get() << '\n'; // Hello, C++
}
Das ist ein gutes Beispiel dafür, dass packaged_task nicht zwingend in einem anderen Thread ausgeführt werden muss. Es „verknüpft“ nur das Ergebnis mit dem future. Wo Sie das task letztlich ausführen, ist Designentscheidung Ihrer Anwendung.
5. Welches Werkzeug wählen und die Disziplin nicht verletzen
promise vs packaged_task vs async
An dieser Stelle kann man leicht in Panik geraten: drei Werkzeuge, alle zur Asynchronität, ähnlich, aber nicht identisch. Lassen Sie uns das Bild stabilisieren. Unten eine Tabelle, die man im Kopf behalten sollte, damit man nicht versucht, mit einem Mikroskop Nägel einzuschlagen.
| Werkzeug | Was Sie schreiben | Was Sie bekommen | Wer „legt das Ergebnis hinein“ | Wann praktisch |
|---|---|---|---|---|
|
callable + Argumente | future<R> | die Bibliothek (via return/Exception im Callable) | „Starte das hier und gib mir später das Ergebnis zurück“ |
|
Sie rufen selbst set_value auf | future<T> | Sie manuell | „Das Ergebnis kommt als Ereignis, nicht als return“ |
|
callable verpackt im Objekt | future<R> + task-Objekt | task beim Aufruf | „Ich will die Aufgabe als Entität speichern/übergeben und ein future bekommen“ |
Ganz grob: async ist am höchsten abstrahiert, promise ist am manuellsten, und packaged_task ist sinnvoll, wenn Sie die Aufgabe als Objekt behandeln und irgendwohin legen wollen, um sie später auszuführen.
Lebensdauer-Disziplin und Verantwortlichkeiten
Wenn Sie anfangen, promise und packaged_task zu verwenden, kommt eine neue Verantwortung hinzu, die im „einfachen“ Code kaum existiert. Sie müssen garantieren, dass der gemeinsame Zustand abgeschlossen wird: entweder mit einem Wert oder mit einem Fehler. Andernfalls wird der Konsument warten und später eine unangenehme Überraschung erleben.
Praktisch bedeutet das: Wenn Sie ein future erstellen, existiert irgendwo ein Lieferant (promise oder task). Der Lieferant muss entweder einen Wert setzen oder einen Fehler melden. Wenn der Lieferant verschwindet, ohne das Versprechen einzulösen, erfährt der Konsument das bei get() (es entsteht ein Fehler „broken promise“).
Als Disziplin ist es hilfreich, folgende einfache Regel zu akzeptieren: Erstellen Sie keinen Ergebniskanal, wenn Sie nicht garantieren können, dass irgendwann ein Abschluss kommt. Die Standardbibliothek verhält sich wie ein strenger Lehrer: „versprochen — erfülle es“. Und das ist im Großen und Ganzen fair.
6. Typische Fehler
Fehler Nr. 1: versuchen, get_future() zweimal aufzurufen.
Anfänger erwarten manchmal, dass man mehrere futures aus einem promise oder einer packaged_task bekommen kann. In der Grundmodellierung ist das nicht so: get_future() gibt normalerweise einen einzelnen Konsumenten. Wenn Sie mehrere Konsumenten brauchen, ist das eine andere Konstruktion (nicht Thema dieser Vorlesung).
Fehler Nr. 2: vergessenes mutable, wenn man promise/packaged_task in eine Lambda verschiebt.
Lambda ist standardmäßig const, und set_value() oder der Aufruf von packaged_task::operator() ändert den Zustand des Objekts. Ergebnis: Compilerfehler, der „aus heiterem Himmel“ erscheinen kann. Die Lösung ist, mutable hinzuzufügen und sich bewusst zu machen, dass wir das gefangene Objekt innerhalb der Lambda verändern.
Fehler Nr. 3: per Referenz erfassen von Daten, die die Lebensdauer des Threads nicht überleben.
Sehr typische Geschichte: Sie erstellen ein promise, starten einen Thread, und in der Lambda verwenden Sie eine Referenz auf eine lokale Variable aus main. main ist längst weitergelaufen, die Variable existiert nicht mehr, und der Thread lebt noch. Im besten Fall bekommen Sie komische Werte, im schlimmsten Fall einen Absturz. In asynchronem Code passiert das besonders leicht; daher ist Capture-by-value und durchdachtes Lebensdauer-Management keine Pedanterie, sondern Absicherung.
Fehler Nr. 4: verwechseln „task verpacken“ und „task ausführen“.
std::packaged_task macht von sich aus nichts. Es startet sich nicht automatisch. Wenn Sie ein task erstellen, ein future bekommen und vergessen, das task aufzurufen, bleibt das future ewig nicht-ready. Das ist wie: einen Wecker kaufen, aber ihn nie aufziehen.
Fehler Nr. 5: eine zu komplizierte Architektur bauen, wo std::async ausreichen würde.
Manchmal wirken promise und packaged_task „erwachsener“, und die Hand greift danach, sie überall einzusetzen. Das ist eine Falle. Wenn sich eine Aufgabe natürlich als Funktion «rechnet und liefert» ausdrückt, ist std::async oft einfacher und verständlicher. promise und packaged_task kommen dann zum Tragen, wenn Sie wirklich Producer/Consumer trennen oder die Aufgabe als Objekt speichern/dispatchen wollen.
GO TO FULL VERSION