Asynkron filläsning med C++20 coroutines
När du för första gången möter C++20-coroutines är det lätt att känna att du hamnat i en märklig mellanvärld: kraftfulla
idéer, men ett API som lämnar ovanligt mycket ansvar till dig. I den här artikeln bygger vi därför ett litet men
komplett exempel på asynkron filinläsning – steg för steg – och jämför med ett motsvarande Node.js-program. På vägen
reder vi ut vad som faktiskt händer i co_await, hur promise_type och awaitable samverkar, och varför din coroutine
plötsligt fortsätter på ”fel” tråd om du inte är uppmärksam.
I två tidigare artiklar har jag beskrivit coroutines i C++20/23. Den första beskriver hur man använder std::generator<T>
som kom i C++23, och den andra en detaljerad genomgång av C++20 coroutines och det (udda) API:t.
Jämför man med coroutines i andra programspråk, såsom JavaScript, så saknas kolossalt många stödfunktioner
i C++ vilket gör det svårt att skriva effektiva och lättförståliga asynkrona program. Här är ett litet program
som läser in en hel fil med hjälp av en async function i Node.js, dvs server-side JavaScript.
import {readFile} from 'node:fs/promises'
import process from 'node:process'
async function readfile_async(filename) {
const data = await readFile(filename, 'utf8');
return data;
}
let filename = 'shakespeare.txt';
if (process.argv.length > 2) {
filename = process.argv[2];
}
(async () => {
try {
const content = await readfile_async(filename);
const size = Buffer.byteLength(content, 'utf8');
console.log('read %d chars from %s', size, filename);
} catch (e) {
console.error(`Error reading file ${filename}: ${e.message}`);
}
})();
JS> node readfile-app.js ./data/shakespeare.txt
read 5590193 chars from ./data/shakespeare.txt
JS>
Tekniskt sett, så returnerar readFile ett promise-objekt, som representerar ett framtida resultat. I väntan på detta
eller att något blev fel, suspenderas funktionen readfile_async tills operationen är klar.
Huvudprogrammet består av en asynkron kontext för att anropa den asynkrona funktionen, vilket vi gör i form
av en asynkron IIFE (Immediately Invoked Function Expression — a.k.a., immediately lambda-expression invocation).
Man brukar normalt inte använda terminologin för coroutines i JavaScript, men det är precis vad det rent tekniskt handlar
om, när man deklarerar en funktion (eller lambda) som async.
Strängt taget behöver man inte använda en async IIFE (eller async function), om man exekverar med en hyfsat modern Node.js version, utan syntaxen tillåter s.k. top-level await. Dock ville jag ha en dylik här, eftersom vi kommer att göra något liknande i den asynkrona C++ versionen.
Som det ser ut just nu i C++ är det tveksamt — tycker jag personligen — vilken nytta man kan ha av coroutines, såvida man inte är beredd att lägga tid och tankekraft på att implementera det runtime-system som andra programspråk tillhandahåller från första början. Visst kan det vara underhållande och lärorikt att titta på programkod som demonstrerar asynkrona loopar och Fibonacci listor, men inget av detta används i affärskritiska system.
I denna artikel och efterföljande artiklar avser jag att beskriva några olika användningsfall (use cases) för hur man kan ha nytta av coroutines, samt hur pass mycket kod man tvingas skriva själv.
Först ut, visar jag hur man kan realisera JavaScript programmet ovan i C++. Först en konventionell synkron version, som blir vår utgångspunkt, och sen en version med coroutines där den största kodmängden utgörs av det runtime-system vi saknar. Här vill jag understryka att denna version är helt hårdkodad för just detta exempel och generaliseringar gör jag i kommande artiklar.
Funktionen readfile()
Denna funktion tar ett filnamn, läser innehållet och returnerar det som en sträng. Om filen inte hittas eller om det uppstår fel under läsningen, kastas en exception. Håll i minnet att det är denna funktion vi använder i både det inledande synkrona programmet och den asynkrona versionen som kommer senare i artikeln.
//readfile.hxx
#pragma once
#include <filesystem>
#include <string>
namespace ribomation::io {
namespace fs = std::filesystem;
auto readfile(fs::path const& filename) -> std::string;
}
//readfile.cxx
#include <filesystem>
#include <fstream>
#include <string>
#include <stdexcept>
#include "readfile.hxx"
namespace ribomation::io {
namespace fs = std::filesystem;
auto readfile(fs::path const& filename) -> std::string {
auto file = std::ifstream{filename, std::ios::binary};
if (not file)
throw std::invalid_argument{"cannot open " + filename.string()};
file.seekg(0, std::ios::end);
auto const filesize = file.tellg();
if (filesize < 0) return {};
file.seekg(0, std::ios::beg);
auto buffer = std::string{};
buffer.resize( static_cast<std::size_t>(filesize) );
file.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));
if (not file && not file.eof())
throw std::runtime_error{"failed reading " + filename.string()};
return buffer;
}
}
Funktionen öppnar en indatafil med det angivna filnamnet för binär läsning. Om filen inte kan öppnas, kastas ett std::invalid_argument-fel.
Filstorleken fås genom att navigera till slutet, läsa av den aktuella positionen och sen navigera tillbaka till början.
Om filstorleken är negativ, returneras en tom sträng. Annars läses filen in i en strängbuffert och returneras. Om läsningen misslyckas,
kastas ett std::runtime_error-fel.
Synkront program
I följande korta synkrona program använder vi funktionen readfile() för att läsa innehållet från en fil och skriva ut dess storlek.
//readfile-app.cxx
auto filename = fs::path{"./shakespeare.txt"};
if (argc == 2) filename = argv[1];
try {
auto content = ribomation::io::readfile(filename);
std::println("read {} chars from {}", content.size(), filename.string());
} catch (std::exception const& err) {
std::println("failed: {}", err.what());
}
C++> g++ -std=c++23 -Wall -I ../readfile ../readfile-app.cxx -L ../build -lreadfile -o readfile-sync-app
C++> ./readfile-sync-app ../data/shakespeare.txt
read 5590193 chars from ../data/shakespeare.txt
C++> ./readfile-sync-app no-such-file.txt
failed: cannot open no-such-file.txt
C++>
Program readfile-async-app.cxx
Jag tänkte visa koden steg-för-steg från programmet bakåt tills vi når runtime-systemet för detta exempel. Givetvis avser jag att förklara de olika stegen och hur de fungerar. Så, här kommer då först applikationskoden och ett körexempel.
//readfile-async-app.cxx
#include <filesystem>
#include <print>
#include <exception>
#include "task-coro.hxx"
#include "async-function.hxx"
#include "readfile-async.hxx"
namespace fs = std::filesystem;
namespace io = ribomation::io;
auto LoadFileCoro(fs::path filename) -> io::TaskCoroutine<void> {
const auto text = co_await io::readfile_async(filename);
std::println("loaded {} chars from {}", text.size(), filename.string());
co_return;
}
int main(int argc, char* argv[]) {
auto filename = fs::path{"./shakespeare.txt"};
if (argc == 2) filename = argv[1];
try {
io::async_function( LoadFileCoro(filename) );
} catch (std::exception const& error) {
std::println("failed: {}", error.what());
return 1;
}
}
Först definierar jag en coroutine som heter LoadFileCoro. Den tar ett filnamn som argument och returnerar en
TaskCoroutine<void> som coroutine-interface. Denna coroutine läser in innehållet i filen asynkront med hjälp av io::readfile_async och skriver
ut antalet tecken som lästes in. Denna funktion suspenderar coroutine objektet via co_await.
I main skapar vi denna coroutine och skickar den som argument till funktionen io::async_function, vilken fungerar som en exekveringskontext
för coroutine objektet. Båda dessa funktioner kommer jag beskriva härnäst och sen arbeta mig bakåt till mer generell kod.
Kompilerar och kör man programmet, kan det se ut så här:
C++> g++ -std=c++23 -Wall -I ../readfile -I ../lib-async -L../build ../readfile-async-app.cxx \
-lasync -lreadfile -o readfile-async-app
C++> ./readfile-async-app ../data/shakespeare.txt
loaded 5590193 chars from ../data/shakespeare.txt
C++> ./readfile-async-app no-such-file.txt
failed: cannot open no-such-file.txt
C++>
Funktion async_function
Varje funktion eller klass — i denna artikel — definierad som en template, har också en specialisering där värdetypen är void. Detta
eftersom en coroutine promise har antingen en funktion return_value eller en return_void. Detta gäller också för funktionen async_function,
som vi börjar med.
//async-function.hxx
#pragma once
#include <optional>
#include <exception>
#include <semaphore>
#include "task-coro.hxx"
namespace ribomation::io {
template<typename ValueType>
auto async_function(TaskCoroutine<ValueType> task) -> ValueType ...
inline void async_function(TaskCoroutine<void> task) ...
}
Grundversion, som returnerar ett värde
Funktionen async_function behövs för att skapa en exekveringskontext för vår coroutine, på samma sätt som
JavaScript-exemplets async IIFE. När man anropar en coroutine-funktion får man tillbaka ett coroutine-objekt som är
suspenderat från start. Detta objekt kommer inte att köras förrän någon explicit startar det genom att anropa resume()
på dess handle. Funktionen async_function tar hand om hela denna livscykel: den tar emot coroutine-objektet, startar
körningen, väntar tills den är klar (eller kastar ett undantag om något går fel), och städar sedan upp genom att
förstöra coroutine-objektet. På så sätt kapslar den in all komplexitet kring hanteringen av coroutines och ger oss ett
enkelt gränssnitt att arbeta mot, precis som async/await fungerar i andra språk.
template<typename ValueType>
auto async_function(TaskCoroutine<ValueType> task) -> ValueType {
auto result = std::optional<ValueType>{};
auto error = std::exception_ptr{};
auto completed = std::binary_semaphore{0};
auto async_context = [&task, &result, &error, &completed]() -> TaskCoroutine<void> {
try {
result = co_await task;
} catch (...) {
error = std::current_exception();
}
//signal we are done, so the owning function can proceed and return
completed.release();
co_return;
};
auto coro = async_context();
//launch the coroutine
coro.start();
//wait here, until release() is invoked
completed.acquire();
if (error) std::rethrow_exception(error);
return std::move( result.value() );
}
Specialiserad version, som inte returnerar ett värde
För att hantera coroutines som inte returnerar något värde, har vi en specialiserad version av async_function som
tar emot en coroutine-funktion som returnerar void. Den fungerar på samma sätt som grundversionen, men utan att
behöva hantera returnerade värden. Den tar emot coroutine-objektet, startar körningen, väntar tills den är klar (eller
kastar ett undantag om något går fel), och städar sedan upp genom att förstöra coroutine-objektet.
inline void async_function(TaskCoroutine<void> task) {
auto error = std::exception_ptr{};
auto completed = std::binary_semaphore{0};
auto async_context = [&task, &error, &completed]() -> TaskCoroutine<void> {
try {
co_await task;
} catch (...) {
error = std::current_exception();
}
completed.release();
co_return;
};
auto coro = async_context();
coro.start();
completed.acquire();
if (error) std::rethrow_exception(error);
}
Funktion readfile_async
Delsystemet readfile består av funktionen readfile_async, som skapar och returnerar ett objekt av klassen
ReadfileAwaitable, vilken i sin tur är ansvarig för att anropa vår ursprungliga readfile funktion på en
separat worker thread.
//readfile-async.hxx
#pragma once
#include <optional>
#include <string>
#include <filesystem>
#include <memory>
#include <coroutine>
namespace ribomation::io {
namespace fs=std::filesystem;
class ReadfileAwaitable {...};
inline auto readfile_async(fs::path filename) -> ReadfileAwaitable...
Så här ser själva funktionen ut. Den tar emot ett filnamn, skapar och returnerar ett objekt av klassen ReadfileAwaitable,
vilken tar emot filnamnet ifråga.
inline auto readfile_async(fs::path filename) -> ReadfileAwaitable {
return ReadfileAwaitable{ std::move(filename) };
}
Klass ReadfileAwaitable
Denna är ansvarig för att anropa den synkrona versionen av readfile, vilket görs på en separat fire&forget worker thread.
Awaitable vs. Awaiter
Innan vi tittar på detaljerna i klassen är det viktigt att skilja på två centrala begrepp i C++ coroutines: awaitable och awaiter.
En awaitable är ett objekt som kan användas tillsammans med operatorn co_await. För att vara "awaitable" måste objektet
antingen ha en medlemsfunktion operator co_await eller så måste det finnas en fri funktion med samma namn som returnerar
en awaiter.
En awaiter är det objekt som faktiskt styr suspensionen. Det måste implementera tre specifika metoder: await_ready(),
await_suspend() och await_resume(). I vårt fall implementerar klassen ReadfileAwaitable dessa tre metoder direkt. Det innebär
att klassen fungerar som både en awaitable (den kan "awaitas") och en awaiter (den sköter logiken).
Varför namnet ReadfileAwaitable?
Valet av namn handlar om semantik och läsbarhet för användaren av API:et. När vi anropar readfile_async(filename) får vi tillbaka
något som vi förväntar oss att kunna vänta på med co_await. Från anroparens perspektiv är objektet en "väntbar" entitet (awaitable).
Att den under huven även råkar implementera awaiter-gränssnittet är en teknisk detalj för att förenkla implementationen och slippa
skapa två separata klasser. Namnet betonar alltså objektets roll i det asynkrona flödet snarare än dess tekniska implementation.
Klass-struktur
class ReadfileAwaitable {
struct CompletionState {
std::optional<std::string> content{};
std::exception_ptr error{};
};
fs::path filename{};
std::shared_ptr<CompletionState> state = std::make_shared<CompletionState>();
public:
explicit ReadfileAwaitable(fs::path filename_) : filename(std::move(filename_)) {}
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> invoking_task);
auto await_resume() -> std::string;
};
Den inre klassen CompletionState representerar utfallet, när målfunktionen är klar; antingen gick det bra och content
har ett innehåll eller så blev något fel och error innehåller information om detta. Detta har viss likhet med resultatet
av ett Promise objekt i JavaScript. Metoden await_ready() returnerar false, så att systemet alltid suspenderar anroparen,
vilket säkerställer att worker-thread kickar igång. Notera också att anroparen fortsätter sedan exekvera på worker-thread, d.v.s.,
anropet invoking_task.resume() sker i worker_body lambdan.
Implementation av medlemsfunktioner
//readfile-async.cxx
#include <thread>
#include "readfile.hxx"
#include "readfile-async.hxx"
namespace ribomation::io {
void ReadfileAwaitable::await_suspend(std::coroutine_handle<> invoking_task) {
auto state_ptr = state;
auto filename_cpy = std::move(filename);
auto worker_body = [state_ptr, invoking_task, filename = std::move(filename_cpy)]() mutable {
try {
//invoke the sync func on the worker thread
state_ptr->content = readfile(filename);
} catch (...) {
//save error, if any
state_ptr->error = std::current_exception();
}
//resume the invoking task, and run on this worker thread
invoking_task.resume();
};
auto worker = std::thread{worker_body};
//run it async, don't bother to join it
worker.detach();
}
auto ReadfileAwaitable::await_resume() -> std::string {
if (state->error) std::rethrow_exception(state->error);
return std::move(state->content.value());
}
}
Funktionen await_suspend skapar en worker thread och i denna anropar vår ursprungliga synkrona readfile funktion.
I worker_body lambdan syns det tydligt att beroende på utfall så kommer content eller error populeras. Anropet worker.detach()
innebär att tråden lämnas "fire & forget", så att funktionen await_suspend kan returnera.
I funktionen await_resume överförs resultatet, som finns lagrat i variabeln state. Gick det bra så flyttas innehållet
i content vidare eller så kastas en exception med innehållet i error.
TaskCoroutine
Sista delen följer nu och utgör kärnan i vårt runtime-system, vilket är helt frikopplat från readfile funktionaliteten. Jag börjar med en översikt av innehållet i header-filen.
//task-coro.hxx
#pragma once
#include <optional>
#include <coroutine>
#include <exception>
#include <utility>
namespace ribomation::io {
//forward decl
template<typename ValueType> struct TaskCoroutine;
namespace impl {
template<typename ValueType>
struct TaskPromise {...};
template<>
struct TaskPromise<void> {...};
}
template<typename ValueType>
struct TaskCoroutine {...};
template<>
struct TaskCoroutine<void> {...};
}
namespace ribomation::io::impl {
template<typename ValueType>
auto TaskPromise<ValueType>::get_return_object() -> TaskCoroutine<ValueType> {...}
inline auto TaskPromise<void>::get_return_object() -> TaskCoroutine<void> {...}
}
Eftersom vi har ett cirkulärt beroende mellan interface-klassen och promise-klassen, så tvingas man till denna (klumpiga) struktur, där vi börjar med en forward-deklaration av coroutine interface-klassen, följt av delar av promise-klassen, och sedan själva interface klassen, för att till slut implementera resterande delar av promise-klassen — inte snyggt om du frågar mig.
Klass TaskCoroutine<T>
TaskCoroutine<T> fungerar som det externa gränssnittet (interface-klassen) för en asynkron uppgift. Dess främsta syfte
är att agera som en brygga mellan anroparen och coroutinens interna tillstånd. Den är designad som en "RAII-wrapper"
kring en std::coroutine_handle, vilket innebär att den tar ansvar för att städa upp (förstöra) coroutinen när objektet
går ur scope.
Huvudsakliga egenskaper:
- Ägarskap: Den är inte kopierbar (deleted copy constructor/assignment), men flyttbar (moveable). Detta säkerställer att endast en instans i taget äger coroutinen.
- Livscykel: I destruktorn förstörs coroutinen via
handle.destroy(), vilket frigör minnet för promise-objektet och dess lokala variabler. - Manuell start: Genom metoden
start()kan anroparen explicit kicka igång exekveringen. Eftersominitial_suspendi promise-klassen returnerarstd::suspend_always, startar coroutinen i ett suspenderat tillstånd och väntar på denna signal.
Inre klass LaunchAwaiter
Detta är den mest centrala delen för att möjliggöra sammansättning av coroutines (dvs. att kunna skriva co_await some_task). LaunchAwaiter implementerar det protokoll som C++ kräver för att ett objekt ska vara "awaitable".
När du skriver co_await task i en annan coroutine, händer följande via LaunchAwaiter:
await_ready(): Kontrollerar om uppgiften redan är klar. Om den är det behöver vi inte suspendera den anropande coroutinen.await_suspend(std::coroutine_handle<> other_task): Detta är den "magiska" länken. Här sparas den anropande coroutinen (other_task) som en s.k. continuation inuti den aktuella uppgiftens promise-objekt. Därefter anropasthis_task.resume(), vilket startar exekveringen av den uppgift vi väntar på.await_resume(): Denna metod anropas när den väntade uppgiften är klar och kontrollen återgår till den anropande coroutinen. Den har två viktiga uppgifter:- Felhantering: Om ett undantag kastades inuti coroutinen (och fångades upp av
unhandled_exceptioni promise-objektet), kommerawait_resumeatt kasta om det här viastd::rethrow_exception. - Resultatleverans: Den hämtar det lagrade värdet från promise-objektet och returnerar det (via
std::move) till anroparen.
- Felhantering: Om ett undantag kastades inuti coroutinen (och fångades upp av
Sammanfattning av flödet
TaskCoroutine gör det möjligt att bygga kedjor av asynkrona anrop. Genom operator co_await() returnerar den en LaunchAwaiter som ser till att:
- Den anropande coroutinen pausas.
- Den nya coroutinen startas.
- När den nya är klar, återupptas den första med resultatet (eller ett fel) till hands.
Detta efterliknar hur Promise och await fungerar i JavaScript, men i en strikt typad C++-miljö.
template<typename ValueType>
struct TaskCoroutine {
using promise_type = impl::TaskPromise<ValueType>;
using handle_type = std::coroutine_handle<promise_type>;
private:
handle_type handle{};
public:
explicit TaskCoroutine(handle_type h) : handle(h) {}
~TaskCoroutine() { if (handle) handle.destroy(); }
TaskCoroutine(TaskCoroutine const&) = delete;
TaskCoroutine(TaskCoroutine && rhs) noexcept :handle(std::exchange(rhs.handle, {})) {}
auto operator =(TaskCoroutine const&) -> TaskCoroutine& = delete;
auto operator =(TaskCoroutine && rhs) noexcept -> TaskCoroutine& {
if (this != &rhs) {
if (handle) handle.destroy();
handle = std::exchange(rhs.handle, {});
}
return *this;
}
void start() {
if (handle && not handle.done()) handle.resume();
}
class LaunchAwaiter {
handle_type this_task;
public:
explicit LaunchAwaiter(handle_type h) : this_task(h) {}
bool await_ready() noexcept { return not this_task || this_task.done(); }
void await_suspend(std::coroutine_handle<> other_task) noexcept {
this_task.promise().continuation = other_task;
this_task.resume();
}
auto await_resume() -> ValueType {
if (auto& p = this_task.promise(); p.error)
std::rethrow_exception(p.error);
return std::move( this_task.promise().value.value() );
}
};
auto operator co_await() const noexcept -> LaunchAwaiter {
return LaunchAwaiter{handle};
}
};
Klass TaskCoroutine<void>
Detta är en specialisering för fallet då inget värde returneras från en färdigkörd coroutine.
template<>
struct TaskCoroutine<void> {
using promise_type = impl::TaskPromise<void>;
using handle_type = std::coroutine_handle<promise_type>;
private:
handle_type handle{};
public:
explicit TaskCoroutine(handle_type h) : handle(h) {}
~TaskCoroutine() { if (handle) handle.destroy(); }
TaskCoroutine(TaskCoroutine const&) = delete;
TaskCoroutine(TaskCoroutine && rhs) noexcept :handle(std::exchange(rhs.handle, {})) {}
auto operator =(TaskCoroutine const&) -> TaskCoroutine& = delete;
auto operator =(TaskCoroutine && rhs) noexcept -> TaskCoroutine& {
if (this != &rhs) {
if (handle) handle.destroy();
handle = std::exchange(rhs.handle, {});
}
return *this;
}
void start() {
if (handle && not handle.done()) handle.resume();
}
class LaunchAwaiter {
handle_type this_task;
public:
explicit LaunchAwaiter(handle_type h) : this_task(h) {}
bool await_ready() noexcept { return not this_task || this_task.done(); }
void await_suspend(std::coroutine_handle<> other_task) noexcept {
this_task.promise().continuation = other_task;
this_task.resume();
}
void await_resume() {
if (auto& p = this_task.promise(); p.error)
std::rethrow_exception(p.error);
}
};
auto operator co_await() const noexcept -> LaunchAwaiter {
return LaunchAwaiter{handle};
}
};
Klass TaskPromise<T>
Om TaskCoroutine är det yttre handtaget, så är TaskPromise coroutinens interna tillståndslager och kontrollcenter. I
C++ coroutine-ramverket är det promise-objektet som definierar hur coroutinen beter sig vid start, avslut och när den
returnerar värden.
Huvudsakliga ansvarsområden:
- Resultatlagring: Den använder en
std::optional<ValueType>för att lagra det slutgiltiga värdet som produceras av coroutinen. - Felhantering: Genom
unhandled_exception()fångar den upp alla undantag som kastas inuti coroutinen. Istället för att programmet kraschar, lagras felet i enstd::exception_ptrså att det kan kastas om senare när resultatet efterfrågas viaco_await. - Livscykelkontroll:
initial_suspend(): Returnerarstd::suspend_always, vilket gör att coroutinen skapas i ett pausat läge. Den börjar inte köra förrän någon explicit anroparstart()ellerco_await.get_return_object(): Skapar och returnerar detTaskCoroutine-objekt som anroparen får i handen.
Inre klass CompletionAwaiter
CompletionAwaiter används i final_suspend() och är den mekanism som sköter kedjekopplingen (continuation) när en
coroutine har kört klart. Dess roll är kritisk för att "nästlade" coroutines ska fungera.
Så fungerar den:
await_ready(): Returnerarfalse, vilket tvingar coroutinen att suspendera en sista gång innan den förstörs. Detta är viktigt för att promise-objektet (och dess data) ska finnas kvar tills anroparen har hunnit hämta resultatet.await_suspend(std::coroutine_handle<TaskPromise> h): Detta är hjärtat i asynkron kedjekoppling. När coroutinen når sitt slut, kontrollerar den om det finns en sparadcontinuation(en anropande coroutine som väntar på svar). Om det finns en sådan, anropascont.resume().- Återgång: Detta gör att kontrollen hoppar tillbaka till den coroutine som gjorde
co_await, precis efter att den asynkrona operationen slutförts.
Sammanfattning av flödet
När en coroutine exekveras, interagerar den med TaskPromise på följande sätt:
- Start: Coroutinen pausas direkt (
initial_suspend). - Exekvering: När den körs och når en
co_return value;, anropasreturn_value(), som sparar resultatet i promise-objektet. - Avslut: Vid slutet anropas
final_suspend(), därCompletionAwaiterser till att väcka den som väntade på resultatet, så att flödet kan fortsätta asynkront.
Utan TaskPromise och dess förmåga att spara undantag och hantera continuations, skulle vi inte kunna bygga de
sekventiella asynkrona flöden som vi är vana vid från andra moderna språk.
template<typename ValueType>
struct TaskPromise {
std::optional<ValueType> value{};
std::exception_ptr error{};
std::coroutine_handle<> continuation{};
struct CompletionAwaiter {
bool await_ready() noexcept { return false; }
void await_resume() noexcept {}
void await_suspend(std::coroutine_handle<TaskPromise> h) noexcept {
if (auto cont = h.promise().continuation; cont) cont.resume();
}
};
auto get_return_object() -> TaskCoroutine<ValueType>;
auto initial_suspend() noexcept { return std::suspend_always{}; }
auto final_suspend() noexcept { return CompletionAwaiter{}; }
template<typename ReturnType>
void return_value(ReturnType&& v) {
value.emplace( std::forward<ReturnType>(v) );
}
void unhandled_exception() noexcept { error = std::current_exception(); }
};
//...
template<typename ValueType>
auto TaskPromise<ValueType>::get_return_object() -> TaskCoroutine<ValueType> {
auto h = std::coroutine_handle<TaskPromise>::from_promise(*this);
return TaskCoroutine<ValueType>{h};
}
Klass TaskPromise<void>
Detta är specialiseringen av promise-klassen, för det fall att task coroutine inte returnerar något.
template<>
struct TaskPromise<void> {
std::exception_ptr error{};
std::coroutine_handle<> continuation{};
struct CompletionAwaiter {
bool await_ready() noexcept { return false; }
void await_resume() noexcept {}
void await_suspend(std::coroutine_handle<TaskPromise> h) noexcept {
if (auto cont = h.promise().continuation; cont) cont.resume();
}
};
auto get_return_object() -> TaskCoroutine<void>;
auto initial_suspend() noexcept { return std::suspend_always{}; }
auto final_suspend() noexcept { return CompletionAwaiter{}; }
void return_void() noexcept {}
void unhandled_exception() noexcept { error = std::current_exception(); }
};
//...
inline auto TaskPromise<void>::get_return_object() -> TaskCoroutine<void> {
auto h = std::coroutine_handle<TaskPromise>::from_promise(*this);
return TaskCoroutine<void>{h};
}
Sammanfattning
Oj, oj som utlovat inledningsvis; en massa ramverkskod bara för att anropa en konventionell/synkron funktion asynkront från en coroutine. Som jag också påpekat tidigare, koden du sett här ovan är också hårdkodad för att just anropa vår readfile funktion, samt att exekveringskontrollen fortsätter på en annan thread, i stället för att återvända till den thread som anropade. Vill man ha den funktionaliteten så behövs en scheduler, vilket jag ämnar visa hur i en annan artikel.
Att implementera ett eget runtime-system för coroutines i C++ kan vid en första anblick verka övermäktigt, men som vi
har sett i analysen av TaskCoroutine<T> och TaskPromise<T> handlar det i grunden om att etablera ett tydligt
protokoll för hur data och kontrollflöde ska vandra mellan olika asynkrona punkter.
Genom att använda TaskPromise som coroutinens interna tillståndslager får vi en robust hantering av både resultat och eventuella undantag. Med den smarta kopplingen i LaunchAwaiter och CompletionAwaiter kan vi skapa kedjor av asynkrona anrop som känns lika naturliga som i JavaScript, trots att C++ kräver att vi själva bygger de kugghjul som får maskineriet att snurra.
Även om C++ coroutines innebär en hel del "boilerplate"-kod, ger det oss en enorm flexibilitet och prestanda. Vi har nu ett ramverk där vi kan skriva kod som ser synkron och lättläst ut, men som under huven utnyttjar trådar och asynkrona resurser på ett effektivt sätt. I kommande artiklar ska vi titta närmare på hur vi kan generalisera detta ytterligare för att bygga ännu mer kraftfulla asynkrona system.