Ein kleiner Exkurs: Executors

Modernes C++  –  12 Kommentare

Vor ein paar Wochen sendete mir Felix Petriconi, einer der Autoren des Proposals zur Futures, eine E-Mail. Er schrieb, dass mein Artikel zu std::future-Erweiterungen bereits veraltet sei. Leider hat er Recht. Die Zukunft der Futures hat sich deutlich mit dem neuen Erstarken der Executors verändert.

Bevor ich über die Zukunft der Futures schreibe, muss ich erst auf das Konzept der Executors eingehen. Sie besitzen eine lange Geschichte in C++, die mindestens acht Jahre alt. Der Vortrag "Finally Executors for C++" von Detlef Vollmann gibt einen sehr schönen Überblick dazu.

Dieser Artikel basiert zu großen Teilen auf dem Proposal P0761 zum Design von Executors und deren formaler Beschreibung im Proposal P0441. Dieser Artikel bezieht sich auch auf das relativ neue Proposal P1055: Modest Executor Proposal.

Zuerst einmal. Was ist ein Executor?

Sie sind der Grundbaustein, um etwas in C++ auszuführen. Sie nehmen eine ähnliche Rolle wie die Allokatoren für die Container in C++ ein. Zu diesem Zeitpunkt sind bereits einige Proposals zu Executors verfasst worden, und viele Entscheidungen sind noch offen. Die Erwartung ist, dass sie mit C++23 Bestandteil des Standards sind, sie aber als Erweiterung des C++-Standards schon deutlich früher zur Verfügung stehen.

Ein Executor besteht aus einer Menge von Regeln, wo, wann und wie eine aufrufbare Einheit ausgeführt werden soll. Eine aufrufbare Einheit kann eine Funktion, ein Funktionsobjekt oder auch eine Lambda-Funktion sein.

  • Wo: Die aufrufbare Einheit kann auf einem internen oder externen Prozessor laufen und das Ergebnis von diesem Prozessor auslesen.
  • Wann: Die aufrufbare Einheit kann sofort starten oder nur vorgemerkt werden.
  • Wie: Die aufrufbare Einheit kann auf einer CPU, einer GPU oder auch vektorisiert ausgeführt werden.

Da Executors der Grundbaustein sind, um etwas auszuführen, hängen die Features zur Concurrency und zur Parallelität in C++ sehr stark von ihnen ab. Das gilt für die neuen Feature zur Concurreny in C++20/23 wie erweiterte Futures, Latches und Barriers, Coroutinen, Transaktional Memory und Task-Blöcke. Dies gilt aber auch für die Erweiterung zur Netzwerk-Programmierung P4734 in C++ und die parallelen Algorithmen der STL.

Hier sind ein paar Codebeispiele zur Anwendung des Executors my_executor.

  • Der Promise std::async:
// get an executor through some means
my_executor_type my_executor = ...

// launch an async using my executor
auto future = std::async(my_executor, [] {
std::cout << "Hello world, from a new execution agent!" << std::endl;
});
  • Der STL-Algorithmus std::for_each:
// get an executor through some means
my_executor_type my_executor = ...

// execute a parallel for_each "on" my executor
std::for_each(std::execution::par.on(my_executor),
data.begin(), data.end(), func);

Es gibt viele Wege, einen Executor zu erhalten:

  • Vom Execution-Kontext static_thread_pool:
// create a thread pool with 4 threads
static_thread_pool pool(4);

// get an executor from the thread pool
auto exec = pool.executor();

// use the executor on some long-running task
auto task1 = long_running_task(exec);
  • Der System-Executor: Dies ist der Default-Executor, der in der Regel einen Thread verwendet. Dieser Executor wird verwendet, wenn kein anderer angegeben wurde.
  • Von einem Executor-Adapter:
// get an executor from a thread pool
auto exec = pool.executor();

// wrap the thread pool's executor in a logging_executor
logging_executor<decltype(exec)> logging_exec(exec);

// use the logging executor in a parallel sort
std::sort(std::execution::par.on(logging_exec),
my_data.begin(), my_data.end());

Der logging-Executor ist in dem Codebeispiel ein Wrapper für den pool-Executor.

