Linéarisation de code asynchrone avec de la corutine

image

En plus d'utiliser coroutine pour créer des générateurs, vous pouvez essayer de les utiliser pour linéariser le code asynchrone existant. Essayons de le faire avec un petit exemple. Prenez le code écrit sur le framework d'acteur et réécrivez une fonction de ce code sur les coroutines. Pour construire le projet, nous utiliserons gcc de la branche coroutines .

Notre objectif est d'obtenir des rappels de nouilles:

    abActor.getA(ABActor::GetACallback([this](int a) {
        abActor.getB(ABActor::GetBCallback([a, this](int b) {
            abActor.saveAB(a - b, a + b, ABActor::SaveABCallback([this](){
                abActor.getA(ABActor::GetACallback([this](int a) {
                    abActor.getB(ABActor::GetBCallback([a, this](int b) {
                        std::cout << "Result " << a << " " << b << std::endl;
                    }));
                }));
            }));
        }));
    }));

Sorte de:

const int a = co_await actor.abActor.getAAsync();
const int b = co_await actor.abActor.getBAsync();
co_await actor.abActor.saveABAsync(a - b, a + b);
const int newA = co_await actor.abActor.getAAsync();
const int newB = co_await actor.abActor.getBAsync();
std::cout << "Result " << newA << " " << newB << std::endl;

Alors, commençons.

Acteurs


Pour commencer, nous devons créer un cadre d'acteur simple. La création d'un cadre d'acteur à part entière est une tâche difficile et importante, nous n'en implémentons donc qu'une sorte.
Créez d'abord une classe de base:

class Actor {
public:
    using Task = std::function<void()>;
public:
    virtual ~Actor();
public:
    void addTask(const Task &task);
    void tryRunTask();
private:
    std::queue<Task> queue;
    mutable std::mutex mutex;
};

L'idée est fondamentalement simple: nous mettons des tâches qui sont des objets fonctionnels dans une file d'attente, et lorsque nous essayons RunTask, nous essayons de terminer cette tâche. La mise en place du cours confirme nos intentions:

Actor::~Actor() = default;

void Actor::addTask(const Task &task) {
    std::lock_guard lock(mutex);
    queue.push(task);
}

void Actor::tryRunTask() {
    std::unique_lock lock(mutex);
    if (queue.empty()) {
        return;
    }

    const Task task = queue.front();
    queue.pop();
    lock.unlock();

    std::invoke(task);
}

La classe suivante est le "fil" auquel nos acteurs appartiendront:

class Actor;

class ActorThread {
public:
    ~ActorThread();
public:
    void addActor(Actor &actor);
    void run();
private:
    std::vector<std::reference_wrapper<Actor>> actors;
};

Ici aussi, tout est simple: au tout début du programme, nous «lions» nos acteurs au thread en utilisant la méthode addActor, puis nous démarrons le thread en utilisant la méthode run.

ActorThread::~ActorThread() = default;

void ActorThread::addActor(Actor &actor) {
    actors.emplace_back(actor);
}

void ActorThread::run() {
    while (true) {
        for (Actor &actor: actors) {
            actor.tryRunTask();
        }
    }
}

Lors du démarrage du thread, nous entrons dans une boucle infinie et essayons d'effectuer une tâche de chaque acteur. Ce n'est pas la meilleure solution, mais cela fera l'affaire pour une démonstration.

Voyons maintenant le représentant de la classe d'acteurs:

class ABActor: public Actor {
public:
    using GetACallback = Callback<void(int result)>;
    using GetBCallback = Callback<void(int result)>;
    using SaveABCallback = Callback<void()>;
public:
    void getA(const GetACallback &callback);
    void getB(const GetBCallback &callback);
    void saveAB(int a, int b, const SaveABCallback &callback);
private:
    void getAProcess(const GetACallback &callback);
    void getBProcess(const GetBCallback &callback);
    void saveABProcess(int a, int b, const SaveABCallback &callback);
private:
    int a = 10;
    int b = 20;
};

