Oktober 2015

Band 30, Nummer 10

Windows mit C++ – Coroutinen in Visual C++ 2015

Von Kenny Kerr | Oktober 2015

Von Coroutinen in C++ habe ich erstmals 2012 erfahren und hier im MSDN Magazine in einer Artikelreihe darüber geschrieben. Ich habe eine funktionsreduzierte Form des kooperativen Multitaskings untersucht, bei der Coroutinen emuliert werden, indem "switch"-Anweisungen clever genutzt werden. Ich habe anschließend einige Möglichkeiten zum Verbessern der Effizienz und Zusammensetzbarkeit asynchroner Systeme mit vorgeschlagenen Erweiterungen für Promises und Futures untersucht. Schließlich bin ich auf verschiedene Herausforderungen, die es selbst bei einer futuristischen Vision von Futures gibt, sowie auf einen Vorschlag für etwas eingegangen, das sich fortsetzbare Funktionen nennt. Ich möchte Sie anregen, diese Artikel zu lesen, wenn Sie Interesse an Herausforderungen und der bisherigen Entwicklung im Zusammenhang mit eleganter Nebenläufigkeit in C++ haben:

Ein Großteil dieser Abhandlungen war theoretisch, da ich keinen Compiler zum Umsetzen dieser Ideen hatte und diese auf verschiedene Weisen emulieren musste. Doch Anfang dieses Jahres kam Visual Studio 2015 auf den Markt. Diese Edition von Visual C++ bietet die experimentelle Compileroption "/await", die eine Implementierung von Coroutinen ermöglicht, die direkt vom Compiler unterstützt werden. Keine Hacks, Makros oder anderen Tricks mehr. Diese Option ist das Wahre, obgleich sie experimentell ist und noch nicht vom C++-Komitee bestätigt wurde. Und es handelt sich nicht bloß um ein syntaktisches Zückerchen im Compiler-Front-End, wie z. B. das C#-Schlüsselwort "yield" und asynchrone Methoden. Die C++-Implementierung stellt eine umfassende Entwicklungsinvestition in das Compiler-Back-End dar, die eine unglaublich skalierbare Implementierung bietet. Sie geht in der Tat weit über das hinaus, was Sie ggf. vorfänden, wenn das Compiler-Front-End einfach nur eine komfortablere Syntax für das Arbeiten mit Promises und Futures oder selbst mit der Task-Klasse "Concurrency Runtime" bieten würde. Lassen Sie uns die Thematik wiederaufgreifen und prüfen, wie es heute damit aussieht. Seit 2012 hat sich viel getan, weshalb ich kurz rekapitulieren möchte, von wo wir gekommen sind und wo wir jetzt stehen, ehe ich auf einige spezifischere Beispiele und praktische Zwecke eingehe.

Die zuvor erwähnte Reihe habe ich mit einem überzeugenden Beispiel für fortsetzbare Funktionen beendet, weshalb ich hier ansetze. Stellen Sie sich ein Paar Ressourcen vor, die aus einer Datei gelesen und in eine Netzwerkverbindung geschrieben werden:

struct File
{
  unsigned Read(void * buffer, unsigned size);
};
struct Network
{
  void Write(void const * buffer, unsigned size);
};

Den Rest können Sie sich vorstellen, doch ist dies ziemlich repräsentativ dafür, wie eine herkömmliche synchrone E/A aussehen kann. Die "Read"-Methode der "File"-Klasse versucht, Daten an der aktuellen Dateiposition im Puffer bis zu einer maximalen Größe zu lesen und gibt die tatsächliche Anzahl kopierter Bytes zurück. Wenn der Rückgabewert kleiner als die angeforderte Größe ist, heißt dies meist, dass das Ende der Datei erreicht wurde. Die "Network"-Klasse bildet ein typisches verbindungsorientiertes Protokoll wie TCP oder eine Windows Named Pipe ab. Die "Write"-Methode kopiert eine bestimmte Anzahl von Bytes in den Netzwerkstapel. Ein typischer synchroner Kopiervorgang ist einfach vorstellbar, doch ich greife Ihnen mit Abbildung 1 unter die Arme, damit Sie einen Bezugsrahmen haben.

Abbildung 1: Synchroner Kopiervorgang

File file = // Open file
Network network = // Open connection
uint8_t buffer[4096];
while (unsigned const actual = file.Read(buffer, sizeof(buffer)))
{
  network.Write(buffer, actual);
}

