Sökresultat för

Asynkron filläsning med C++20 coroutines

48 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
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. Eftersom initial_suspend i promise-klassen returnerar std::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:

  1. await_ready(): Kontrollerar om uppgiften redan är klar. Om den är det behöver vi inte suspendera den anropande coroutinen.
  2. 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 anropas this_task.resume(), vilket startar exekveringen av den uppgift vi väntar på.
  3. 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_exception i promise-objektet), kommer await_resume att kasta om det här via std::rethrow_exception.
    • Resultatleverans: Den hämtar det lagrade värdet från promise-objektet och returnerar det (via std::move) till anroparen.

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:

  1. Den anropande coroutinen pausas.
  2. Den nya coroutinen startas.
  3. 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 en std::exception_ptr så att det kan kastas om senare när resultatet efterfrågas via co_await.
  • Livscykelkontroll:
    • initial_suspend(): Returnerar std::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 anropar start() eller co_await.
    • get_return_object(): Skapar och returnerar det TaskCoroutine-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:

  1. await_ready(): Returnerar false, 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.
  2. 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 sparad continuation (en anropande coroutine som väntar på svar). Om det finns en sådan, anropas cont.resume().
  3. Å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:

  1. Start: Coroutinen pausas direkt (initial_suspend).
  2. Exekvering: När den körs och når en co_return value;, anropas return_value(), som sparar resultatet i promise-objektet.
  3. Avslut: Vid slutet anropas final_suspend(), där CompletionAwaiter ser 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.