Cette classe stocke 2 nombres en elle-mĂŞme - a et b, et, sur demande, renvoie leurs valeurs ou les remplace.

En rappel, il accepte un objet fonctionnel avec les paramètres nécessaires. Mais faisons attention au fait que différents acteurs peuvent être lancés dans différents threads. Et par conséquent, si à la fin du travail, nous appelons simplement le rappel passé à la méthode, ce rappel sera appelé dans le thread exécutable actuel, et non dans le thread qui a appelé notre méthode et créé ce rappel. Par conséquent, nous devons créer un wrapper sur le rappel qui résoudra cette situation:

template<typename C>
class Callback {
public:
    template<typename Functor>
    Callback(Actor &sender, const Functor &callback)
        : sender(sender)
        , callback(callback)
    {}
public:
    template<typename ...Args>
    void operator() (Args&& ...args) const {
        sender.addTask(std::bind(callback, std::forward<Args>(args)...));
    }
private:
    Actor &sender;
    std::function<C> callback;
};

Ce wrapper se souvient de l'acteur d'origine, et lorsque vous essayez de vous exécuter, il ajoute simplement un véritable rappel à la file d'attente des tâches de l'acteur d'origine.
Par conséquent, l'implémentation de la classe ABActor ressemble à ceci:

void ABActor::getA(const GetACallback &callback) {
    addTask(std::bind(&ABActor::getAProcess, this, callback));
}

void ABActor::getAProcess(const ABActor::GetACallback &callback) {
    std::invoke(callback, a);
}

void ABActor::getB(const GetBCallback &callback) {
    addTask(std::bind(&ABActor::getBProcess, this, callback));
}

void ABActor::getBProcess(const ABActor::GetBCallback &callback) {
    std::invoke(callback, b);
}

void ABActor::saveAB(int a, int b, const SaveABCallback &callback) {
    addTask(std::bind(&ABActor::saveABProcess, this, a, b, callback));
}

void ABActor::saveABProcess(int a, int b, const ABActor::SaveABCallback &callback) {
    this->a = a;
    this->b = b;
    std::invoke(callback);
}

Dans la méthode d'interface de la classe, nous lions simplement les arguments passés au «slot» correspondant de la classe, créant ainsi une tâche, et plaçons cette tâche dans la file d'attente des tâches de cette classe. Lorsque le thread de tâche commence à exécuter la tâche, il appellera ainsi le «slot» correct, qui effectuera toutes les actions dont il a besoin et appellera le rappel, qui à son tour enverra le rappel réel à la file d'attente de la tâche qui l'a provoquée.

Écrivons un acteur qui utilisera la classe ABActor:

class ABActor;

class WokrerActor: public Actor {
public:
    WokrerActor(ABActor &actor)
        : abActor(actor)
    {}
public:
    void work();
private:
    void workProcess();
private:
    ABActor &abActor;
};

void WokrerActor::work() {
    addTask(std::bind(&WokrerActor::workProcess, this));
}

void WokrerActor::workProcess() {
    abActor.getA(ABActor::GetACallback(*this, [this](int a) {
        std::cout << "Result " << a << std::endl;
    }));
}

Et tout mettre ensemble:

int main() {
    ABActor abActor;
    WokrerActor workerActor(abActor);

    ActorThread thread;
    thread.addActor(abActor);
    thread.addActor(workerActor);

    workerActor.work();

    thread.run();
}

Suivons toute la chaîne de code.