Solange die "Read"-Methode einen Wert größer Null zurückgibt, werden die resultierende Bytes mithilfe der "Write"-Methode aus dem Zwischenpuffer in das Netzwerk kopiert. Das ist die Art von Code, die eigentlich jeder Programmierer unabhängig von seinem Hintergrund mühelos verstehen sollte. Freilich bietet Windows Dienste zum vollständigen Verlagern dieser Art von Vorgang in den Kernel, um alle Übergänge zu vermeiden. Doch diese Dienste sind auf bestimmte Szenarien begrenzt, was repräsentativ für die Arten von Blockiervorgängen ist, mit denen Apps häufig zu tun haben.

Die C++-Standardbibliothek bietet Futures und Promises für den Versuch, asynchrone Vorgänge zu unterstützen, doch aufgrund ihres naiven Designs wurden dies zumeist verschmäht. Diese Probleme habe ich bereits 2012 angesprochen. Selbst wenn wir über diese Probleme hinwegsehen, ist das Neuschreiben des Kopierbeispiels "file" zu "network" in Abbildung 1 keine triviale Sache. Die direkteste Übersetzung der synchronen (und einfachen) "while"-Schleife erfordert einen sorgfältig manuell erstellten Iterationsalgorithmus, der eine Kette von Futures durchlaufen kann:

template <typename F>
future<void> do_while(F body)
{
  shared_ptr<promise<void>> done = make_shared<promise<void>>();
  iteration(body, done);
  return done->get_future();
}

In der "iteration"-Funktion wird der Algorithmus wirklich lebendig:

template <typename F>
void iteration(F body, shared_ptr<promise<void>> const & done)
{
  body().then([=](future<bool> const & previous)
  {
    if (previous.get()) { iteration(body, done); }
    else { done->set_value(); }
  });
}

Das Lambda muss das gemeinsame Promise anhand des Werts erfassen, da dies wirklich iterativ und nicht rekursiv ist. Was aber problematisch ist, da dies für jede Iteration ein Paar ineinandergreifender Vorgänge bedeutet. Darüber hinaus haben Futures noch keine "then"-Methode zum Verketten von Fortsetzungen, obwohl Sie diese mittlerweile mithilfe der Task-Klasse "Concurrency Runtime" simulieren können. Dennoch könnte ich unter der Annahme, dass solch futuristische Algorithmen und Fortsetzungen vorhanden sind, den synchronen Kopiervorgang in Abbildung 1 auf asynchrone Weise neu schreiben. Als Erstes muss ich den Klassen "File" und "Network" asynchrone Überladungen hinzufügen: Vielleicht etwa so:

struct File
{
  unsigned Read(void * buffer, unsigned const size);
  future<unsigned> ReadAsync(void * buffer, unsigned const size);
};
struct Network
{
  void Write(void const * buffer, unsigned const size);
  future<unsigned> WriteAsync(void const * buffer, unsigned const size)
};

Die "WriteAsync"-Methode des Futures muss die Anzahl der kopierten Bytes wiederholen, da dies alles ist, über das eine Fortsetzung verfügt, um zu entscheiden, ob die Iteration beendet werden soll. Eine andere Option ist ggf., dass die "File"-Klasse eine "EndOfFile"-Methode bereitstellt. Auf jeden Fall kann der Kopiervorgang angesichts dieser neuen Primitiven auf eine verständliche Weise ausgedrückt werden, wenn man sich genügend Koffein eingeflößt hat. Abbildung 2 veranschaulicht diesen Ansatz.

Abbildung 2: Kopiervorgang mit Futures

File file = // Open file
Network network = // Open connection
uint8_t buffer[4096];
future<void> operation = do_while([&]
{
  return file.ReadAsync(buffer, sizeof(buffer))
    .then([&](task<unsigned> const & read)
    {
      return network.WriteAsync(buffer, read.get());
    })
    .then([&](task<unsigned> const & write)
    {
      return write.get() == sizeof(buffer);
    });
});
operation.get();

Der "do_while"-Algorithmus vereinfacht das Verketten von Fortsetzungen, solange der "Hauptteil" der Schleife "true" zurückgibt. So wird "ReadAsync" aufgerufen, dessen Ergebnis von "WriteAsync" verwendet wird, dessen Ergebnis wiederum als Schleifenbedingung getestet wird. Dies ist zwar keine Raketenwissenschaft, aber ich habe kein Verlangen, Code wie diesen zu schreiben. Er ist gekünstelt und wird schnell zu komplex, um ein weiteres Wort darüber zu verlieren. Hier kommen fortsetzbare Funktionen ins Spiel.

Das Hinzufügen der Compileroption "/await" ermöglicht, dass der Compiler fortsetzbare Funktionen, eine Implementierung von Coroutinen für C++, unterstützt. Sie werden fortsetzbare Funktionen anstatt bloß Coroutinen genannt, da sie sich so umfassend wie möglich wie herkömmliche C++-Funktionen verhalten sollen. Und im Gegensatz zu meinen Erörterungen im Jahr 2012 soll ein Consumer einer bestimmten Funktion überhaupt nicht wissen müssen, ob diese tatsächlich als Coroutine implementiert ist.

