Código asincrónico linealizado con corutina

imagen

Además de usar la rutina para crear generadores, puede intentar usarlos para linealizar el código asincrónico existente. Intentemos hacer esto con un pequeño ejemplo. Tome el código escrito en el marco del actor y reescriba una función de este código en las rutinas. Para construir el proyecto, usaremos gcc de la rama de corutinas .

Nuestro objetivo es obtener devoluciones de llamada de fideos:

    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;
                    }));
                }));
            }));
        }));
    }));

Algo así como:

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;

Entonces empecemos.

Actores


Para empezar, necesitamos crear un marco de actor simple. Crear un marco de actor completo es una tarea difícil y grande, por lo que solo implementamos algún tipo de él.
Primero, cree una clase 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;
};

La idea es básicamente simple: colocamos tareas que son objetos funcionales en una cola, y cuando intentamos RunTask, intentamos completar esta tarea. La implementación de la clase confirma nuestras intenciones:

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 siguiente clase es el "hilo" al que pertenecerán nuestros actores:

class Actor;

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

Aquí también todo es simple: al comienzo del programa, "vinculamos" a nuestros actores al hilo usando el método addActor, y luego comenzamos el hilo usando el método run.

ActorThread::~ActorThread() = default;

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

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

Al comenzar el hilo, ingresamos en un bucle infinito e intentamos realizar una tarea de cada actor. No es la mejor solución, pero servirá para una demostración.

Ahora veamos al representante de la clase de actor:

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;
};

Esta clase almacena 2 números en sí mismo, ayb, y, a pedido, devuelve sus valores o los sobrescribe.

Como devolución de llamada, acepta un objeto funcional con los parámetros necesarios. Pero prestemos atención al hecho de que diferentes actores pueden ser lanzados en diferentes hilos. Y, por lo tanto, si al final del trabajo simplemente llamamos a la devolución de llamada pasada al método, esta devolución de llamada se invocará en el subproceso ejecutable actual, y no en el subproceso que llamó a nuestro método y creó esta devolución de llamada. Por lo tanto, necesitamos crear un contenedor sobre la devolución de llamada que resolverá esta situación:

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;
};

Este contenedor recuerda al actor original, y cuando intenta ejecutarse, simplemente agrega una devolución de llamada real a la cola de tareas del actor original.
Como resultado, la implementación de la clase ABActor se ve así:

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);
}

En el método de interfaz de la clase, simplemente vinculamos los argumentos pasados ​​a la "ranura" correspondiente de la clase, creando así una tarea, y colocamos esta tarea en la cola de tareas de esta clase. Cuando el subproceso de la tarea comienza a realizar la tarea, llamará al "slot" correcto, que realizará todas las acciones que necesita y llamará a la devolución de llamada, que a su vez enviará la devolución de llamada real a la cola de la tarea que lo causó.

Escribamos un actor que usará la clase 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;
    }));
}

Y poner todo junto:

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

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

    workerActor.work();

    thread.run();
}

Sigamos toda la cadena de código.

Al principio, creamos los objetos necesarios y establecemos conexiones entre ellos.
Luego agregamos la tarea workProcess a la cola de tareas del actor Worker.
Cuando se inicia el hilo, encontrará nuestra tarea en la cola y comenzará a ejecutarla.
En el proceso de ejecución, llamamos al método getA de la clase ABActor, colocando así la tarea correspondiente en la cola de la clase ABActor y completamos la ejecución.
A continuación, el subproceso tomará la tarea recién creada de la clase ABActor y la ejecutará, lo que conducirá a la ejecución del código getAProcess.
Este código llamará a una devolución de llamada, pasando el argumento necesario: la variable a. Pero dado que la devolución de llamada que posee es una envoltura, de hecho, una devolución de llamada real con parámetros completados se colocará en la cola de la clase Worker.
Y cuando, en la próxima iteración del ciclo, el subproceso se retira y ejecuta nuestra devolución de llamada desde la clase Worker, veremos el resultado de la línea "Resultado 10"