Au début, nous créons les objets nécessaires et établissons des connexions entre eux.
Ensuite, nous ajoutons la tâche workProcess à la file d'attente des tâches d'acteur Worker.
Lorsque le thread démarre, il trouvera notre tâche dans la file d'attente et commencera à l'exécuter.
Dans le processus d'exécution, nous appelons la méthode getA de la classe ABActor, plaçant ainsi la tâche correspondante dans la file d'attente de la classe ABActor, et terminons l'exécution.
Ensuite, le thread prendra la tâche nouvellement créée de la classe ABActor et l'exécutera, ce qui conduira à l'exécution du code getAProcess.
Ce code appellera un rappel, en lui passant l'argument nécessaire - la variable a. Mais puisque le rappel qu'il possède est un wrapper, en fait, un vrai rappel avec des paramètres remplis sera mis dans la file d'attente de la classe Worker.
Et lorsque, à la prochaine itération du cycle, le thread se retire et exécute notre rappel de la classe Worker, nous verrons la sortie de la ligne «Résultat 10»

Le framework d'acteur est un moyen assez pratique d'interagir avec des classes dispersées à travers différents flux physiques. La particularité de la conception des classes, comme vous auriez dû en être convaincu, est qu'au sein de chaque acteur individuel, toutes les actions sont exécutées entièrement et dans un seul fil. Le seul point de synchronisation des flux est fait dans les détails d'implémentation du framework acteur et n'est pas visible pour le programmeur. Ainsi, un programmeur peut écrire du code à un seul thread sans se soucier de boucler les mutex et de suivre les situations de course, les blocages et autres maux de tête.

Malheureusement, cette solution a un prix. Étant donné que le résultat de l'exécution d'un autre acteur n'est accessible qu'à partir du rappel, tôt ou tard le code de l'acteur se transforme en quelque chose comme ceci:

    abActor.getA(ABActor::GetACallback(*this, [this](int a) {
        abActor.getB(ABActor::GetBCallback(*this, [a, this](int b) {
            abActor.saveAB(a - b, a + b, ABActor::SaveABCallback(*this, [this](){
                abActor.getA(ABActor::GetACallback(*this, [this](int a) {
                    abActor.getB(ABActor::GetBCallback(*this, [a, this](int b) {
                        std::cout << "Result " << a << " " << b << std::endl;
                    }));
                }));
            }));
        }));
    }));

Voyons si nous pouvons Ă©viter cela en utilisant l'innovation de C ++ 20 - coroutines.

Mais d'abord, nous préciserons les limitations.

Naturellement, nous ne pouvons en aucun cas changer le code du framework acteur. De plus, nous ne pouvons pas modifier les signatures des méthodes publiques et privées des instances de la classe Actor - ABActor et WorkerActor. Voyons voir si nous pouvons sortir de cette situation.

Coroutines. Partie 1. Awaiter


L'idée principale de la corutine est que lors de la création de la coroutine, un cadre de pile distinct est créé pour elle sur le tas, à partir duquel nous pouvons "quitter" à tout moment, tout en conservant la position d'exécution actuelle, les registres du processeur et d'autres informations nécessaires. Ensuite, nous pouvons également à tout moment revenir à l'exécution de la coroutine suspendue et la terminer jusqu'à la fin ou jusqu'à la prochaine suspension.

