Der Abschluss des Exkurses: Unified Futures

Modernes C++  –  0 Kommentare

Nach meinem letzten Artikel zu Executors schreibe ich heute abschließend über Unified Futures schreiben. Ganz genau beschäftige ich mich mit der langen Geschichte der Futures und beende damit meinen Ausflug von den "C++ Core Guidelines".

Die lange Geschichte der Promise und Futures begann mit C++11.

C++11: Die standardisierten Futures

Tasks in der Gestalt von Promisen und Futures haben einen ambivalenten Ruf in C++. Einerseits sind sie deutlich einfacher zu verwenden als Threads oder Bedingungsvariablen, andererseits besitzen sie eine große Schwäche: Ihre Aufrufe können nicht verknüpft werden. C++20/23 soll diese Schwächen überwinden. Zu Tasks in der Gestalt von std::async, std::packaged_task oder std::promise und std::future habe ich bereits einige Artikel geschrieben: Tasks. Mit C++20/23 werden wir wohl erweiterte Futures erhalten.

Aufgrund der Schwächen der in C++11 standardisierten Futures,erweiterte ISO/IEC TS 19571:2016 die Futures. Aus der Vogelperspektive betrachtet, können sie nun verknüpft werden. Ein erweiterter Future ist fertig (ready), wenn sein Vorgänger (then), wenn einer seiner Vorgänger (when_any) fertig ist oder wenn alle seiner Vorgänger (when_all) fertig sind. Die erweiterten Futures befinden sich im Namensraum std::experimental. Falls du neugierig bist, hier sind die Details: std::future-Erweiterungen.

Dies war aber noch nicht der Endpunkt einer intensiven Diskussion. Mit der Renaissance von Executors änderte sich auch die "Future" der Futures.

Das Dokument "P0701r1: Back to the std2::future Part II" gibt einen großartigen Überblick zu den Nachteilen der standardisierten und der erweiterten Futures.

Nachteile der standardisierten und erweiterten Futures

Futures und Promise sollten nicht mit dem Executor std::thread fest verknüpft sein

C++11 besitzt nur einen Executor: std::thread. Konsequenterweise sind daher Futures und std::thread fest verknüpft. Dies änderte sich das erst Mal mit den parallelen Algorithmen der STL. Dies wird sich noch mehr mit den Executors ändern, die es erlauben, den Future zu konfigurieren. Zum Beispiel kann der Future in einem separaten Thread, auf einem Threadpool oder schlicht sequenziell ausgeführt werden.

Wo wird die .then-Fortsetzung aufgerufen?

Stelle dir vor, du verwendest eine einfache Fortsetzung wie in dem folgenden Beispiel:

future f1 = async([]() { return 123; }); 
future f2 = f1.then([](future f) {
return to_string(f.get());
});

Die Frage ist: Wo soll die Fortsetzung ausgeführt werden? Gegenwärtig gibt es ein paar Möglichkeiten.

  1. Der Konsument: Der Execution Agent des Konsumenten führt die Fortsetzung aus.
  2. Der Produzent: Der Execution Agent des Produzenten führt die Fortsetzung aus.
  3. inline_executor-Semantik: Falls der gemeinsame Zustand fertig ist, wenn die Fortsetzung gesetzt wird, wird die Fortsetzung vom Konsument ausgeführt; falls der gemeinsame Zustand nicht fertig ist, vom Produzenten.
  4. thread_executor-Semantik: Ein neuer Thread std::thread führt die Fortsetzung aus.

Insbesondere die ersten zwei Möglichkeiten besitzen einen deutlichen Nachteil: Sie blockieren. Im ersten Fall wartet der Konsument, bis der gemeinsame Zustand fertig ist; im zweiten Fall wartet der Produzent, bis der gemeinsame Zustand fertig ist.

Das Dokument P0701r184 enthält ein paar Beispiele für die Fortsetzung der Ausführung:

auto i = std::async(thread_pool, f).then(g).then(h);
// f, g and h are executed on thread_pool.

auto i = std::async(thread_pool, f).then(g, gpu).then(h);
// f is executed on thread_pool, g and h are executed on gpu.

auto i = std::async(inline_executor, f).then(g).then(h);
// h(g(f())) are invoked in the calling execution agent.
Die Übergabe des futures an .then ist kompliziert

Da der Future und nicht sein Wert an die Fortsetzung übergeben wird, ist die Syntax recht kompliziert:

std::future f1 = std::async([]() { return 123; });
std::future f2 = f1.then([](std::future f) {
return std::to_string(f.get());
});

Nun nehme ich an, dass der Wert direkt übergeben werden kann, da to_string für std::future überladen ist:

std::future f1 = std::async([]() { return 123; });
std::future f2 = f1.then(std::to_string);
Die Rückgabetypen von when_all und when_any sind zu kompliziert

Mein Artikel std::future Erweiterungen stellt die aufwendige Verwendung von when_all und when_any genauer vor.

Futures können im Destruktor blockieren