Actor Framework es una forma bastante conveniente de interactuar con las clases dispersas entre diferentes flujos físicos entre sí. La peculiaridad del diseño de clase, como debería haber estado convencido de esto, es que dentro de cada actor individual todas las acciones se realizan por completo y en un solo hilo. El único punto de sincronización de las secuencias se realiza en los detalles de implementación del marco del actor y no es visible para el programador. Por lo tanto, un programador puede escribir código de un solo subproceso sin preocuparse por cerrar mutexes y rastrear situaciones de carrera, puntos muertos y otros dolores de cabeza.

Lamentablemente, esta solución tiene un precio. Dado que el resultado de ejecutar a otro actor solo es accesible desde la devolución de llamada, tarde o temprano el código del actor se convierte en algo como esto:

    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;
                    }));
                }));
            }));
        }));
    }));

Veamos si podemos evitar esto usando la innovación de C ++ 20 - corutinas.

Pero primero, especificaremos las limitaciones.

Naturalmente, de ninguna manera podemos cambiar el código del marco del actor. Además, no podemos cambiar las firmas de los métodos públicos y privados de instancias de la clase Actor: ABActor y WorkerActor. Veamos si podemos salir de esta situación.

Corutinas Parte 1. Awaiter


La idea principal de la rutina es que al crear la rutina, se crea un marco de pila separado para él en el montón, desde el cual podemos "salir" en cualquier momento, mientras mantenemos la posición de ejecución actual, los registros del procesador y otra información necesaria. Luego, en cualquier momento, también podemos volver a la ejecución de la corutina suspendida y completarla hasta el final o hasta la próxima suspensión.

El objeto std :: coroutine_handle <> es responsable de administrar estos datos, que esencialmente representan un puntero al marco de la pila (y otros datos necesarios), y que tiene un método de reanudación (o su análogo, el operador ()), que nos devuelve a la ejecución de la rutina. .

En base a estos datos, primero escribamos la función getAAsync y luego intentemos generalizar.

Entonces, supongamos que ya tenemos una instancia de la clase coro std :: coroutine_handle <>, ¿qué necesitamos hacer?

Debe llamar al método ya existente ABActor :: getA, que resolverá la situación según sea necesario, pero primero debe crear una devolución de llamada para el método getA.

Recordemos que una devolución de llamada se devuelve a la devolución de llamada del método getA, el resultado del método getA. Además, esta devolución de llamada se llama en el subproceso de trabajo del subproceso. Por lo tanto, a partir de esta devolución de llamada, podemos continuar ejecutando con seguridad la rutina que se creó solo a partir del subproceso Worker y que continuará llevando a cabo su secuencia de acciones. Pero también, en algún lugar debemos guardar el resultado devuelto en la devolución de llamada, por supuesto, nos será útil aún más.

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

Entonces, ahora necesita tomar una instancia del objeto coroutine_handle de alguna parte y un enlace donde pueda guardar nuestro resultado.

En el futuro, veremos que se nos pasa coroutine_handle como resultado de llamar a la función. En consecuencia, todo lo que podemos hacer con él es pasarlo a alguna otra función. Preparemos esta función como lambda. (Pasaremos el enlace a la variable donde se almacenará el resultado de la devolución de llamada a la empresa).

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);
};

Guardaremos esta función en la próxima clase.

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;

// ...

Además del objeto funcional, también mantendremos aquí la memoria (en forma de valor variable) para el valor que nos espera en la devolución de llamada.

Como mantenemos la memoria debajo del valor aquí, difícilmente queremos que la instancia de esta clase se copie o se mueva a alguna parte. Imagine, por ejemplo, que alguien copió esta clase, guardó el valor bajo la variable de valor en la instancia anterior de la clase y luego trató de leerlo desde la nueva instancia. Y, naturalmente, no está allí, ya que la copia se produjo antes de guardar. Desagradable. Por lo tanto, nos protegemos de este problema al prohibir a los constructores y copiar y mover operadores.

Sigamos escribiendo esta clase. El siguiente método que necesitamos es:

    bool await_ready() const noexcept {
        return false;
    }

Responde a la pregunta de si nuestro significado está listo para ser emitido. Naturalmente, en la primera llamada, nuestro valor aún no está listo, y en el futuro nadie nos preguntará sobre esto, así que simplemente devuelva falso.