L'objet std :: coroutine_handle <> est responsable de la gestion de ces données, qui représentent essentiellement un pointeur vers le cadre de pile (et d'autres données nécessaires), et qui a une méthode de reprise (ou son analogue, l'opérateur ()), qui nous renvoie à l'exécution de la coroutine .

Sur la base de ces données, écrivons d'abord la fonction getAAsync, puis essayons de généraliser.

Donc, supposons que nous ayons déjà une instance de la classe coro std :: coroutine_handle <>, que devons-nous faire?

Vous devez appeler la méthode ABActor :: getA déjà existante, ce qui résoudra la situation au besoin, mais vous devez d'abord créer un rappel pour la méthode getA.

Rappelons qu'un rappel est renvoyé au rappel de la méthode getA - le résultat de la méthode getA. En outre, ce rappel est appelé dans le thread de travail du thread. Ainsi, à partir de ce rappel, nous pouvons continuer en toute sécurité à exécuter la coroutine, qui a été créée uniquement à partir du thread Worker et qui continuera à exécuter sa séquence d'actions. Mais aussi, nous devons quelque part sauvegarder le résultat retourné dans le rappel, cela, bien sûr, nous sera utile plus loin.

auto callback = GetACallback(returnCallbackActor, [&value, coro](int result) {
        value = result;
        std::invoke(coro);
 });
getA(callback);

Donc, maintenant vous devez prendre une instance de l'objet coroutine_handle quelque part et un lien où vous pouvez enregistrer notre résultat.

À l'avenir, nous verrons que coroutine_handle nous est transmis suite à l'appel de la fonction. En conséquence, tout ce que nous pouvons en faire est de le transmettre à une autre fonction. Préparons cette fonction en tant que lambda. (Nous transmettrons le lien à la variable où le résultat du rappel sera stocké dans l'entreprise).

auto storeCoroToQueue = [&returnCallbackActor, this](auto &value, std::coroutine_handle<> coro) {
    auto callback=GetACallback(returnCallbackActor, [&value, coro](int result){
        value = result;
        std::invoke(coro);
    });
    getA(callback);
};

Nous enregistrerons cette fonction dans la classe suivante.

struct ActorAwaiterSimple {
    int value;

    std::function<void(int &value,std::coroutine_handle<>)> forwardCoroToCallback;

    ActorAwaiterSimple(
        const std::function<void(int &value, std::coroutine_handle<>)> &forwardCoroToCallback
    )
        : forwardCoroToCallback(forwardCoroToCallback)
    {}

    ActorAwaiterSimple(const ActorAwaiterSimple &) = delete;
    ActorAwaiterSimple& operator=(const ActorAwaiterSimple &) = delete;
    ActorAwaiterSimple(ActorAwaiterSimple &&) = delete;
    ActorAwaiterSimple& operator=(ActorAwaiterSimple &&) = delete;

// ...

En plus de l'objet fonctionnel, nous tiendrons également ici la mémoire (sous forme de valeur variable) de la valeur qui nous attend dans le rappel.

Puisque nous maintenons la mémoire sous la valeur ici, nous voulons à peine que l'instance de cette classe soit copiée ou déplacée quelque part. Imaginez, par exemple, que quelqu'un copie cette classe, enregistre la valeur sous la variable de valeur dans l'ancienne instance de la classe, puis essaie de la lire à partir de la nouvelle instance. Et ce n'est naturellement pas là, car la copie a eu lieu avant l'enregistrement. Désagréable. Par conséquent, nous nous protégeons de ce problème en interdisant les constructeurs et les opérateurs de copie et de déplacement.

Continuons à écrire cette classe. La prochaine méthode dont nous avons besoin est:

    bool await_ready() const noexcept {
        return false;
    }

Il répond à la question de savoir si notre sens est prêt à être émis. Naturellement, au premier appel, notre valeur n'est pas encore prête, et à l'avenir, personne ne nous posera de questions à ce sujet, alors renvoyez simplement false.

L'instance coroutine_handle nous sera transmise dans la méthode void wait_suspend (std :: coroutine_handle <> coro), appelons-y notre foncteur préparé, en y passant également un lien mémoire sous value:

    void await_suspend(std::coroutine_handle<> coro) noexcept {
        std::invoke(forwardCoroToCallback, std::ref(value), coro);
    }

Le résultat de l'exécution de la fonction sera demandé au bon moment en appelant la méthode expect_resume. Nous ne refuserons pas au demandeur:

    int await_resume() noexcept {
        return value;
    }

Maintenant, notre méthode peut être appelée à l'aide du mot clé co_await:

const int a = co_await actor.abActor.getAAsync(actor);

Ce qui va se passer ici, nous le représentons déjà à peu près.

Tout d'abord, un objet de type ActorAwaiterSimple sera créé, qui sera transféré à "l'entrée" de co_await. Il demandera d'abord (en appelant attend_ready) si nous avons accidentellement un résultat fini (nous n'en avons pas), puis appellera wait_suspend, en passant dans un contexte (en fait, un pointeur vers le cadre de pile de coroutine actuel) et interrompra l'exécution.

À l'avenir, lorsque l'acteur ABActor terminera son travail et appellera le rappel du résultat, ce résultat (déjà dans le thread de thread de travail) sera enregistré dans la seule instance (restant sur la pile de coroutine) d'ActorAwaiterSimple et la poursuite de la coroutine commencera.

Corutin poursuivra l'exécution, prendra le résultat enregistré en appelant la méthode expect_resume et passera ce résultat à la variable a

À l'heure actuelle, la limitation de l'Awaiter actuel est qu'il ne peut fonctionner qu'avec des rappels avec un paramètre de type int. Essayons d'étendre l'application d'Awaiter:

template<typename... T>
struct ActorAwaiter {

    std::tuple<T...> values;

    std::function<void(std::tuple<T...> &values, std::coroutine_handle<>)> storeHandler;

    ActorAwaiter(const std::function<void(std::tuple<T...> &values, std::coroutine_handle<>)> &storeHandler)
        : storeHandler(storeHandler)
    {}

    ActorAwaiter(const ActorAwaiter &) = delete;
    ActorAwaiter& operator=(const ActorAwaiter &) = delete;
    ActorAwaiter(ActorAwaiter &&) = delete;
    ActorAwaiter& operator=(ActorAwaiter &&) = delete;

    bool await_ready() const noexcept {
        return false;
    }

    void await_suspend(std::coroutine_handle<> coro) noexcept {
        std::invoke(storeHandler, std::ref(values), coro);
    }

    //   bool B  ,
    //   sfinae      
    template<
        bool B=true,size_t len=sizeof...(T),std::enable_if_t<len==0 && B, int>=0
    >
    void await_resume() noexcept {

    }

    //   bool B  ,
    //   sfinae      
    template<
        bool B=true,size_t len=sizeof...(T),std::enable_if_t<len==1 && B, int>=0
    >
    auto await_resume() noexcept {
        return std::get<0>(values);
    }

    //   bool B  ,
    //   sfinae      
    template<
        bool B=true,size_t len=sizeof...(T),std::enable_if_t<len!=1 && len!=0 && B, int>=0
    >
    std::tuple<T...> await_resume() noexcept {
        return values;
    }
};

Ici, nous utilisons std :: tuple afin de pouvoir enregistrer plusieurs variables Ă  la fois.

Sfinae est imposé à la méthode wait_resume de sorte qu'il est possible de ne pas retourner un tuple dans tous les cas, mais en fonction du nombre de valeurs se trouvant dans le tuple, return void, exactement 1 argument ou le tuple entier.

Les enveloppes pour créer Awaiter lui-même ressemblent maintenant à ceci:

template<typename MakeCallback, typename... ReturnArgs, typename Func>
static auto makeCoroCallback(const Func &func, Actor &returnCallback) {
    return [&returnCallback, func](auto &values, std::coroutine_handle<> coro) {
        auto callback = MakeCallback(returnCallback, [&values, coro](ReturnArgs&& ...result) {
            values = std::make_tuple(std::forward<ReturnArgs>(result)...);
            std::invoke(coro);
        });
        func(callback);
    };
}

template<typename MakeCallback, typename... ReturnArgs, typename Func>
static ActorAwaiter<ReturnArgs...> makeActorAwaiter(const Func &func, Actor &returnCallback) {
    const auto storeCoroToQueue = makeCoroCallback<MakeCallback, ReturnArgs...>(func, returnCallback);
    return ActorAwaiter<ReturnArgs...>(storeCoroToQueue);
}

ActorAwaiter<int> ABActor::getAAsync(Actor &returnCallback) {
    return makeActorAwaiter<GetACallback, int>(std::bind(&ABActor::getA, this, _1), returnCallback);
}

ActorAwaiter<int> ABActor::getBAsync(Actor &returnCallback) {
    return makeActorAwaiter<GetBCallback, int>(std::bind(&ABActor::getB, this, _1), returnCallback);
}

ActorAwaiter<> ABActor::saveABAsync(Actor &returnCallback, int a, int b) {
    return makeActorAwaiter<SaveABCallback>(std::bind(&ABActor::saveAB, this, a, b, _1), returnCallback);
}

Voyons maintenant comment utiliser le type créé directement dans coroutine.

Coroutines. Partie 2. Reprise


Du point de vue du C ++, une fonction qui contient les mots co_await, co_yield ou co_return est considérée comme coroutine. Mais une telle fonction devrait également renvoyer un certain type. Nous avons convenu que nous ne changerons pas la signature des fonctions (ici, je veux dire que le type de retour fait également référence à la signature), nous devrons donc en sortir d'une manière ou d'une autre.

Créons une coroutine lambda et appelons-la depuis notre fonction:

void WokrerActor::workProcess() {
    const auto coroutine = [](WokrerActor &actor) -> ActorResumable {
        const int a = co_await actor.abActor.getAAsync(actor);
        const int b = co_await actor.abActor.getBAsync(actor);
        co_await actor.abActor.saveABAsync(actor, a - b, a + b);
        const int newA = co_await actor.abActor.getAAsync(actor);
        const int newB = co_await actor.abActor.getBAsync(actor);
        std::cout << "Result " << newA << " " << newB << std::endl;
    };

    coroutine(*this);
}

(Pourquoi ne pas capturer cela dans la liste de capture des lambdas? Ensuite, tout le code à l'intérieur serait un peu plus facile. Mais il se trouve que, apparemment, les lambda-coroutines du compilateur ne sont pas encore entièrement prises en charge, donc ce code ne fonctionnera pas.)

Comme vous pouvez le voir, notre le code de rappel effrayant est maintenant devenu un code linéaire assez agréable. Il ne nous reste plus qu'à inventer la classe ActorResumable.

Examinons-la.

struct ActorResumable {
    struct promise_type {
        using coro_handle = std::coroutine_handle<promise_type>;

        auto get_return_object() { 
            //  ,    ActorResumable   promise_type
            return coro_handle::from_promise(*this);
        }

        auto initial_suspend() {
            //      
            return std::suspend_never();
        }

        auto final_suspend() {
            //      . 
            // ,     
            return std::suspend_never();
        }

        void unhandled_exception() {
            //   ,       
            std::terminate();
        }
    };

    ActorResumable(std::coroutine_handle<promise_type>) {}
};

Le pseudocode de la corutine générée à partir de notre lambda ressemble à ceci:

ActorResumable coro() {
    promise_type promise;
    ActorResumable retobj = promise.get_return_object();
    auto intial_suspend = promise.initial_suspend();
    if (initial_suspend == std::suspend_always)  {
          // yield
    }
    try { 
        //  .
        const int a = co_await actor.abActor.getAAsync(actor);
        std::cout << "Result " << a << std::endl;
    } catch(...) { 
        promise.unhandled_exception();
    }
final_suspend:
    auto final_suspend = promise.final_suspend();
    if (final_suspend == std::suspend_always)  {
         // yield
    } else {
         cleanup();
    }

Ce n'est qu'un pseudo-code, certaines choses sont intentionnellement simplifiées. Voyons néanmoins ce qui se passe.

Nous créons d'abord une promesse et ActorResumable.

Après initial_suspend (), nous ne faisons pas de pause, mais continuons. Nous commençons à exécuter la partie principale du programme.

Lorsque nous arrivons à co_await, nous comprenons que nous devons faire une pause. Nous avons déjà examiné cette situation dans la section précédente, vous pouvez y revenir et l'examiner.

Après avoir poursuivi l'exécution et affiché le résultat à l'écran, l'exécution de la coroutine se termine. Nous vérifions final_suspend et effaçons tout le contexte de la coroutine.

Coroutines. Partie 3. Tâche


Souvenons-nous du stade que nous avons atteint.

void WokrerActor::workProcess() {
    const auto coroutine = [](WokrerActor &actor) -> ActorResumable {
        const int a = co_await actor.abActor.getAAsync(actor);
        const int b = co_await actor.abActor.getBAsync(actor);
        co_await actor.abActor.saveABAsync(actor, a - b, a + b);
        const int newA = co_await actor.abActor.getAAsync(actor);
        const int newB = co_await actor.abActor.getBAsync(actor);
        std::cout << "Result " << newA << " " << newB << std::endl;
    };

    coroutine(*this);
}

Il a l'air bien, mais il est facile de voir que le code:

        const int a = co_await actor.abActor.getAAsync(actor);
        const int b = co_await actor.abActor.getBAsync(actor);

répété 2 fois. Est-il possible de refactoriser ce moment et de le mettre dans une fonction distincte?

Voyons Ă  quoi cela pourrait ressembler:

CoroTask<std::pair<int, int>> WokrerActor::readAB() {
    const int a = co_await abActor.getAAsync2(*this);
    const int b = co_await abActor.getBAsync2(*this);
    co_return std::make_pair(a, b);
}

void WokrerActor::workCoroProcess() {
    const auto coroutine = [](WokrerActor &actor) -> ActorResumable {
        const auto [a, b] = co_await actor.readAB();
        co_await actor.abActor.saveABAsync2(actor, a - b, a + b);
        const auto [newA, newB] = co_await actor.readAB();
        std::cout << "Result " << newA << " " << newB << " " << a << " " << b << std::endl;
    };

    coroutine(*this);
}

Il ne nous reste plus qu'à inventer le type CoroTask. Réfléchissons-y. Tout d'abord, co_return est utilisé à l'intérieur de la fonction readAB, ce qui signifie que CoroTask doit satisfaire l'interface Resumable. Mais aussi, un objet de cette classe est utilisé pour entrer co_await d'une autre coroutine. Cela signifie que la classe CoroTask doit également satisfaire l'interface Awaitable. Implémentons ces deux interfaces dans la classe CoroTask:

template <typename T = void>
struct CoroTask {
    struct promise_type {
        T result;
        std::coroutine_handle<> waiter;

        auto get_return_object() {
            return CoroTask{*this};
        }

        void return_value(T value) {
            result = value;
        }

        void unhandled_exception() {
            std::terminate();
        }

        std::suspend_always initial_suspend() {
            return {};
        }

        auto final_suspend() {
            struct final_awaiter {
                bool await_ready() {
                    return false;
                }
                void await_resume() {}
                auto await_suspend(std::coroutine_handle<promise_type> me) {
                    return me.promise().waiter;
                }
            };
            return final_awaiter{};
        }
    };

    CoroTask(CoroTask &&) = delete;
    CoroTask& operator=(CoroTask&&) = delete;
    CoroTask(const CoroTask&) = delete;
    CoroTask& operator=(const CoroTask&) = delete;

    ~CoroTask() {
        if (h) {
            h.destroy();
        }
    }

    explicit CoroTask(promise_type & p)
        : h(std::coroutine_handle<promise_type>::from_promise(p))
    {}

    bool await_ready() {
        return false;
    }

    T await_resume() {
        auto &result = h.promise().result;
        return result;
    }

    void await_suspend(std::coroutine_handle<> waiter) {
        h.promise().waiter = waiter;
        h.resume();
    }
private:
    std::coroutine_handle<promise_type> h;
};

(Je recommande fortement d'ouvrir l'image d'arrière-plan de ce message. À l'avenir, cela vous aidera grandement.)

Alors, voyons ce qui se passe ici.

1. Accédez à la coroutine lambda et créez immédiatement la coroutine WokrerActor :: readAB. Mais après avoir créé cette coroutine, nous ne commençons pas à l'exécuter (initial_suspend == suspend_always), ce qui nous oblige à interrompre et revenir à la coroutine lambda.

2. co_await lambda vérifie si readAB est prêt. Le résultat n'est pas prêt (wait_ready == false), ce qui l'oblige à passer son contexte à la méthode CoroTask :: attendant_suspend. Ce contexte est enregistré dans CoroTask et la reprise des coroutines readAB

3. est lancée . Une fois que readAB coroutine a terminé toutes les actions nécessaires, il atteint la ligne:

co_return std::make_pair(a, b);

en conséquence, la méthode CoroTask :: promise_type :: return_value est appelée et la paire de nombres créée est enregistrée dans CoroTask :: promise_type

4. Puisque la méthode co_return a été appelée, l'exécution de la coroutine prend fin, ce qui signifie qu'il est temps d'appeler la méthode CoroTask :: promise_type :: final_suspend . Cette méthode renvoie une structure auto-écrite (n'oubliez pas de regarder l'image), qui vous oblige à appeler la méthode final_awaiter :: attendant_suspend, qui retourne le contexte lambda coroutine stocké à l'étape 2.

Pourquoi ne pourrions-nous pas simplement retourner suspend_always ici? Après tout, dans le cas de la suspension initiale de cette classe, avons-nous réussi? Le fait est que dans initial_suspend nous avons réussi parce que cette coroutine a été appelée par notre coroutine lambda, et nous y sommes revenus. Mais au moment où nous avons atteint l'appel final_suspend, notre coroutine a très probablement continué à partir d'une autre pile (en particulier, à partir du lambda que la fonction makeCoroCallback a préparé), et si nous renvoyions suspend_always ici, nous y retournerions, et non à la méthode workCoroProcess.

5. Puisque la méthode final_awaiter :: attendant_suspend nous a renvoyé le contexte, cela force le programme à continuer d'exécuter le contexte retourné, c'est-à-dire la coroutine lambda. Depuis que l'exécution est revenue au point:

const auto [a, b] = co_await actor.readAB();

nous devons ensuite isoler le résultat enregistré en appelant la méthode CoroTask :: attendant_resume. Le résultat est reçu, transmis aux variables a et b, et maintenant l'instance de CoroTask est détruite.

6. L'instance CoroTask a été détruite, mais qu'est-il arrivé au contexte WokrerActor :: readAB? Si nous, de CoroTask :: promise_type :: final_suspend, renverrions suspend_never (plus précisément, renverrions cela à la question en attente_deviendrait vrai), alors à ce moment le contexte coroutine serait nettoyé. Mais comme nous ne l'avons pas fait, l'obligation de clarifier le contexte nous est transférée. Nous allons effacer ce contexte dans le destructeur CoroTask, à ce stade, il est déjà sûr.

7. La coroutine readAB est exécutée, le résultat en est obtenu, le contexte est effacé, la lambda coroutine continue de fonctionner ...

Ouf, en quelque sorte réglé. Vous souvenez-vous qu'à partir des méthodes ABActor :: getAAsync () et similaires, nous retournons une structure auto-écrite? En fait, la méthode getAAsync peut également être transformée en coroutine en combinant les connaissances acquises lors de l'implémentation des classes CoroTask et ActorAwaiter et en obtenant quelque chose comme:

CoroTaskActor<int> ABActor::getAAsync(Actor &returnCallback) {
    co_return makeCoroCallback<GetACallback, int>(std::bind(&ABActor::getA, this, _1), returnCallback);
}

mais je laisse cela pour l'auto-analyse.

résultats


Comme vous pouvez le voir, avec l'aide de coroutine, vous pouvez assez bien linéariser le code de rappel asynchrone. Certes, le processus d'écriture des types et fonctions auxiliaires ne semble pas encore trop intuitif.

Tout le code est disponible dans le référentiel. Je

vous recommande également de consulter ces conférences pour une immersion plus complète dans le sujet
.
Un grand nombre d'exemples sur le thème de la coroutine du même auteur sont ici .
Et vous pouvez également regarder cette conférence.

All Articles