Zum Zeitpunkt des Verfassens dieses Artikels erfordert die Compileroption "/await" auch die Option "/Zi" anstelle der Standardoption "/ZI", um das Feature "Bearbeiten und fortfahren" des Debuggers zu deaktivieren. Sie müssen außerdem SDL-Überprüfungen mit der Option "/sdl-" deaktivieren und die "/RTC"-Optionen vermeiden, da die Laufzeitprüfungen des Compilers nicht mit Coroutinen kompatibel sind. Alle diese Einschränkungen gelten vorübergehend und sind auf die experimentelle Natur der Implementierung zurückzuführen. In den anstehenden Updates des Compilers sollten sie beseitigt werden. Doch wie in Abbildung 3 ersichtlich, lohnt sich der Aufwand unbedingt. Dies ist offensichtlich und fraglos wesentlich einfacher zu schreiben und zu verstehen, als das, was für den mit Futures implementierten Kopiervorgang erforderlich war. Es sieht vielmehr so aus wie das ursprüngliche synchrone Beispiel in Abbildung 1. Außerdem muss das "WriteAsync"-Future in diesem Fall keinen spezifischen Wert zurückgeben.

Abbildung 3: Kopiervorgang in der fortsetzbaren Funktion

future<void> Copy()
{
  File file = // Open file
  Network network = // Open connection
  uint8_t buffer[4096];
  while (unsigned copied = await file.ReadAsync(buffer, sizeof(buffer)))
  {
    await network.WriteAsync(buffer, copied);
  }
}

Das in Abbildung 3 verwendete Schlüsselwort "await" sowie andere neue Schlüsselwörter, die von der Compileroption "/await" geboten werden, können nur in einer fortsetzbaren Funktion enthalten sein. Deshalb ist die umgebende "Copy"-Funktion zu sehen, die ein Future zurückgibt. Ich verwende dieselben "ReadAsync"- und "WriteAsync"-Methoden wie in den vorherigen Futures-Beispielen, doch wichtig ist der Hinweis, dass dem Compiler keine Futures bekannt sind. In der Tat müssen sie überhaupt keine Futures sein. Wie funktioniert das also? Nun, es funktioniert erst, wenn bestimmte Adapterfunktionen geschrieben werden, die dem Compiler die benötigten Bindungen bereitstellen. Dies entspricht der Weise, in der der Compiler bestimmt, wie eine bereichsbasierte "for"-Anweisung verbunden werden soll, indem geeignete "begin"- und "end"-Funktionen gesucht werden. Bei einem "await"-Ausdruck sucht der Compiler nicht nach "begin" und "end", sondern geeignete Funktionen, die "await_ready", "await_suspend" und "await_resume" heißen. Diese neuen Funktionen können wie "begin" und "end" entweder Member- oder freie Funktionen sein. Die Möglichkeit, Nicht-Memberfunktionen schreiben zu können, ist überaus hilfreich, da Sie dadurch Adapter für vorhandene Typen schreiben können, die die benötigte Semantik bieten, was bei den futuristischen Futures der Fall ist, die ich bislang untersucht habe. Abbildung 4 zeigt eine Gruppe von Adaptern, die der Interpretation des Compilers der fortsetzbaren Funktion in Abbildung 3 entsprechen.

Abbildung 4: "Await"-Adapter für ein hypothetisches Future

namespace std
{
  template <typename T>
  bool await_ready(future<T> const & t)
  {
    return t.is_done();
  }
  template <typename T, typename F>
  void await_suspend(future<T> const & t, F resume)
  {
    t.then([=](future<T> const &)
    {
      resume();
    });
  }
  template <typename T>
  T await_resume(future<T> const & t)
  {
    return t.get();
  }
}

Beachten Sie wiederum, dass die Future-Klassenvorlage der C++-Standardbibliothek noch keine "then"-Methode bietet, um eine Fortsetzung hinzuzufügen. Doch ist dies alles, was nötig ist, damit dieses Beispiel im aktuellen Compiler funktioniert. Das Schlüsselwort "await" in einer fortsetzbaren Funktion legt gewissermaßen einen möglichen Anhaltepunkt fest, an dem die Ausführung die Funktion verlassen kann, falls der Vorgang noch nicht abgeschlossen ist. Falls "true" für "await_ready" zurückgegeben wird, wird die Ausführung nicht angehalten, und "await_resume" wird umgehend aufgerufen, um das Ergebnis abzurufen. Wenn hingegen "false" für "await_ready" zurückgegeben wird, wird "await_suspend" aufgerufen. Dies erlaubt dem Vorgang das Registrieren einer vom Compiler bereitgestellten "resume"-Funktion, die beim letztendlichen Abschluss aufgerufen wird. Sobald diese "resume"-Funktion aufgerufen wird, werden die Coroutinen beim vorherigen Anhaltepunkt fortgesetzt, und die Ausführung erfolgt weiter bis zum nächsten "wait"-Ausdruck oder zur Beendigung der Funktion.