La instancia de coroutine_handle se nos pasará en el método void await_suspend (std :: coroutine_handle <> coro), así que llamemos a nuestro functor preparado, pasando allí también un enlace de memoria debajo del valor:

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

El resultado de la ejecución de la función se preguntará en el momento adecuado llamando al método await_resume. No nos negaremos al solicitante:

    int await_resume() noexcept {
        return value;
    }

Ahora se puede llamar a nuestro método usando la palabra clave co_await:

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

Lo que sucederá aquí, ya lo estamos representando aproximadamente.

Primero, se creará un objeto de tipo ActorAwaiterSimple, que se transferirá a la "entrada" de co_await. Primero preguntará (llamando a await_ready) si accidentalmente tenemos un resultado final (no tenemos), luego llamará a await_suspend, pasando en un contexto (de hecho, un puntero al marco de pila de rutina actual) e interrumpirá la ejecución.

En el futuro, cuando el actor ABActor complete su trabajo y llame a la devolución de llamada de resultado, este resultado (que ya está en el subproceso de subproceso de trabajo) se guardará en la única instancia (que queda en la pila de la rutina) de ActorAwaiterSimple y comenzará la continuación de la rutina.

Corutin continuará la ejecución, tomará el resultado guardado llamando al método await_resume y pasará este resultado a la variable a

Por el momento, la limitación del Awaiter actual es que solo puede funcionar con devoluciones de llamada con un parámetro de tipo int. Intentemos expandir la aplicación de 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;
    }
};

Aquí usamos std :: tuple para poder guardar varias variables a la vez.

Sfinae se impone en el método await_resume para que sea posible no devolver una tupla en todos los casos, sino que depende del número de valores que se encuentran en la tupla, return void, exactamente 1 argumento o la tupla completa.

Los envoltorios para crear Awaiter ahora se ven así:

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);
}

Ahora veamos cómo usar el tipo creado directamente en la rutina.

Corutinas Parte 2. Reanudable


Desde el punto de vista de C ++, una función que contiene las palabras co_await, co_yield o co_return se considera de rutina. Pero también dicha función debería devolver un cierto tipo. Acordamos que no cambiaremos la firma de las funciones (aquí quiero decir que el tipo de retorno también se refiere a la firma), por lo que tendremos que salir de alguna manera.

Creemos una corutina lambda y llamemos desde nuestra función:

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);
}

(¿Por qué no capturar esto en la captura-lista de lambdas? Entonces todo el interior de código saldría un poco más fácil. Pero da la casualidad de que, al parecer, las lambda-co-rutinas en el compilador todavía no están soportados por completo, por lo que este código no funcionará.)

Como se puede ver, nuestra el código de devolución de llamada ahora se ha convertido en un código lineal bastante agradable. Todo lo que nos queda es inventar la clase ActorResumable

.

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>) {}
};

El pseudocódigo de la corutina generada a partir de nuestra lambda se parece a esto:

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();
    }

Esto es solo un pseudocódigo, algunas cosas se simplifican intencionalmente. Sin embargo, veamos qué pasa.

Primero creamos una promesa y ActorResumable.

Después de initial_suspend () no hacemos una pausa, sino que seguimos adelante. Comenzamos a ejecutar la parte principal del programa.

Cuando llegamos a co_await, entendemos que debemos hacer una pausa. Ya hemos examinado esta situación en la sección anterior, puede volver a ella y revisarla.

Después de continuar la ejecución y mostrar el resultado en la pantalla, finaliza la ejecución de rutina. Verificamos final_suspend y borramos todo el contexto de la rutina.

Corutinas Parte 3. Tarea


Recordemos a qué etapa hemos llegado ahora.

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);
}

Se ve bien, pero es fácil ver que el código:

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

repetido 2 veces. ¿Es posible refactorizar este momento y ponerlo en una función separada?

Esbocemos cómo se vería esto:

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);
}