Was sind gemäß dem Proposal P1055 die Ziele eines Executor Concept?

  • Batchable: kontrolliert den Kompromiss zwischen dem Übertagen der Funktion und seiner Größe.
  • Heterogenous: erlaubt es, die aufrufbare Einheit auf verschiedenen Kontexten laufen zu lassen und das Ergebnis des Aufrufs zu erhalten.
  • Orderable: erlaubt die Reihenfolge anzugeben, in der die aufrufbare Einheiten aufgerufen werden. Dieses Ziel schließt Garantien wie LIFO- (Last In, First Out), FIFO- (First In, First Out) Ausführungen, aber auch Prioritätsvergaben, zeitliche Einschränkungen oder die sequenzielle Ausführung ein.
  • Controllable: Die ausführbare Einheit muss sich auf einer bestimmen Rechenressource sofort oder verzögert ausführen lassen. Selbst der Abbruch einer Ausführung soll möglich sein.
  • Continuable: Um eine asynchrone ausführbare Einheit auszuführen, sind Signale notwendig. Diese sollen anzeigen, ob das Ergebnis zur Verfügung steht, ob ein Fehler aufgetreten ist, ob die ausführbare Einheit fertig ist oder ob diese abgebrochen werden soll. Das explizite Starten oder Anhalten soll auch möglich sein.
  • Layerable: Hierarchien erlauben es, die Executors mit mehr Funktionalität auszustatten, ohne deren Komplexität zu erhöhen.
  • Usable: Die einfache Anwendung und die einfache Implementierung soll im Fokus der Executors stehen.
  • Composable: erlaubt die Executors mit Funktionalität zu erweitern, die nicht Teil des Standards sind.
  • Minimal: Das Executor Concept sollte nur enthalten, was sich nicht durch Bibliotheken implementieren lässt.

Ein Executor bietet mindestens eine der sechs Ausführungsfunktionen an, um aus einer ausführbaren Einheit einen Execution Agent zu erzeugen. Jede Ausführungsfunktion besitzt zwei Eigenschaften: Kardinalität und Richtung:

  • Kardinalität:
    • single: erzeugt einen Execution Agent.
    • bulk: erzeugt eine Gruppe von Execution Agents.
  • Richtung:
    • oneway: erzeugt einen Execution Agent, der kein Ergebnis zurückgibt.
    • twoway: erzeugt einen Execution Agent, der einen Future zurückgibt, um auf das Ergebnis der Ausführung zu warten.
    • then: erzeugt einen Execution Agent, er einen Future zurückgibt, um auf das Ergebnis der Ausführung zu warten. Der Execution Agent beginnt mit seiner Ausführung, nachdem der übergebenen Future fertig ist.

Gerne gehe ich nochmals weniger formal auf die Ausführungsfunktionen ein.

Zuerst beziehe ich mich auf den single-Kardinalität-Fall.

  • Eine oneway-Ausführungsfunktion ist ein "fire and forget"-Job. Dieser ist einem fire and forget Future ziemlich ähnlich. Dieser blockiert aber nicht automatisch in seinem Destruktor.
  • Eine twoway-Ausführungsfunktion gibt einen Future zurück. Damit lässt sich das Ergebnis abfragen. So verhält sich diese Ausführungsfunktion wie ein std::promise, der einen Handle auf sein Ergebnis mit dem assoziierten std::future zurückgibt.
  • Eine then-Ausführungsfunktion ist eine Art Fortsetzung. Sie gibt einen Future zurück. Der Execution Agent wird nur dann ausgeführt, wenn der Future fertig ist.

Der bulk-Kardinalität-Fall ist komplizierter. Diese Ausführungsfunktionen erzeugen eine Gruppe von Execution Agents, und jeder dieser Execution Agent ruft die gleiche aufrufbare Einheit auf. Sie geben das Ergebnis einer Fabrik zurück und nicht das Ergebnis der einzelnen Execution Agent. Es liegt in der Verantwortung des Anwenders, das richtige Ergebnis mit Hilfe der Fabrik zu erzeugen.

Wie kannst du dir sicher sein, dass dein Executor die gewünschte Ausführungsfunktion unterstützt? Im konkreten Fall weißt du es:

void concrete_context(const my_oneway_single_executor& ex)
{
auto task = ...;
ex.execute(task);
}

Im allgemeinen Fall kannst du die Funkton execution::require verwenden:

template <typename Executor> 
void generic_context(const Executor& ex)
{
auto task = ...;

// ensure .twoway_execute() is available with execution::require()
execution::require(ex, execution::single,
execution::twoway).twoway_execute(task);
}

In diesem Fall muss der Executor ex die Kardinalität single und die Richtung twoway unterstützten.

In meinem nächsten Artikel geht mein Exkurs von den "C++ Core Guidelines" weiter. Die Zukunft der Futures ändert sich hauptsächlich wegen der Executoren. Daher geht es im nächsten Artikel um die Futures.

Falls du die ganzen Details zu Concurrency von C++11 bis C++20 wissen willst. Mein Buch "Concurrency with Modern C++" gibt es seit heute auch auf Deutsch bei Hanser: "Modernes C++: Concurrency meistern".