Beachten Sie, dass die Fortsetzung für den jeweiligen Thread erfolgt, der die "resume"-Funktion des Compilers aufgerufen hat. Dies bedeutet, dass es durchaus möglich ist, dass eine fortsetzbare Funktion für einen Thread aktiv wird und die Ausführung später für einen anderen Thread fortsetzt. Aus Leistungssicht ist dies wünschenswert, da die Alternative das Verlagern der Fortsetzung zu einem anderen Thread bedeuten würde, was häufig aufwendig und unnötig ist. Andererseits kann es Fälle geben, bei denen es wünschenswert oder sogar erforderlich sein kann, sollte nachfolgender Code eine Threadaffinität aufweisen, was für den meisten Grafikcode gilt. Leider bietet das Schlüsselwort "await" noch keine Möglichkeit, dass der Autor eines "await"-Ausdrucks dem Compiler einen solchen Hinweis gibt. Dies ist nicht ohne Beispiel. Die Concurrency Runtime bietet eine solche Option, doch auch interessanterweise bietet die Sprache C++ selbst ein Muster, das Sie befolgen können:

int * p = new int(1);
// Or
int * p = new (nothrow) int(1);

Gleichfalls benötigt der "await"-Ausdruck einen Mechanismus zum Bereitstellen eines Hinweises für die "await_suspend"-Funktion zum Beeinflussen des Threadkontexts, für den die Fortsetzung erfolgt:

await network.WriteAsync(buffer, copied);
// Or
await (same_thread) network.WriteAsync(buffer, copied);

Standardmäßig erfolgt die Fortsetzung auf die effizienteste Weise, die für den Vorgang möglich ist. Die Konstante "same_thread" eines beliebigen hypothetischen "std::same_thread_t"-Typs würde Überladungen der "await_suspend-"Funktion eindeutig machen. Die "await_suspend"-Funktion in Abbildung 3 wäre die Standard- und effizienteste Option, da sie vermutlich bei einem Arbeitsthread fortgesetzt und ohne einen weiteren Kontextwechsel abgeschlossen würde. Die "same_thread"-Überladung in Abbildung 5 könnte angefordert werden, wenn der Consumer Threadaffinität benötigt.

Abbildung 5: Hypothetische "await_suspend"-Überladung

template <typename T, typename F>
void await_suspend(future<T> const & t, F resume, same_thread_t const &)
{
  ComPtr<IContextCallback> context;
  check(CoGetObjectContext(__uuidof(context),
    reinterpret_cast<void **>(set(context))));
  t.then([=](future<T> const &)
  {
    ComCallData data = {};
    data.pUserDefined = resume.to_address();
    check(context->ContextCallback([](ComCallData * data)
    {
      F::from_address(data->pUserDefined)();
      return S_OK;
    },
    &data,
    IID_ICallbackWithNoReentrancyToApplicationSTA,
    5,
    nullptr));
  });
}

Diese Überladung ruft die "IContextCallback"-Schnittstelle für den aufrufenden Thread (bzw. das aufrufende Apartment) ab. Die Fortsetzung ruft dann schließlich die "resume"-Funktion des Compilers im selben Kontext auf. Wenn dies das STA (Single-Threaded Apartment) der App sein sollte, könnte die App weiterhin mit anderen Diensten mit Threadaffinität interagieren. Die Klassenvorlage "ComPtr" und Hilfsfunktion "check" gehören zur Bibliothek "Modern", die Sie unter github.com/kennykerr/modern herunterladen können. Sie können jedoch auch alles nutzen, was Ihnen zur Verfügung steht.

Ich habe einen weiten Bogen gespannt, wobei einiges weiter etwas theoretisch ist, doch der Visual C++-Compiler bietet bereits alle Instrumente, um diese Ideen zu realisieren. Es ist eine aufregende Zeit für C++-Entwickler mit Interesse an Nebenläufigkeit, und ich hoffe, dass Sie auch im nächsten Monat wieder dabei sind, wenn ich mich ausgiebig mit fortsetzbaren Funktionen in Visual C++ beschäftige.


Kenny Kerr ist Programmierer aus Kanada sowie Autor bei Pluralsight und Microsoft MVP. Er veröffentlicht Blogs unter kennykerr.ca, und Sie können ihm auf Twitter unter @kennykerr folgen.

Unser Dank gilt dem folgenden technischen Experten bei Microsoft für die Durchsicht dieses Artikels: Gor Nishanov