In C ++ 20 wird die Möglichkeit angezeigt, sofort mit Coroutinen zu arbeiten. Dieses Thema ist für uns bei Yandex.Taxi nah und interessant (für unsere eigenen Bedürfnisse entwickeln wir ein asynchrones Framework). Daher werden wir heute den Lesern von Habr anhand eines realen Beispiels zeigen, wie man mit stapellosen C ++ - Coroutinen arbeitet.
Nehmen wir als Beispiel etwas Einfaches: Ohne mit asynchronen Netzwerkschnittstellen zu arbeiten, asynchrone Timer, die aus einer Funktion bestehen. Versuchen wir beispielsweise, diese „Nudel“ aus Rückrufen zu realisieren und neu zu schreiben:

void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto finally = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetworkThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(finally); }); } else { writerQueue.PushTask(finally); } }); } else { finally(); } }); }
Einführung
Coroutinen oder Coroutinen sind die Fähigkeit, die Ausführung einer Funktion an einem vordefinierten Ort zu verhindern. Übergeben Sie irgendwo den gesamten Status der gestoppten Funktion zusammen mit lokalen Variablen. Führen Sie die Funktion an derselben Stelle aus, an der wir sie gestoppt haben.
Es gibt verschiedene Arten von Coroutinen: stapellos und stapelbar. Wir werden später darüber sprechen.
Erklärung des Problems
Wir haben mehrere Aufgabenwarteschlangen. Jede Aufgabe enthält bestimmte Aufgaben: Es gibt eine Warteschlange zum Zeichnen von Grafiken, eine Warteschlange für Netzwerkinteraktionen und eine Warteschlange für die Arbeit mit einer Festplatte. Alle Warteschlangen sind Instanzen der WorkQueue-Klasse mit der void PushTask-Methode (std :: function <void ()> task); Warteschlangen leben länger als alle darin platzierten Aufgaben (die Situation, dass wir eine Warteschlange zerstört haben, wenn noch offene Aufgaben darin sind, sollte nicht auftreten).
Die Funktion FuncToDealWith () aus dem Beispiel führt eine Logik in verschiedenen Warteschlangen aus und stellt abhängig von den Ergebnissen der Ausführung eine neue Aufgabe in die Warteschlange.
Wir schreiben die „Nudeln“ von Rückrufen in Form eines linearen Pseudocodes neu und markieren, in welcher Warteschlange der zugrunde liegende Code ausgeführt werden soll:
void CoroToDealWith() { InCurrentThread();
Ungefähr dieses Ergebnis möchte ich erreichen.
Es gibt Einschränkungen:
- Warteschlangenschnittstellen können nicht geändert werden - sie werden in anderen Teilen der Anwendung von Drittentwicklern verwendet. Sie können keinen Entwicklercode brechen oder neue Warteschlangeninstanzen hinzufügen.
- Sie können die Verwendung der Funktion FuncToDealWith nicht ändern. Sie können nur den Namen ändern, aber keine Objekte zurückgeben, die der Benutzer zu Hause behalten muss.
- Der resultierende Code sollte genauso produktiv sein wie das Original (oder sogar noch produktiver).
Lösung
Schreiben Sie die Funktion FuncToDealWith neu
In Coroutines TS erfolgt die Coroutine-Optimierung durch Festlegen des Typs des Rückgabewerts der Funktion. Wenn der Typ bestimmte Anforderungen erfüllt, können Sie im Funktionskörper die neuen Schlüsselwörter co_await / co_return / co_yield verwenden. In diesem Beispiel verwenden wir co_yield, um zwischen Warteschlangen zu wechseln:
CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetworkThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); }
Es stellte sich heraus, dass es dem Pseudocode aus dem letzten Abschnitt sehr ähnlich war. Die gesamte „Magie“ für die Arbeit mit Coroutinen ist in der CoroTask-Klasse verborgen.
CoroTask
Im einfachsten (in unserem) Fall besteht der Inhalt der "Tuner" -Klasse der Coroutine nur aus einem Alias:
#include <experimental/coroutine> struct CoroTask { using promise_type = PromiseType; };
versprechen_typ ist ein Datentyp, den wir selbst schreiben müssen. Es enthält Logik, die beschreibt:
- Was tun beim Verlassen von Coroutine?
- Was tun, wenn Sie Corutin zum ersten Mal eingeben?
- Wer befreit Ressourcen
- Was tun mit Ausnahmen, die aus der Coroutine herausfliegen?
- So erstellen Sie ein CoroTask-Objekt
- Was tun, wenn in Corutins co_yield genannt wird?
Der Alias Versprechen_Typ muss so aufgerufen werden. Wenn Sie den Aliasnamen in etwas anderes ändern, schwört der Compiler und sagt, dass Sie CoroTask falsch geschrieben haben. Der Name CoroTask kann beliebig geändert werden.
Aber warum ist diese CoroTask notwendig, wenn alles in Versprechen_Typ beschrieben ist?In komplexeren Fällen können Sie CoroTask erstellen, mit dem Sie mit einer gestoppten Coroutine kommunizieren, Daten von ihr senden und empfangen, sie wecken und zerstören können.
PromiseType
Zum lustigen Teil kommen. Wir beschreiben das Verhalten von Corutin:
class WorkQueue;
Im obigen Code können Sie den Datentyp std :: experimentelle :: suspend_never bemerken. Dies ist ein spezieller Datentyp, der besagt, dass Corutin nicht gestoppt werden muss. Es gibt auch das Gegenteil - den Typ std :: experimental :: suspend_always, der Sie auffordert, Corutin zu stoppen. Diese Typen sind die sogenannten Wartbaren. Wenn Sie an ihrer internen Struktur interessiert sind, machen Sie sich keine Sorgen, wir werden unsere Awaitables bald schreiben.
Die nicht trivialste Stelle im obigen Code ist final_suspend (). Die Funktion hat unerwartete Auswirkungen. Wenn wir
die Ausführung in dieser Funktion nicht stoppen, bereinigen die vom Compiler der Coroutine zugewiesenen Ressourcen den Compiler für uns. Wenn wir in dieser Funktion die Ausführung von Coroutine stoppen (z. B. durch Rückgabe von std :: experimental :: suspend_always {}), müssen Sie Ressourcen manuell von außerhalb freigeben: Sie müssen einen intelligenten Zeiger auf Coroutine irgendwo speichern und explizit aufrufen zerstören (). Glücklicherweise ist dies für unser Beispiel nicht erforderlich.
INCORRECT PromiseType ::ield_value
Es scheint, dass das Schreiben von PromiseType ::ield_value recht einfach ist. Wir haben eine Linie; Coroutine, die ausgesetzt und in diesem Zug gesetzt werden muss:
auto PromiseType::yield_value(WorkQueue& wq) {
Und hier stehen wir vor einem sehr großen und schwer zu erkennenden Problem. Tatsache ist, dass wir die Coroutine zuerst in die Warteschlange stellen und erst dann aussetzen. Es kann vorkommen, dass Coroutine aus der Warteschlange entfernt wird und bereits ausgeführt wird, bevor wir sie im aktuellen Thread anhalten. Dies führt zu einer Rennbedingung, undefiniertem Verhalten und völlig verrückten Laufzeitfehlern.
Korrigieren Sie PromiseType ::ield_value
Wir müssen also zuerst Corutin stoppen und erst dann zur Warteschlange hinzufügen. Dazu schreiben wir unser Awaitable und nennen es Schedule_for_execution:
auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { WorkQueue& wq; constexpr bool await_ready() const noexcept { return false; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; return schedule_for_execution{wq}; }
Die Klassen std :: experimental :: suspend_always, std :: experimental :: suspend_never, Schedule_for_execution und andere Awaitables sollten 3 Funktionen enthalten. await_ready wird aufgerufen, um zu überprüfen, ob die Coroutine gestoppt werden soll. await_suspend wird aufgerufen, nachdem das Programm gestoppt wurde, und das Handle der gestoppten Coroutine wird an das Programm übergeben. await_resume wird aufgerufen, wenn die Coroutine-Ausführung fortgesetzt wird.
Und was kann in dreieckigen skrabs std :: experimental :: coroutine_handle <> geschrieben werden?Sie können dort den PromiseType-Typ angeben, und das Beispiel funktioniert genauso :)
std :: experimental :: coroutine_handle <> (auch bekannt als std :: experimental :: coroutine_handle <void>) ist der Basistyp für alle std :: experimentellen :: coroutine_handle <DataType>, wobei der Datentyp der Versprechenstyp der aktuellen Coroutine sein muss. Wenn Sie nicht auf den internen Inhalt von DataType zugreifen müssen, können Sie std :: experimentelle :: coroutine_handle <> schreiben. Dies kann an Stellen nützlich sein, an denen Sie von einem bestimmten Typ von versprechen_Typ abstrahieren und den Typ Löschen verwenden möchten.
Fertig
Sie können
das Beispiel kompilieren, online ausführen und auf jede Weise experimentieren .
Und wenn ich co_yield nicht mag, kann ich es durch etwas ersetzen?Kann durch co_await ersetzt werden. Fügen Sie dazu PromiseType die folgende Funktion hinzu:
auto await_transform(WorkQueue& wq) { return yield_value(wq); }
Aber was ist, wenn ich co_await nicht mag?Die Sache ist schlecht. Nichts zu ändern.
Spickzettel
CoroTask ist eine Klasse, die das Verhalten einer Coroutine anpasst. In komplexeren Fällen können Sie mit einer gestoppten Coroutine kommunizieren und Daten daraus entnehmen.
CoroTask :: Versprechen_Typ beschreibt, wie und wann Coroutinen gestoppt werden, wie Ressourcen freigegeben werden und wie CoroTask erstellt wird.
Awaitables (std :: experimental :: suspend_always, std :: experimental :: suspend_never, Schedule_for_execution und andere) teilen dem Compiler mit, was mit Coroutine an einem bestimmten Punkt zu tun ist (ob Corutin gestoppt werden soll, was mit gestopptem Corutin zu tun ist und was zu tun ist, wenn Corutin aufwacht). .
Optimierungen
Unser PromiseType weist einen Fehler auf. Selbst wenn wir derzeit in der richtigen Task-Warteschlange ausgeführt werden, wird durch den Aufruf von co_yield die Coroutine angehalten und in dieselbe Task-Warteschlange verschoben. Es wäre viel optimaler, die Ausführung der Coroutine nicht zu stoppen, sondern die Ausführung sofort fortzusetzen.
Lassen Sie uns diesen Fehler beheben. Fügen Sie dazu PromiseType ein privates Feld hinzu:
WorkQueue* current_queue_ = nullptr;
Darin halten wir einen Zeiger auf die Warteschlange, in der wir gerade ausführen.
Als nächstes optimieren Sie PromiseType ::ield_value:
auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { const bool do_resume; WorkQueue& wq; constexpr bool await_ready() const noexcept { return do_resume; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; const bool do_not_suspend = (current_queue_ == &wq); current_queue_ = &wq; return schedule_for_execution{do_not_suspend, wq}; }
Hier haben wir Schedule_for_execution :: await_ready () optimiert. Diese Funktion teilt dem Compiler nun mit, dass die Coroutine nicht angehalten werden muss, wenn die aktuelle Task-Warteschlange mit der Warteschlange übereinstimmt, auf der wir starten möchten.
Fertig. Sie können
auf jede Weise experimentieren .
Über die Leistung
Im ursprünglichen Beispiel haben wir bei jedem Aufruf von WorkQueue :: PushTask (std :: function <void ()> f) eine Instanz der Klasse std :: function <void ()> aus dem Lambda erstellt. Im realen Code sind diese Lambdas oft recht groß, weshalb std :: function <void ()> gezwungen ist, dynamisch Speicher zum Speichern von Lambdas zuzuweisen.
Im Coroutine-Beispiel erstellen wir Instanzen von std :: function <void ()> aus std :: experiment :: coroutine_handle <>. Die Größe von std :: experiment :: coroutine_handle <> hängt von der Implementierung ab, aber die meisten Implementierungen versuchen, die Größe auf ein Minimum zu beschränken. Bei Clang ist seine Größe also gleich sizeof (void *). Beim Erstellen von std :: function <void ()> erfolgt keine dynamische Zuordnung von kleinen Objekten.
Insgesamt - mit Coroutines haben wir einige unnötige dynamische Zuordnungen beseitigt.
Aber! Der Compiler kann oft nicht einfach die gesamte Coroutine auf dem Stapel speichern. Aus diesem Grund ist bei der Eingabe von CoroToDealWith eine zusätzliche dynamische Zuordnung möglich.
Stapellos gegen stapelbar
Wir haben gerade mit stapellosen Coroutinen gearbeitet, für deren Arbeit der Compiler Unterstützung benötigt. Es gibt auch stapelbare Coroutinen, die vollständig auf Bibliotheksebene implementiert werden können.
Die ersten ermöglichen eine wirtschaftlichere Speicherzuweisung, möglicherweise werden sie vom Compiler besser optimiert. Die zweiten sind einfacher in vorhandene Projekte zu implementieren, da sie weniger Codeänderungen erfordern. In diesem Beispiel können Sie den Unterschied jedoch nicht spüren. Es werden kompliziertere Beispiele benötigt.
Zusammenfassung
Wir haben das grundlegende Beispiel untersucht und eine universelle Klasse CoroTask erhalten, mit der andere Coroutinen erstellt werden können.
Der Code damit wird lesbarer und etwas produktiver als mit dem naiven Ansatz:
War | Mit Coroutinen |
---|
void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto fin = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(fin); }); } else { writerQueue.PushTask(fin); } }); } else { fin(); } }); } | CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } |
Über Bord gab es Momente:
- wie man eine andere Coroutine von Corutin aufruft und auf ihre Fertigstellung wartet
- Was für nützliche Dinge können Sie in CoroTask stopfen
- Ein Beispiel, das den Unterschied zwischen Stackless und Stackful ausmacht
Andere
Wenn Sie mehr über andere Neuheiten der C ++ - Sprache erfahren oder persönlich mit Ihren Kollegen über die Vorteile kommunizieren möchten, schauen Sie sich die C ++ Russia-Konferenz an. Der nächste findet
am 6. Oktober in Nischni Nowgorod statt .
Wenn Sie Probleme mit C ++ haben und etwas in der Sprache verbessern möchten oder nur mögliche Innovationen diskutieren möchten, besuchen Sie bitte
https://stdcpp.ru/ .
Wenn es Sie überrascht, dass Yandex.Taxi eine Vielzahl von Aufgaben hat, die nicht mit Grafiken zusammenhängen, dann hoffe ich, dass dies eine angenehme Überraschung für Sie war :)
Besuchen Sie uns am 11. Oktober , wir werden über C ++ und mehr sprechen.