Todo lo que nos queda es inventar el tipo CoroTask. Pensemos en ello. Primero, co_return se usa dentro de la función readAB, lo que significa que CoroTask debe satisfacer la interfaz Reanudable. Pero también, un objeto de esta clase se usa para ingresar co_await de otra corutina. Esto significa que la clase CoroTask también debe satisfacer la interfaz Awaitable. Implementemos ambas interfaces en la clase 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;
};

(Recomiendo encarecidamente abrir la imagen de fondo de esta publicación. En el futuro, esto será de gran ayuda).

Entonces, veamos qué sucede aquí.

1. Vaya a la rutina lambda e inmediatamente cree la rutina WokrerActor :: readAB. Pero después de crear esta corutina, no comenzamos a ejecutarla (initial_suspend == suspend_always), lo que nos obliga a interrumpir y volver a la lambda corutina.

2. co_await lambda comprueba si readAB está listo. El resultado no está listo (await_ready == false), lo que lo obliga a pasar su contexto al método CoroTask :: await_suspend. Este contexto se guarda en CoroTask y se inicia el resumen de la rutina

de readAB 3. Después de que la rutina de readAB haya completado todas las acciones necesarias, llega a la línea:

co_return std::make_pair(a, b);

como resultado, se llama al método CoroTask :: promise_type :: return_value y dentro del método CoroTask :: promise_type se guarda el par de números creado

4. Dado que se llamó al método co_return, la ejecución de la rutina llega a su fin, lo que significa que es hora de llamar al método CoroTask :: promise_type :: final_suspend . Este método devuelve una estructura autoescrita (no olvide mirar la imagen), que le obliga a llamar al método final_awaiter :: await_suspend, desde el cual se devuelve el contexto lambda de rutina guardado en el paso 2.

¿Por qué no podríamos regresar suspend_always aquí? Después de todo, en el caso de initial_suspend de esta clase, ¿tuvimos éxito? El hecho es que en initial_suspend lo logramos porque nuestra corutina fue llamada por nuestra corutina lambda, y volvimos a ella. Pero en el momento en que llegamos a la llamada final_suspend, lo más probable es que nuestra rutina continúe desde otra pila (específicamente, desde el lambda que preparó la función makeCoroCallback), y si regresáramos suspend_always aquí, regresaríamos a ella, y no al método workCoroProcess.

5. Dado que el método final_awaiter :: await_suspend nos devolvió el contexto, esto obliga al programa a continuar ejecutando el contexto devuelto, es decir, la lambda corutina. Desde que la ejecución volvió al punto:

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

entonces necesitamos aislar el resultado guardado llamando al método CoroTask :: await_resume. Se recibe el resultado, se pasa a las variables a y b, y ahora se destruye la instancia de CoroTask.

6. La instancia de CoroTask fue destruida, pero ¿qué pasó con el contexto WokrerActor :: readAB? Si desde CoroTask :: promise_type :: final_suspend devolveríamos suspend_never (más precisamente, devolveríamos eso a la pregunta await_ready volvería verdadero), entonces en ese momento el contexto de rutina se limpiaría. Pero como no lo hicimos, se nos transfiere la obligación de aclarar el contexto. Despejaremos este contexto en el destructor CoroTask, en este punto ya es seguro.

7. Se ejecuta la corrutina readAB, se obtiene el resultado, se borra el contexto, la corrutina lambda continúa ejecutándose ...

Uf, más o menos lo resolvió. ¿Recuerdas que de los métodos ABActor :: getAAsync () y similares, devolvemos una estructura autoescrita? De hecho, el método getAAsync también se puede convertir en una rutina combinando el conocimiento obtenido de la implementación de las clases CoroTask y ActorAwaiter y obteniendo algo como:

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

pero esto lo dejaré para autoanálisis.

recomendaciones


Como puede ver, con la ayuda de la rutina, puede linealizar bastante bien el código de devolución de llamada asíncrono. Es cierto que el proceso de escribir tipos y funciones auxiliares todavía no parece demasiado intuitivo.

Todo el código está disponible en el repositorio.

También le recomiendo que mire estas conferencias para una inmersión más completa en el tema
. Aquí
hay una gran cantidad de ejemplos sobre el tema de la corutina del mismo autor . Y también puedes ver esta conferencia.

All Articles