"Fire and Forget"-Futures hören sich sehr vielversprechend an, besitzen aber einen großen Nachteil. Ein Future, der durch std::async erzeugt wird, wartet in seinem Destruktor, bis der Promise fertig ist. Was nach Concurrency ausschaut, wird daher tatsächlich sequenziell ausgeführt. Das Dokument P0701r1 bringt es auf den Punkt: das ist nicht tolerierbar und fehleranfällig.

Ich gehe auf dieses besondere Verhalten der "Fire and Forget"-Futures bereits in dem Artikel "Besondere Futures mit std::async" ein.

Werte und die Werte der Futures sollten sich einfach zusammen verwenden lassen

In C++11 gibt keinen einfachen Weg, einen Future zu erzeugen. Dazu ist immer ein Promise notwendig:

std::promise<std::string> p;
std::future<std::string> fut = p.get_future();
p.set_value("hello");

Dies wird sich wohl mit der Funktion std::make_ready_future der erweiterten Futures ändern.

std::future<std::string> fut = make_ready_future("hello");

Durch die gleichzeitige Verwendung von Futures und Werten als Argumente ist der Aufruf von when_all deutlich einfacher:

bool f(std::string, double, int);

std::future<std::string> a = /* ... */;
std::future<int> c = /* ... */;

std::future<bool> d1 = when_all(a, make_ready_future(3.14), c).then(f);
// f(a.get(), 3.14, c.get())

std::future<bool> d2 = when_all(a, 3.14, c).then(f);
// f(a.get(), 3.14, c.get())

Leider ist weder die syntaktische Form d1 noch d2 mit den erweiterten Futures möglich.

Fünf neue Konzepte

Er gibt fünf neue Konzepte für Futures und Promise in dem Proposal 1054R085 zu Unfied Futures.

  • FutureContinuation: aufrufbare Objekte, die als Argument einen Future mit einem Wert oder einer Ausnahme annehmen können.
  • SemiFuture: diese können an einen Executor gebunden werden, sodass ein ContinuableFuture (f = sf.via(exec)) entsteht.
  • ContinuableFuture: ist eine Verfeinerung eines SemiFuture, an den sich eine FutureContinuation anheften lässt: f.then(c). Die FutureContinuation c wird ausgeführt, wenn der Executor des Future f fertig ist.
  • SharedFuture: stellt eine Verfeinerung eines ContinuableFuture dar, der mehrere FutureContinuations besitzen kann.
  • Promise: Jeder Promise ist mit einem Future assoziiert. Der Promise stellt den Future mit einem Wert oder einer Ausnahme fertig.

Das Dokument bietet bereits eine Deklaration der neuen Konzepte an:

template <typename T>
struct FutureContinuation
{
// At least one of these two overloads exists:
auto operator()(T value);
auto operator()(exception_arg_t, exception_ptr exception);
};

template <typename T>
struct SemiFuture
{
template <typename Executor>
ContinuableFuture<Executor, T> via(Executor&& exec) &&;
};

template <typename Executor, typename T>
struct ContinuableFuture
{
template <typename RExecutor>
ContinuableFuture<RExecutor, T> via(RExecutor&& exec) &&;

template <typename Continuation>
ContinuableFuture<Executor, auto> then(Continuation&& c) &&;
};

template <typename Executor, typename T>
struct SharedFuture
{
template <typename RExecutor>
ContinuableFuture<RExecutor, auto> via(RExecutor&& exec);

template <typename Continuation>
SharedFuture<Executor, auto> then(Continuation&& c);
};

template <typename T>
struct Promise
{
void set_value(T value) &&;

template <typename Error>
void set_exception(Error exception) &&;
bool valid() const;
};

Der Einfachheit halber will ich die fünf Konzepte kurz zusammenfassen.

  • Eine FutureContinuation kann mit einem Value oder einer Ausnahme aufgerufen werden.
  • Alle Futures (SemiFuture, ContinuableFuture und SharedFuture) besitzen eine Methode via, die einen Executor annimmt, der ein ContinuableFuture zurückgibt. Die Methode via erlaubt eine Konvertierung von einem in einen anderen Future-Typ, indem ein anderer Executor zum Einsatz kommt.
  • Nur eine ContinuableFuture und eine SharedFuture besitzen die then-Methode für die Fortsetzung. Die Methode benötigt eine FutureContinuation und gibt einen ContinuableFuture zurück.
  • Ein Promise kann einen Wert oder eine Ausnahme setzen.

Zukünftige Arbeit

Das Proposal 1054R085 lässt ein paar Fragen offen:

  • Forward-Progress-Garantien für Futures und Promise.
  • Anforderungen an die Synchronisation von Futures und Promise an Ausführungen, die nicht concurrent sind.
  • Das Zusammenspiel mit den standardisierten std::future und std::promise.
  • Future unwrapping für future<future>> oder noch kompliziertere Ausdrücke. Future unwrapping soll in dem konkreten Fall den äußeren Future entfernen.
  • Umsetzung von when_all, when_any oder when_n.
  • Zusammenspiel mit sys::async.

Wie geht's weiter?

Mein nächster Artikel setzt die Reise durch die "C++ Core Guidelines" fort. In ihm werde ich mich mit der lock-freien Programmierung beschäftigen.