Линеаризуем асинхронный код с помощью корутин +5





image

Помимо использования корутин для создания генераторов, их можно попробовать использовать для линеаризации уже существующего асинхронного кода. Давайте попробуем это сделать на небольшом примере. Возьмем код, написанный на акторном фреймворке и перепишем одну функцию этого кода на корутины. Для сборки проекта будем использовать gcc из ветки coroutines.

Наша цель — получить из лапши коллбэков:

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

Что-то вроде:

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;

Итак, приступим.

Акторы


Для начала нам нужно создать простенький акторный фреймворк. Создание полноценного акторного фреймворка — непростая и большая задача, поэтому мы реализуем лишь некое его подобие.
Для начала создадим базовый класс:

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

Идея в принципе проста: мы помещаем задачи, являющиеся функциональными объектами, в очередь, и по вызову tryRunTask пытаемся выполнить эту задачу. Реализация класса подтверждает наши намерения:

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

Следующий класс — это «тред», к которому будет принадлежать наши акторы:

class Actor;

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

Тут тоже все просто: в самом начале программы мы «привязываем» наши акторы к треду методом addActor, а потом запускаем тред методом run.

ActorThread::~ActorThread() = default;

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

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

При запуске «треда», мы входим в бесконечный цикл и пытаемся выполнить по одной задаче с каждого актора. Не самое оптимальное решение, но для демонстрации пойдет.

Теперь давайте рассмотрим представителя класса акторов:

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

Этот класс хранит в себе 2 числа — a и b, и по запросу выдает их значения или перезаписывает.

В качестве коллбэка он принимает функциональный объект с необходимыми параметрами. Но давайте обратим внимание на то, что разные акторы могут быть запущены в разных потоках. И поэтому, если по окончании работы мы просто вызовем переданный в метод коллбэк, этот коллбэк будет вызван в текущем выполняемом треде, а не в том треде, что вызвал наш метод и создал этот коллбэк. Поэтому нам нужно создать обертку над коллбэком, которая разрулит эту ситуацию:

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

Эта обертка запоминает исходный актор и при попытке выполнить себя просто добавляет настоящий коллбэк в очередь задач исходного актора.
В результате, реализация класса ABActor выглядит так:

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

В интерфейсном методе класса мы просто биндим переданные аргументы к соответствующему «слоту» класса, создавая таким образом задачу, и помещаем эту задачу в очередь задач этого класса. Когда тред задач начнет выполнять задачу, он таким образом вызовет правильный «слот», который выполнит все необходимые ему действия и вызовет коллбэк, который в свою очередь отдаст настоящий коллбэк в очередь вызвавшей задачи.

Давайте напишем актора, который будет использовать класс 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;
    }));
}

И соберем все это вместе:

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

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

    workerActor.work();

    thread.run();
}

Давайте проследим всю цепочку работы кода.

В начале, мы создаем необходимые объекты и устанавливаем связи между ними.
Потом мы добавляем задачу workProcess в очередь задач Worker актора.
Когда тред запустится, он обнаружит в очереди нашу задачу и начнет ее выполнять.
В процессе выполнения, мы вызовем метод getA класса ABActor, тем самым положив соответствующую задачу в очередь класса ABActor, и завершим выполнение.
Дальше тред возьмет только что созданную задачу из класса ABActor, и выполнит ее, что приведет к выполнению кода getAProcess.
Этот код вызовет коллбэк, передав в него нужный аргумент — переменную a. Но так как коллбэк, которым он владеет, это обертка, то на самом деле настоящий коллбэк с заполненными параметрами положится в очередь класса Worker.
И когда на следующей итерации цикла тред вытащит и исполнит наш коллбэк из класса Worker, мы увидим вывод на экран строки «Result 10»

Акторный фреймворк — довольно удобный способ взаимодействия классов, раскиданных по разным физическим потокам, друг с другом. Особенность проектирования классов, как вы должны были в этом убедиться, в том, что внутри каждого отдельного актора все действия выполняются целиком и полностью в единственном потоке. Единственная точка синхронизации потоков вынесена в детали реализации акторного фреймворка и не видна программисту. Таким образом, программист может писать однопоточный код, не заботясь обкладыванием мьютексами и отслеживанием ситуаций гонок, deadlock-ов и прочей головной боли.

К сожалению, у такого решения есть своя цена. Так как результат выполнения другого актора доступен только из коллбэка, то рано или поздно акторный код превращается в нечто такое:

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

Давайте посмотрим, сможем ли мы этого избежать, используя нововведение C++20 — корутины.

Но сначала оговорим ограничения.

Естественно, мы никоим образом не можем менять код акторного фреймворка. Также, мы не можем менять сигнатуры публичных и приватных методов экземпляров класса Actor — ABActor и WorkerActor. Посмотрим, сможем ли мы выкрутиться из этой ситуации.

Корутины. Часть 1. Awaiter


Основная идея корутин — что при создании корутин для нее создается отдельный стековый фрейм на куче, из которого мы в любой момент можем «выйти», сохранив при этом текущую позицию выполнения, регистры процессора и другую необходимую информацию. Потом мы также в любой момент можем вернуться к выполнению приостановленной корутины и выполнить ее до конца или до следующей приостановки.

За управлением этими данными отвечает объект std::coroutine_handle<>, который по сути представляет указатель на стековый фрейм (и другие необходимые данные), и у которого есть метод resume (или его аналог, оператор ()), который возвращает нас к выполнению корутины.

Давайте на основе этих данных сначала напишем функцию getAAsync, а потом попробуем обобщить.

Итак, предположим, что у нас уже есть экземпляр класса std::coroutine_handle<> coro, что нам нужно сделать?

Необходимо вызвать уже существующий метод ABActor::getA, который разрулит ситуацию как нужно, но для начала необходимо создать для метода getA коллбэк.

Давайте вспомним, в коллбэк метода getA возвращается число — результат выполнения метода getA. Причем этот коллбэк вызывается в потоке Worker треда. Таким образом, из этого коллбэка мы можем безопасно продолжить выполнять корутину, которая была создана как раз из треда Worker-а и которая продолжит выполнять свою последовательность действий. Но также мы должны куда-то сохранить результат возвращенный в коллбэке, он нам, естественно, дальше пригодится.

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

Итак, теперь нужно откуда-то взять экземпляр объекта coroutine_handle и ссылку, куда можно сохранить наш результат.

В дальнейшем мы увидим, что coroutine_handle передается к нам в результате вызова функции. Соответственно, все что мы можем с ним сделать, передать его в какую-то другую функцию. Давайте подготовим эту функцию в виде лямбды. (Ссылку на переменную, где будет храниться результат выполнения коллбэка, передадим в эту функцию за компанию).

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

Эту функцию мы сохраним в следующем классе.

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;

// ...

Помимо функционального объекта, мы также будем здесь держать память (в виде переменной value) под ожидающее нас в коллбэке значение.

Так как мы здесь держим память под значение, то вряд ли мы хотим, чтобы экземпляр этого класса куда-то скопировался или переместился. Представьте, что например кто-то скопировал этот класс, сохранил значение под переменную value в старом экземпляре класса, а потом попытался прочитать его из нового экземпляра. А его там естественно нет, так как копирование произошло раньше сохранения. Неприятно. Поэтому оградим себя от этой неприятности, запретив конструкторы и операторы копирования и перемещения.

Давайте продолжим писать этот класс. Следующий метод, который нам нужен, это:

    bool await_ready() const noexcept {
        return false;
    }

Он отвечает на вопрос, готово ли наше значение для того, чтобы быть выдано. Естественно, при первом вызове наше значение еще не готово, а в дальнейшем нас никто спрашивать об этом не будет, поэтому просто вернем false.

Экземпляр coroutine_handle нам будет передан в методе void await_suspend(std::coroutine_handle<> coro), так что давайте в нем вызовем наш подготовленный функтор, передав туда также ссылку на память под value:

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

Результат выполнения функции в нужный момент нас попросят, вызвав метод await_resume. Не будем отказывать просящему:

    int await_resume() noexcept {
        return value;
    }

Теперь наш метод можно вызывать, используя ключевое слово co_await:

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

Что здесь произойдет, мы уже примерно представляем.

Сначала создастся объект типа ActorAwaiterSimple, который передастся на «вход» co_await-у. Он сначала поинтересуется (вызвав await_ready), нет ли у нас случайно уже готового результата (у нас нет), после чего вызовет await_suspend, передав в него контекст (по сути, указатель на текущий стековый фрейм корутины) и прервет выполнение.

В дальнейшем, когда актор ABActor выполнит свою работу и вызовет коллбэк с результатом, этот результат (уже в треде потока Worker) сохранится в единственный (оставшийся на стеке корутины) экземпляр ActorAwaiterSimple и запустится продолжение корутины.

Корутина продолжит выполнение, возьмет сохраненный результат, вызвав метод await_resume, и передаст этот результат в переменную a

На данный момент ограничение текущего Awaiter-а в том, что он умеет работать только с коллбеками с одним параметром типа int. Давайте попробуем расширить применение 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;
    }
};

Здесь мы пользуемся std::tuple для того, чтобы иметь возможность сохранить сразу несколько переменных.

На метод await_resume наложен sfinae для того, чтобы можно было не возвращать во всех случаях tuple, а в зависимости от количества значений, лежащих в tuple, возвращать void, ровно 1 аргумент или tuple целиком.

Обертки для создания самого Awaiter-а теперь выглядит так:

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

Теперь давайте разберемся, как воспользоваться созданным типом непосредственно в корутине.

Корутины. Часть 2. Resumable


С точки зрения C++, корутиной считается функция, которая содержит в себе слова co_await, co_yield или co_return. Но также такая функция должна возвращать определенный тип. Мы условились, что не будем менять сигнатуру функций (здесь я подразумеваю, что возвращаемый тип тоже относится к сигнатуре), поэтому придется как-то выкручиваться.

Давайте создадим лямбду-корутину и вызовем ее из нашей функции:

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

(Почему не захватить this в capture-list лямбды? Тогда весь код внутри вышел бы чуть проще. Но так получилось, что, видимо, лямбда-корутины в компиляторе пока поддерживаются не полностью, поэтому такой код работать не будет.)

Как видите, наш страшный код на коллбэках превратился теперь в довольно приятный линейный код. Все, что нам осталось, это изобрести класс 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>) {}
};

Псевдокод сгенерированной корутины из нашей лямбды выглядит примерно следующим образом:

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

Это всего лишь псевдокод, некоторые вещи намеренно упрощены. Давайте тем не менее посмотрим, что происходит.

Вначале мы создаем promise и ActorResumable.

После initial_suspend() мы не приостанавливаемся, а идем дальше. Начинаем выполнять основную часть программы.

Когда доходим до co_await-а, понимаем, что нужно приостановиться. Мы эту ситуацию уже разбирали в предыдущем разделе, можно вернуться к нему и пересмотреть.

После того, как мы продолжили выполнение и вывели результат на экран, выполнение корутины заканчивается. Проверяем final_suspend, и очищаем весь контекст корутины.

Корутины. Часть 3. Task


Давайте вспомним, до какого этапа мы сейчас дошли.

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

Выглядит неплохо, но несложно заметить, что код:

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

повторяется 2 раза. Нельзя ли отрефакторить этот момент и вынести его в отдельную функцию?

Давайте набросаем, как это может выглядеть:

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

Нам осталось лишь изобрести тип CoroTask. Давайте подумаем. Во-первых, внутри функции readAB используется co_return, это значит, что CoroTask должен удовлетворять интерфейсу Resumable. Но также, объект этого класса используется на вход co_await-а другой корутины. Значит, класс CoroTask также должен удовлетворять интерфейсу Awaitable. Давайте реализуем оба этих интерфейса в классе 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;
};

(Настоятельно рекомендую открыть фоном заглавную картинку этого поста. В дальнейшем это вам сильно поможет.)

Итак, давайте разберемся, что здесь происходит.

1. Заходим в лямбду coroutine и сразу же создаем корутину WokrerActor::readAB. Но после создания этой корутины, не начинаем выполнять ее (initial_suspend == suspend_always), что вынуждает нас прерваться и вернуться к выполнению лямбды coroutine.

2. co_await лямбды проверяет, готов ли результат выполнения readAB. Результат не готов (await_ready == false), что вынуждает ее передать свой контекст в метод CoroTask::await_suspend. Этот контекст сохраняется в CoroTask, и запускается resume корутины readAB

3. После того, как корутина readAB выполнила все нужные действия, она доходит до строки:

co_return std::make_pair(a, b);

в результате чего вызывается метод CoroTask::promise_type::return_value и внутри CoroTask::promise_type сохраняется созданная пара чисел

4. Так как вызвался метод co_return, выполнение корутины подходит к концу, а значит, самое время вызвать метод CoroTask::promise_type::final_suspend. Этот метод возвращает самописную структуру (не забывайте поглядывать на картинку), которая вынуждает вызвать метод final_awaiter::await_suspend, из которого возвращает сохраненный на шаге 2 контекст лямбды coroutine.

Почему мы не могли вернуть здесь просто suspend_always? Ведь в случае initial_suspend этого класса у нас это получилось? Дело в том, что в initial_suspend у нас это получилось потому, что эту корутину вызывала наша лямбда coroutine, и мы в нее вернулись. Но в момент, когда мы дошли до вызова final_suspend, нашу корутину скорее всего продолжали уже из другого стека (конкретно, из лямбды, которая подготовила функция makeCoroCallback), и, вернув здесь suspend_always, мы вернулись бы в нее, а не в метод workCoroProcess.

5. Так как метод final_awaiter::await_suspend вернул нам контекст, то это вынуждает программу продолжить выполнение возвращенного контекста, то есть лямбды coroutine. Так как выполнение вернулось в точку:

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

то мы должны вычленить сохраненный результат, вызвав метод CoroTask::await_resume. Результат получен, передан в переменные a и b, и теперь экземпляр CoroTask уничтожается.

6. Экземпляр CoroTask уничтожился, но что сталось с контекстом WokrerActor::readAB? Если бы мы из CoroTask::promise_type::final_suspend вернули бы suspend_never (точнее, вернули бы то, что на вопрос await_ready вернуло бы true), то в тот момент контекст корутины почистился бы. Но так как мы этого не сделали, то обязанность очищать контекст переносится на нас. Мы же очистим этот контекст в деструкторе CoroTask, на этот момент это уже безопасно.

7. Корутина readAB выполнена, результат из нее получен, контекст очищен, продолжается выполнение лямбды coroutine…

Уф, вроде разобрались. А помните, что из методов ABActor::getAAsync() и подобных мы возвращаем самописную структуру? На самом деле, метод getAAsync также можно превратить в корутину, объединив знания, полученные из реализации классов CoroTask и ActorAwaiter, и получив что-то вроде:

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

но это я уже оставлю для самостоятельного разбора.

Выводы


Как видите, с помощью корутин можно довольно неплохо линеаризовать асинхронный код на коллбэках. Правда, процесс написания вспомогательных типов и функций пока не выглядит слишком интуитивным.

Весь код доступен в репозитории

Также рекомендую для более полного погружения в тему посмотреть эти лекции
.
Большое количество примеров на тему корутин от тогоже автора есть здесь.
И еще можно посмотреть эту лекцию

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (96):

  1. AndDav
    /#21428868

    Почему не захватить this в capture-list лямбды? Тогда весь код внутри вышел бы чуть проще. Но так получилось, что, видимо, лямбда-корутины в компиляторе пока поддерживаются не полностью, поэтому такой код работать не будет.

    Это типичная ошибка работы с корутинами: https://quuxplusone.github.io/blog/2019/07/10/ways-to-get-dangling-references-with-coroutines/#exciting-new-way-to-dangle-a-reference и нет, это не недоделка в компиляторе.

    • svr_91
      /#21428914

      Возможно. Но это конечно очень неинтуитивно. Может, все-таки разрешат сохранять в capture-list в корутинах

      • AndDav
        /#21429144

        Если вы сделаете member-function корутиной вы тоже ожидаете, что поля класса скопируются в coroutine frame?
        Дискуссия о том, чтобы как-то улучшить статус-кво, как я понимаю заглохла: https://github.com/GorNishanov/coroutines-ts/issues/32

        • KanuTaH
          /#21429362

          Начиная с C++17 можно захватывать *this, это именно захват копии класса, созданной через его конструктор копирования.

          • AndDav
            /#21429420

            И как это связано с coroutine frame?

            • KanuTaH
              /#21429534

              А, ну да, тут это не поможет. После первого же co_await обращаться к захваченному *this все равно будет нельзя.

  2. Playa
    /#21428922

    // Фиктивный параметр bool B здесь нужен, так как sfinae не работает не на шаблонных функциях

    Может можно обойтись if constexpr?

    • svr_91
      /#21429130

      Хм, а if constexpr позволит «менять» возвращаемый тип функции?

  3. eao197
    /#21431958 / +1

    Честно попытался прочитать вашу статью. Но с самого начала преследовало ощущение, что вокруг какая-то невесть откуда взявшаяся сложность. Причем какая-то искуственная сложность. Которая берется не из предметной области, а откуда-то еще.


    Но когда дошел вот до этого примера кода:


        template<
            bool B=true,size_t len=sizeof...(T),std::enable_if_t<len!=1 && len!=0 && B, int>=0
        >

    то появилась следующая версия: вы умудряетесь писать сложный код на ровном месте. Поскольку вот это же самое сходу можно записать несколько проще и компактнее:


        template<
            bool B=true, std::enable_if_t<B && 1u < sizeof...(T), int>=0
        >

    В итоге статью до конца все-таки досмотрел и остался под впечатлением, что вы описали какой-то излишне сложный способ решения какой-то выдуманной задачи. Причем эта выдуманная задача тоже какая-то излишне сложная.


    И вот это вот впечатление излишней сложности оставляет негативное ощущение от всей статьи.

    • svr_91
      /#21432454

      Да, здесь я немного пролетел, как мне было уже неоднократно указано.

      Просто этот код я брал из проекта, написанного под C++14, да еще и под разные компиляторы (msvc оказыватеся работает со sfinae немного не так, как gcc). И как-то не подумал, что его можно упростить используя более совершенные возможности.

      Этот код занимает не очень большую часть статьи, думал, никто не будет акцентировать на нем столько внимания :(

    • svr_91
      /#21432460

      А про искуственную сложность, не понял, в чем там искуственность. Можете предложить более простой подход хотябы к некоторым аспектам статьи?

      • eao197
        /#21432680

        А про искуственную сложность, не понял, в чем там искуственность.

        Если бы мне самому это было понятно, то я бы это уже бы выразил в конкретных замечаниях. А так ощущение есть, но откуда оно происходит не ясно.


        Хотя даже если взять ваш первоначальный пример:


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

        то непонятно зачем там все это. Вот обращение к некому ABActor::GetACallback оно зачем? И сама запись getA(ABActor::GetACallback()) она какая-то странная. Как будто вы намеренно выставляете напоказ все кишки своего актора.


        По идее, вам нужно сделать запрос 'GetA' к актору A, а когда он на этот запрос ответит, вам нужно среагировать на ответ. Что, по идее, должно записываться как-то так (если уж у вас такая тяга к коллбэкам):


        send<GetA>(A).then([](int a) {... /* Обработка ответа от A */ });

        Ну или так:


        A.send<GetA>().then([](int a) {... /* Обработка ответа от A */ });

        Без всяких дополнительных ABActor::GetACallback.

        • svr_91
          /#21432776

          Возможно действительно стоило бы написать например через метод then. Но тогда статься бы называлась «пишем акторный фреймворк». Мне хотелось во-первых, обойтись малой кровью, во-вторых, показать идею работы. Я еще в самом начале написал, что не ставлю целью написать полноценный акторный фреймворк. Так что претензия, что «кишик торчат наружу»… Ну, во-первых, не вполне кишки, а во-вторых, «и что»?
          Может быть конечно метод then реализуется несложно, но я просто об этом не подумал. Думал, подход с явным созданием структуры будет более наглядным.

          • eao197
            /#21432824

            Ну, во-первых, не вполне кишки,

            Как раз кишки.


            а во-вторых, «и что»?

            Как минимум, это одна из составляющих впечатления "искуственная сложность".


            Ну и позволю себе дать ссылку на свою статью полуторалетней давности: https://habr.com/ru/post/430672/
            Там сравнивается использование нескольких подходов к решению одной и той же задачи. И я не могу отделаться от мысли о том, что то, что вы называете "акторным" подходом на самом деле есть task-based подход. И вот как раз в примитивной реализации task-based подхода этот самый callback-hell наступает очень быстро.

            • svr_91
              /#21432990

              > что вы называете «акторным» подходом на самом деле есть task-based подход
              Да, возможно. Что-то среднее между акторным и task подходом. Акторный подход, в котором ответчик всегда обязан отдать коллбэк обратно сендеру.

              > Как раз кишки.
              Не понимаю, причем здесь кишки? Мне кажется, наоборот удобно, что можно посмотреть на тип и понять, какие параметры требуются коллбэку. Как это понять в случае Вашего then?
              И к томуже эта структура реализована конкрентым классом акторов. Он вообще может не реализовывать этот объект, и гонять обычные сообщения. Как раз то, что Вы называете «настоящими акторами». Но вот класс захотел и в качестве доп. параметра решил принять некоторую структуру. Причем здесь кишки?

              > И вот как раз в примитивной реализации task-based подхода этот самый callback-hell наступает очень быстро.
              А как тогда решить эту же задачу без task-based подхода? Хранить состояние, запоминать «этот ответ от сервера мы уже получили»? Тоже hell получается, но уже не callback

              • eao197
                /#21433052

                Акторный подход, в котором ответчик всегда обязан отдать коллбэк обратно сендеру.

                Это что-то новенькое.


                Не понимаю, причем здесь кишки?

                При том, что это, как минимум, создает дополнительную сложность для читателя вашей статьи.


                Мне кажется

                А вот мне кажется, что в вашей статье чуть ли не повсюду присутствует искуственная сложность. И я пытаюсь обосновать эти свои ощущения.


                Как раз кишки наружу — это и есть один из источников сложности. Пользователь акторного фремворка (даже слепленного на коленке) не должен погружаться в детали его работы. А у вас пользователь вынужден погружаться в это. А читатель вынужден задавать себе вопрос: а зачем в это погружаться, какой в этом смысл?


                удобно, что можно посмотреть на тип и понять, какие параметры требуются коллбэку. Как это понять в случае Вашего then?

                Вот у вас сигнатура getA:


                void getA(const GetACallback &callback);

                Глядя на нее нужно приложить некотрые усилия для того, чтобы понять, что callback здесь — это не параметр для выполнения самой операции getA, а нечто, что целевой актор должен вызвать для того, чтобы отдать кому-то результат выполнения операции getA.


                Хотя куда естественней было бы видеть интерфейс вида:


                int getA();

                Тогда видно, что операция getA не требует для своей работы никаких входных параметров и возвращает она int.


                А в метод then должен был бы передаваться функтор, который ждет на вход int.


                И все оказывается достаточно прозрачно.


                Хранить состояние, запоминать «этот ответ от сервера мы уже получили»? Тоже hell получается, но уже не callback

                Так вы в итоге и пришли к тому же самому "этот ответ мы уже получили". Просто записали это в виде линейного кода:


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

                Могли бы приблизительно это же самое и на CSP-ных каналах получить.

                • svr_91
                  /#21433160

                  > а зачем в это погружаться, какой в этом смысл?
                  Потому что на деталях этого строятся корутины. Я потому и написал свой акторный фреймворк с нуля, чтобы читатель видел, как оно все работает. Без понимания одного не понять другого.
                  Читатель все равно должен держать в голове всю структуру фреймворка, event loop, треды и т.д. Ну по крайней мере я должен, я иначе не пойму. Кто вызовет корутину, когда результат выполнения «станет доступным»? Исполнится ли корутина в том же треде, в котором была создана? Если Вы сможете это объяснить лучше чем я, то welkome как говорится, с удовольствием прочту Вашу статью. Собственно и написал я эту статью от безысходности, так как ничего подобного на хабре не было (есть нечто подобное в виде видеолекций, но все же).
                  Это мое понимание того, как должно выглядеть объяснение материала. Если у Вас другое понимание, то спорить не буду, попробуйте объяснить по другому.

                  > Вот у вас сигнатура getA:
                  Ну этож коллбэк. Если читатель не знает, что такое коллбэк, ну чтож… Вот берем boost::asio… Ой, смотрите, и там коллбэки.

                  Можно ли это переписать с помощью then? Может быть и можно, а смысл? Еще раз говорю, что у меня не было цели написать свой акторный фреймворк.

                  > Могли бы приблизительно это же самое и на CSP-ных каналах получить.
                  Дак мы акторы или CSP-ные каналы обсуждаем?

                  • eao197
                    /#21433212

                    Давайте я вам еще раз объясню свою точку зрения:


                    • вашу статью тяжело читать потому, что практически везде по тексту возникает ощущение, что автор не может сделать просто. Ну вот тупо не может сделать просто и делает все сложно;
                    • вы спросили что именно сложно и я попытался вам донести некоторые соображения;
                    • вы эти соображения воспринимаете не как попытки объяснить откуда берется ощущение чрезмерной сложности, а как попытки, ну не знаю, объяснить вам "как нужно было делать";
                    • тогда как у меня нет цели рассказать в чем вы не правы.

                    Соответственно, если вы не желаете (не можете) воспринимать написанное мной именно как попытку объяснить происхождение ощущения искусственной сложности, то вряд ли я еще чем-то могу помочь.


                    Ну и да. У вас и не акторный подход, и не CSP. Скорее криво написанный и непонятный сходу task-based подход, который вы не смогли простым образом переложить на короутины из C++20. Отсюда и негативное (лично у меня) впечатление от статьи. И именно: автор в принципе не может сделать просто, поэтому делает чрезмерно сложно.

                    • svr_91
                      /#21433288

                      Я бы желал воспринять написанное Вами, но не могу пока найти, что воспринять. Некрасивый sfinae? Ок, мы это уже прошли, к томуже некоторые комментаторы предложили решение получше Вашего. Введение then? А зачем это нужно? Только для того, чтобы ввести?

                      То что Вы видите, это мой подход к решению подобных задач. Может, он не совсем правильно «акторный», но тем не менее это такой подход и он мне даже нравится. Я по крайней мере не вижу другого способа решить такую задачу. Точнее, способ есть — это fibers с futur-ами, можно попробовать переложить такую задачу на нее, но не факт, что это вообще получится, и если получится, то проще.

                      Ноги у такой задачи растут много откуда. То же сетевое взаимодействие (boost.asio, libev) основано на примерно таком же подходе. Можно было бы показать на этих примерах, но я решил абстрагироваться от конкретного сетевого фреймворка и написать велосипед.

                      Как понимаю, основная претензия ко мне в том, что я неаккуратно назвал это «актором». Ну, Вы, как разработчик акторного фреймворка конечно лучше знаете, но может уже стоит перестать цепляться к словам?

                      • eao197
                        /#21433328 / +1

                        Некрасивый sfinae?

                        Это sfinae является демонстрацией того, что вы не можете сделать просто, поэтому делаете сложно на ровном месте. Это не претензия. И уж тем более не попытка показать, как сделать лучше.


                        Как понимаю, основная претензия ко мне в том, что я неаккуратно назвал это «актором».

                        Нет, не понимаете. Вы сделали что-то чрезмено сложное для подобной статьи. Из-за чего статья ваша практически по всей ее протяженности воспринимается с одним и тем же вопросом "ну почему так сложно-то?"


                        А то, что вы понятия к месту и не к месту путаете, так это, наверное, всего лишь следствие.


                        То же сетевое взаимодействие (boost.asio, libev) основано на примерно таком же подходе. Можно было бы показать на этих примерах

                        Ну так и показали бы. Хотя бы на примере работы с тем же Boost.Asio. Типа на Asio с коллбэками мы делаем вот так, а на C++20 короутинах будет вот так. Можно было бы хотя бы о чего-нибудь предметного оттолкнуться. А не от вымышленных бесполезных примеров с непонятным смыслом.

                        • svr_91
                          /#21433462 / -1

                          > Это sfinae является демонстрацией того, что вы не можете сделать просто, поэтому делаете сложно на ровном месте.
                          Ну нашли у меня ошибку. Поправили. Спасибо. Что дальше то ее раскапывать? Если есть что еще сказать, то сказали бы.

                          > Ну так и показали бы. Хотя бы на примере работы с тем же Boost.Asio
                          Я решил исповедовать «абстрактный» подход. Думал, что программисты поднаторели в абстракциях, и смогут перенести свою ситуацию на мой код. Но видимо Вам подавай только конкретику. Шаг влево, шаг вправо — расстрел

      • eao197
        /#21441930

        А про искуственную сложность, не понял, в чем там искуственность.

        Ok, в результате спора в комментариях я лучше понял что именно меня смущает в вашей статье. Попробую эти соображения сформулировать.


        Первое. Необходимость ручного вызова GetACallback/GetBCallback/etc. Дело в том, что когда речь заходит об акторах, то для акторов thread-safety должна обеспечиваться автоматически самим фреймворкам. Т.е. когда актор A что-то осылает актору B, то актор B гарантированно будет обрабатывать входящее сообщение на своем (и только своем контексте). Тоже самое происходит и с ответом от B к A: этот ответ так же гарантировано будет обрабатываться строго на контексте A. И для обспечения этих свойств пользователю акторного фреймворка делать ничего не нужно.


        У вас же в примере thread-safe полуавтоматически обеспечивается только от A к B. Тогда как для ответного сообщения от B к A требуется ручная работа. Именно поэтому программисту вручную приходится вызывать GetACallback при вызове getA.


        Как по мне, было бы лучше, если бы getA в таком случае имел бы вид:


        A.getA(this, [this](auto a) {...});

        вместо:


        A.getA(GetACallback(this, [this](auto a){...}));

        Именно вот эта потребность в ручном вызове GetACallback и выглядит для меня как выставление "кишок" наружу. Хотя от этих кишок можно было бы легко избавится делая вызов GetACallback прямо внутри getA.


        Второе. Мне не хватило раскрытия того, что именно вы подразумеваете под асинхронностью.


        Давайте представим, что есть некая сущность S, предоставляющая тот или иной сервис. И есть сущность C, которая пользуется этим сервисом.


        Сущность C хочет воспользоваться услугами S в асинхронном режиме. Т.е. сделать что-то типа:


        void C::do_something() {
           async(some_context, [&S]{ S.get_service(); });
           ... // Продолжаем работу пока где-то еще S выполняет вызов get_service.
        }

        Здесь C асинхронно обращается к S.


        Но вот собственно работа S::get_service может быть как синхронной (например, get_service выполняет какую-то CPU-bound активность типа шифрования данных или проверки подписи, перекодирования изображений и т.д.), так и асинхронной (например, get_service вызывает неблокирующую операцию записи данных в сокет, которая непонятно когда закончится).


        И если S::get_service — это синхронная операция, то возврат ее результата через лямбду — это как-то странно. Можно, конечно, но странно. Тут бы что-то вроде future бы подошло. И код бы имел вид:


        void C::do_something() {
           auto f = async(some_context, [&S]{ return S.get_service(); });
           ... // Продолжаем работу пока где-то еще S выполняет вызов get_service.
           f.get();
        }

        Ну или запись с then была бы вполне уместной: S.ask<get_service>().then(this, [](auto result) {...}).


        А вот если вы говорите о случаях, когда сама S::get_service — это асинхронная операция, то тогда вызов callback-а вопросов не вызывает.


        Так что если бы вы сделали упор на то, что в первую очередь важна асинхронность самого S::get_service, а не асинхронность обращения от C к S, то мне лично ваш замысел был бы лучше понятен. А так непонятно, на какую именно асинхронность нужно обращать внимание: на то, что C асинхронно обращается к S или на то, что S выполняет свою работу на собственном контексте исполнения только асинхронно.


        Третье. Очень нехватает какой-то осмысленной работы внутри актора, который требует у abActor выполнения операций getA, getB и т.д. Т.е. если бы вы сами операции обозвали бы как-то предметно, типа receiveMessage, decryptMessage, storeMessage, да еще и эти операции выполнялись бы разными акторами, да еще если бы результаты этих операций хоть как-то бы анализировались бы… Типа:


        mqClient.receiveMessage(this, [this](auto msg) {
           log_incoming(msg);
           if(encryptedMessage(msg)) {
             ++encrypted_stats;
             crypto.decryptMessage(msg.payload(), this, [this, &msg](const auto decrypted) {
               log_decrypted(decrypted);
               msg.setPayload(decrypted);
             });
           }
           db.storeMessage(msg, this, [this](auto result) {...})
        });

        Ну или что-то в этом духе.


        То ваш пример воспринимался бы лучше, т.к. было бы понятно и сколько акторов участвует, и почему важно callback-и вызвать на контексте инициатора асинхронной операции.

        • mayorovp
          /#21442034

          Но вот собственно работа S::get_service может быть как синхронной (например, get_service выполняет какую-то CPU-bound активность типа шифрования данных или проверки подписи, перекодирования изображений и т.д.), так и асинхронной (например, get_service вызывает неблокирующую операцию записи данных в сокет, которая непонятно когда закончится).

          Ну, этот момент как раз понятен же. Если S — это не только сервис, но ещё и актор, то любую операцию, хоть синхронную, хоть асинхронную, он может выполнять только в собственном потоке. Для остальных потоков она будет выглядеть асинхронной.


          И если S::get_service — это синхронная операция, то возврат ее результата через лямбду — это как-то странно. Можно, конечно, но странно. Тут бы что-то вроде future бы подошло.
          А вот если вы говорите о случаях, когда сама S::get_service — это асинхронная операция, то тогда вызов callback-а вопросов не вызывает.

          А вот это как раз очень странные утверждения. Хотя бы потому что


          1. future замечательно подходит в обоих случаях;
          2. future + then вместе дают точно такой же callback, просто чуть более кружным путём, т.е. задачи "избавиться от колбеков совсем" future не решает.

          • eao197
            /#21442088

            Если S — это не только сервис, но ещё и актор, то любую операцию, хоть синхронную, хоть асинхронную, он может выполнять только в собственном потоке.

            Акцент на то, может ли S::get_service выдать результат на рабочем потоке актора S сразу (т.е. синхронно) или нет. Если может, то у S::get_service может быть сигнатура вида result_type get_service().


            Если же get_service и на контексте S должна работать асинхронно (т.е. возврат из get_service произошел, а результат еще неизвестен), то сигнатура S::get_service будет какой-то такой: void get_service(result_handling_callback).


            Грубо говоря, можем ли мы тупо написать:


            auto f = async(context, [&]{ return S.get_service(); });

            или не можем.


            future замечательно подходит в обоих случаях;

            Я не думаю, что future хорошее решение для случая, когда S::get_service на контексте S асинхронный и имеет сигнатуру вида void get_service(result_handling_callback).


            future + then вместе дают точно такой же callback, просто чуть более кружным путём, т.е. задачи "избавиться от колбеков совсем" future не решает.

            Речь не про решение, а про восприятие текста статьи и примеров в ней. Если S::get_service исполняется на контексте S синхронно, тогда future.then вполне себе хорошее решение и может рассматриваться вместо наколеночного фреймворка описанного в статье. Если же S::get_service на контексте S асинхронно, то тогда сразу понятно для чего автор начал велосипедить свой фреймворк.

            • mayorovp
              /#21442180

              Я не думаю, что future хорошее решение для случая, когда S::get_service на контексте S асинхронный и имеет сигнатуру вида void get_service(result_handling_callback).

              Разумеется, сигнатура должна быть вида future<void> get_service(). А потребителю должно быть вообще без разницы, синхронная там на стороне сервиса операция или асинхронная.

              • eao197
                /#21442212

                сигнатура должна быть вида future<void> get_service()

                Скорее future<some_result> get_service().


                А потребителю должно быть вообще без разницы, синхронная там на стороне сервиса операция или асинхронная.

                А вот при чтении статьи мне лично не понятно, за какую сторону "болеть": за C, который просто хочет вызвать асинхронно S::get_service, за реализацию S::get_service, за обе стороны сразу.

                • mayorovp
                  /#21442238

                  Сопрограммы дают возможность "по-болеть" за обоих.

                  • eao197
                    /#21442260

                    Как раз этого я из статьи и не увидел.

                    • mayorovp
                      /#21442284

                      Да, это как раз недостаток поста.

                    • svr_91
                      /#21442336

                      Этот пример показывает, что код на корутины можно переписать частично. Не обязательно перерефакторивать весь проект, чтобы заюзать возможность использовать корутины. Если нам нужна в какойто функции корутина, мы пишем обертки и используем.

                      Другой вопрос в том, как много этих оберток нужно написать. Сами классы для работы корутин — CoroTask, Awaiter, Resumable пишутся один раз и их можно использовать везде. Но для того, чтобы иметь возможность вызывать «акторный» метод из существующего «актора», для каждого такого метода придется писать доп. обертку. Насколько это применимо? Насколько это удобно в конкретном коде? Это решать уже читателям статьи

        • svr_91
          /#21442198

          Спасибо. Вот это — примерно то, что я ожидал от Вас в первом ну или хотябы во втором сообщении. Давайте теперь более предметно разберем

          1) Про GetACallback. В принципе понятно. Я тоже, когда работал над этим примером, думал, стоит ли тащить эту обертку над коллбэком или сделать предложенным Вами методом. Склонился к обертке. Склонился потому, что она обеспечивает более «надежный», хоть и более многословный код. Я правда думал, стоит привносить эту лишнюю надежность в обучающую статью или не стоит, и решил, что хуже не будет. Заодно отсечет лишние вопросы, которые лично я бы автору статьи в таком случае задал.

          Ну тоесть ответ такой, что эта обертка просто переносит контроль надежности кода из одного места в другое. Обидно, что из-за этого куска кода развернулась такая полемика. Мне казалось, что я наоборот «изобрел» что-то такое, что можно было бы взять на вооружение.

          2) Вот как раз в моем понимании акторы — это всегда асинхронность. Сейчас мы конечно опять начнем полемику о том, что представляют из себя акторы, но я всеже рискну.
          Можно ли было сделать этот метод синхронным? Да, но тогда его можно было бы вызвать из разных потоков. Но в этом случае, нам пришлось бы оборачивать среду этого метода в примитивы синхронизации.
          Лично я считаю, что программист должен выбирать между акторным подходом, но без мьютексов, или мьютексы, но не-акторный подход. Иначе получается так, что мы огребаем все недостатки как мьютексов, так и акторов, не приобретая никаких достоинств.

          Конечно, в реальности все может быть намного сложнее, и в какойто момент для предотврощения распростронения callback hell может оказаться необходимо переделать метод с асинхронного на синхронный, привнеся в код ненавистные мьютексы. Но это обсуждение точно не для обучающей статьи.

          3) Про future. Да, такой вариант есть. Но есть как минимум 2 момента — во-первых, коллбэки никуда не пропадают, во-вторых, нужен метод future.then. Но у стандартных future нету метода then. Вроде бы его обещали преподнести как раз в C++20 (но сейчас я его там уже чтото не вижу), но зачем, если у нас и так там уже появятся корутины?

          4) Про осмысленный пример. Ок, принято. Я конечно не считаю, что пример должен быть настолько осмысленным, как Вы привели, но признаю, что нормальной осмысленности мне всеже не хватило.

          • eao197
            /#21442290

            2) Вот как раз в моем понимании акторы — это всегда асинхронность. Сейчас мы конечно опять начнем полемику о том, что представляют из себя акторы, но я всеже рискну.

            Акторы — это асинхронность. Но на акторах традиционно это решается по-другому. Как-то так:


            void C::do_something() {
               send<get_service>(S, this);
               ...
               receive([this](service_result result) {...});
            }
            ...
            void S::get_service(Actor & reply_to) {
               ...
               send<service_result>(reply_to, ...);
            }

            Так что если бы у вас были традиционные акторы, то вопросов по поводу асинхронности не было бы, т.к. там асинхронность выглядит иначе.


            А вот с вашими callback-ами именно что пришлось разбираться с тем, на какую именно асинхронность нужно обращать внимание.

          • eao197
            /#21442326 / +1

            3) Про future. Да, такой вариант есть. Но есть как минимум 2 момента — во-первых, коллбэки никуда не пропадают, во-вторых, нужен метод future.then. Но у стандартных future нету метода then.

            Для статьи вместо наколеночного акторного фреймворка вы могли бы навелосипедить эти самые продвинутые future с then. И, поскольку эта концепция достаточно хорошо известна, то исходные примеры с future.then могли бы оказаться более показательными. Т.к. callback hell все равно бы присутствовал. Но хотя бы было сходу понятно что происходит. А так приходится глубоко погружаться в особенности вашего фреймворка.

            • svr_91
              /#21442358

              Это мы уже когдато обсуждали. Писать future.then для того, чтобы написать future.then для этой статьи мне кажется слишком. Одна из целей почти любой статьи «напугать» читателя неправильным подходом, и показать красивый «правильный» подход. Добавление future еще больше напугать читателя не смогло бы. И помочь решить проблему тоже. И поэтому смысла вводить я не вижу.

              В чем смысл вводить future, чтобы тутже от него отказаться?
              Если хочется иметь красивый линейный код в варианте с коллбэками, то его несложно устроить и без всяких future.

              • eao197
                /#21442378

                В чем смысл вводить future, чтобы тутже от него отказаться?

                Чтобы показать, что короутины делают код еще более прямолинейным и обозримым.


                Если хочется иметь красивый линейный код в варианте с коллбэками, то его несложно устроить и без всяких future.

                Интересно было бы посмотреть.


                Ну и если это так "несложно", то зачем тогда браться за короутины?

                • svr_91
                  /#21442414

                  auto print = [](int a, int b) {
                      std::cout << a << b;
                  };
                  auto receivedNewB = [print](int a) {
                      abActor.getB(std::bind(print, a, _1));
                  };
                  auto receivenNewA = [receivedNewB]() {
                      abActor.getA(receivedNewB);
                  };
                  auto saveAB = [receivenNewA](int a, int b) {
                      abActor.saveAB(a, b, receiveNewA);
                  };
                  ...
                  


                  Чуть подредактировал, вроде лучше стало

                  • eao197
                    /#21442480

                    Тут есть два "но":


                    1. Вложенность никуда не ушла. Вы лишь сделали запись лаконичнее. Но если в ней придется разобраться, то все равно придется покурить бамбук на тему того, что и откуда вызывается.


                    2. Здесь же как раз нет заботы о том, на каком контексте будут вызываться коллбэки для обработки результатов. А эта забота и была одной из сложностей в статье.


  4. commenter
    /#21432426

    Простые вопросы — а зачем вообще нужна такая адская вложенность callback-ов? Зачем их всех приостанавливать и запускать в произвольном порядке? Что это всё даёт на практике? К чему вся эта явная сложность? Что она упрощает? Может она только усложняет?

    Скажем в однопоточном браузере нужны средства для работы с сервером, а поскольку браузер однопоточный, вешать всё на время ожидания ответа сервера негуманно. В браузере можно было бы всё реализовать на callback-ах, но это иногда выглядит плохо читаемо, поэтому там логично видеть await и тому подобное. Но зачем такой же подход тянуть в многопоточный C++? Ну и результирующая сложность реализации такого подхода очевидна из статьи.

    • svr_91
      /#21432442

      Есть «акторный» подход к решению задач. Это один из подходов, не утверждается, что он единственно верный. Но он есть. И зачастую код с акторным подходом превращается в такую «мешанину» коллбэков.

      На самом деле, эту «мешанину» коллбэков тоже можно линеаризовать, не прибегая к помощи корутин, но можно поступить и более радикальным способом.

      Кроме «акторного» подхода, можно попробовать использовать другие подходы к решению задачи. Вообще, лично я считаю (но это мнение ничем не подкреплено, лишь диванная аналитика), что писать подобный код на корутинах изначально — контрпродуктивно. Лучше попробовать например boost fibers. Но от уже существующего кода избавиться сложнее. Но можно, написав не очень простые обертки, линеаризовать небольшую часть уже существующего кода или писать новые части с использованием такого подхода.

      • eao197
        /#21432688

        Есть «акторный» подход к решению задач. Это один из подходов, не утверждается, что он единственно верный. Но он есть. И зачастую код с акторным подходом превращается в такую «мешанину» коллбэков.

        Акторный подход как бы базируется на отсылке сообщений акторам. А у вас он именно что "акторный", т.к. вместо взаимодействия на сообщениях вы вручную диспетчируете вызовы конкретных коллбэков (с ручным же провязыванием этих коллбэков в цепочки).

      • commenter
        /#21432708

        Может попробовать выбрать пример, в котором проявляется простота именно после перехода к «акторному» варианту? Даже на статью может потянуть.

        Я, имея немалый опыт, почти не сталкивался с подобными примерами. А если сталкивался, то почему-то они в памяти не осели. Обычно всё решается другими методами.

        • svr_91
          /#21434606

          Ну если под «акторным» вариантом понимать мой вариант (не все согласны, что мой вариант настоящий акторный), то я бы предложил любой асинхронный код. Который требует коллбэки.
          Мне кажется, если вы сталкивались с асинхронным кодом, то примерно понимаете о чем речь. А если сталкивались и не понимаете, то это уже мне интересно, как вы разруливали этот код :)

          • commenter
            /#21436114

            Я обрабатывал асинхронность сообразно ситуации. То есть иногда достаточно одного callback-a (и ради него одного ничего не надо накручивать), иногда делал вызов синхронным с возвратом того результата, который отдаётся callback-у, иногда просто поток отдельный запускал. Но всегда было понимание контекста — зачем я это делаю. Контекст задаёт ограничения, которых не задаёт абстрактный пример. Поэтому абстрактный пример как не напиши — всегда можно сказать, что на самом-то деле могут быть вот такие ограничения, и тогда данное решение будет подходящим. А вот с заданным контекстом никто уже не отвертится, потому что ограничения уже задекларированы.

            Поэтому я и говорил про более конкретный пример с понятным контекстом.

            • svr_91
              /#21436598

              > То есть иногда достаточно одного callback-a
              Это очень странно. Обычно асинхронный код распространяется по исходникам как короновирус, и одним методом тут как правило отделаться не получается
              > иногда делал вызов синхронным
              Вы же понимаете, что это не очень хорошо?
              > иногда просто поток отдельный запускал
              Тоже метод, но думаю, вы понимаете его ограничения.

              > А вот с заданным контекстом никто уже не отвертится, потому что ограничения уже задекларированы.
              А потом приходит начальник и говорит «все поменялось, надо исправить». Знаем, проходили. Поэтому у меня не было здесь цели показать, как работает такое решение в проде. У меня была цель дать теоретическое введение в работу корутин

              Давайте так. Что сподвигло меня написать эту статью. В языке C++ постепенно появляются корутины. Есть один очевидный способ использования корутин — создание генераторов. В принципе, я понимаю этот подход, и понимаю, чем он хорош. Но неужели целый нетривиальный механизм затащили в язык только для того, чтобы писать генераторы?

              Я задумался над этим вопросом, и у меня возникла идея, что одной из возможностей корутин могла бы стать линеаризация существующего асинхронного кода. Что это означает? Под асинхронным кодом в данном контексте я подразумеваю код с использованием коллбэков. И при использовании коллбэков рано или поздно разработчики утыкаются в так называемый callback hell. Замедьте, этот термин не я придумал, эта ситуация известна куче программистов по всему миру. И я решил задаться вопросом, можно ли исправить этот callback hell корутинным подходом.

              Что необходимо, чтобы организовать callback hell? Для этого нужны 2 вещи — собственно коллбэк и так называемый event loop, который «дернет» коллбэк при наступлении нужного события (при готовности ответа например). Я не знаю других механизмов, как это еще можно сделать (можно запустить отдельный поток, и в нем заблокировавшись ждать результат, и дождавшись, вызвать коллбэк), если вы знаете, то расскажите, но по крайней мере этот подход самый популярный. Я решил продемонстрировать этот подход, написав простенький акторный фреймворк. При этом у меня не было цели написать полноценный акторный фреймворк, что почемуто показалось eao197, акторный фреймворк служит лишь для демонстрации ситуации callback hell-а.

              После того, как я продемонстрировал callback hell, я показал способ, как его исправить. При этом, у меня не было цели написать полностью готовое к продакшен решение. Даже сейчас там не все гладко с точки зрения продакшен-кода: нет проверок, нет обработки исключений. Я лишь хотел дать общее понимание, как работать с корутинами в данной ситуации. Для использования данного подхода на проде придется допиливать мое решение, в том числе приспосабливать его к конкретному фреймворку. Я полагаю, что это будет несложно после моего изложения, если я не прав, ну чтож, тогда это будет действительно справедливая критика.

              Но еще раз, у меня не было цели показать в действии акторный подход. У меня была цель найти единомышленников, кто сталкивался с callback hell (а я сталкивался) и попробовать предложить им решение этой проблемы

              • eao197
                /#21436628

                У меня не было желания продолжать обсуждение, но раз уж вы сами меня упомянули, то вот почему я считаю, что использование "акторов" и "акторного подхода" в вашей статье неуместно: "[prog.actors] Почему я не считаю упомянутых в статье на Хабре акторов настоящими акторами".


                И, повторюсь, проблема вашей статьи в том, что вы искусственно создаете ненужную сложность на пустом месте. И эта сложность мешает воспринимать то, что вы хотите рассказать.


                Если вашей целью было показать callback hell, то и нужно было просто показывать callback hell, без приплетания "акторного" подхода, да еще с такой кривой интерпритацией и странной реализацией (странной даже со скидкой на то, что это всего лишь демонстрация). Пример этого самого callback hell можно увидеть по той же ссылке выше. Без всяких наколеночных акторных "фреймворков".

                • svr_91
                  /#21437388

                  И где у вас там callback hell? По ссылке выше не увидел.

                  • eao197
                    /#21437568 / -1

                    Да вот же:


                    thread_executor contextA; // Контекст для операций над A.
                    ExecutionContextBoundDataObjectA A(contextA); // Это "актор" A.
                    
                    schedule(A, [&A] {
                       auto a = A.getA();
                       schedule(A, [a, &A] {
                          auto b = A.getB();
                          schedule(A, [a, b, &A] {
                             A.saveAB(a - b, a + b);
                             schedule(A, [&A] {...});
                          });
                       });
                    });

                    Типичный. Но читается проще, т.к. нет всех тех условностей, которые вы у себя в статье нагородили.

                    • svr_91
                      /#21438500 / -1

                      Это код? Это не код. Оно даже в функцию не обернуто. Не говоря уже о том, что я не понимаю, что делает функция schedule

                    • svr_91
                      /#21438808 / -1

                      Давайте всетаки доведем обсуждение до логического конца.


                      Поймите, я специально привел в статье полный код, провоцирующий callback hell. Это не баг, а фича так сказать. Вы же пока привели какойто кусок кода, который и вставить пока непонятно куда. И я также не понимаю, как работает функция shedule

                      • eao197
                        /#21439124 / -1

                        Давайте всетаки доведем обсуждение до логического конца.

                        Подозреваю, что это вряд ли возможно.


                        Вы же пока привели какойто кусок кода, который и вставить пока непонятно куда.

                        Это аналог вот этого вашего фрагмента из самого начала вашей же статьи, увидев который неподготовленный читатель так же может заявить "Это код? Это не код. Оно даже в функцию не обернуто":


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

                        И я также не понимаю, как работает функция shedule

                        Она получает два параметра:


                        • объект, из которого можно извлечь execution_context;
                        • функтор, который нужно вызвать на execution_context из первого параметра.

                        Функция schedule извлекает из первого параметра execution_context. У этого execution_context есть очередь, в которуе schedule ставит заявку на выполнение функтора (который передается вторым параметром).


                        В данном примере используется некий thread_executor, который реализует execution_context в виде отдельной нити ОС.


                        Т.е. здесь принцип такой же, как в вашем наколеночном "акторном" фреймворке, но без всякой лишней шелухи, только сама суть.

                        • svr_91
                          /#21439370

                          > Это аналог вот этого вашего фрагмента из самого начала вашей же статьи
                          В начале статьи у меня было интро для превличения внимания, чтобы показать что из чего получается. Дальше шел полный разбор этого кода с реализацией.

                          А так как у вас полной демо-реализации не заметно, то я не представляю, как сравнить сложность двух подходов, на чем Вы настаиваете.
                          Ну ок, я примерно понял, как это работает. Давайте я тогда напишу небольшой класс, чтобы подсчитать сложность в строках кода

                          class Work: public Actor {
                              std::optional<std::pair<int, int>> cache;
                          public:
                              void work() {
                                   if (cache.has_value()) {
                                         std::cout << cache->first << cache->second;
                                         return;
                                   }
                                   int a = A.getA();
                                   shedule(A, [this, a](){
                                         int b = A.getB();
                                         cache = std::make_pair(a, b);
                                   });
                              }
                          }
                          

                          Это Ваш вариант. А вот мой вариант:
                          class Work: public Actor {
                              std::optional<std::pair<int, int>> cache;
                          public:
                              void work() {
                                   if (cache.has_value()) {
                                         std::cout << cache->first << cache->second;
                                         return;
                                   }
                                   A.getA(GetACallback([this](int a) {
                                         A.getB(GetBCallback([this](int b) {
                                                 cache = std::make_pair(a, b);
                                         });
                                  });
                              }
                          }
                          


                          Мне кажется, по количеству строк ровно то же самое, в чем сложность то?

                          (PS минусую не я, у меня вообще нету права голосовать)

                          • eao197
                            /#21439452

                            Если стоит цель свести количество строк к минимуму, то "типа мой" вариант можно вообще записать вот так:


                                void work() {
                                     if (cache.has_value()) {
                                           std::cout << cache->first << cache->second;
                                           return;
                                     }
                                     shedule(A, [this, a = A.getA()](){
                                           cache = std::make_pair(a, A.getB());
                                     });
                                }

                            в чем сложность то?

                            Сложность в том, что в вашем примере читателю нужно разбираться вот с этой мешаниной из непонятно что делающих методов:


                            A.getA(GetACallback([this](int a)
                            A.getB(GetBCallback([this](int b)

                            Для демонстрации callback hell-а вызовы GetACallback/GetBCallback не нужны.

                            • svr_91
                              /#21439562

                              Вы даже не заметили 2 ловушки, оставленные мной в этом задании.


                              1) В каком потоке (то, что Вы называете execution_context) заполняется и используется кэш?
                              2) в каком потоке вызывается getA?

                              • eao197
                                /#21439588

                                С чего вы решили, что я делал какое-то ваше задание? Это во-первых.


                                Во-вторых, для демонстрации проблемы callback hell это не имеет значения.


                                В-третьих, зачем мне продолжать этот разговор, если вы все еще ничего не поняли?

                                • svr_91
                                  /#21439628

                                  > С чего вы решили, что я делал какое-то ваше задание?
                                  Ну ок, описался я. Не задание. Код.

                                  > Во-вторых, для демонстрации проблемы callback hell это не имеет значения.
                                  А что имеет? Ок, давайте рассмотрим Ваш первоначальный пример

                                  schedule(A, [&A] {
                                     auto a = A.getA();
                                     schedule(A, [a, &A] {
                                        auto b = A.getB();
                                        schedule(A, [a, b, &A] {
                                           A.saveAB(a - b, a + b);
                                           schedule(A, [&A] {...});
                                        });
                                     });
                                  });
                                  


                                  Чем он будте отличаться от того, что если его упростить таким образом:
                                  schedule(A, [&A] {
                                     auto a = A.getA();
                                     auto b = A.getB();
                                     A.saveAB(a - b, a + b);
                                  });
                                  

                                  Ой, смотрите, callback hell пропал. Примера не получилось.

                                  В третьих, чего именно я не понял?

                                  В четвертых, вы любите упоминать какието «кишки». А метод schedule не является из Вашего примера «кишками»?

                                  • eao197
                                    /#21441172

                                    Чем он будте отличаться от того, что если его упростить таким образом:

                                    Этот пример всего лишь калька с того, что вы сами показали в своей статье, а именно — два раздельных обращения getA и getB к одному и тому же "актору":


                                    abActor.getA(ABActor::GetACallback([this](int a) {
                                            abActor.getB(ABActor::GetBCallback([a, this](int b) {

                                    Я хз почему вы так сделали. Но раз сделали, то это же я повторил и у себя.


                                    Но если для вас это повод поймать собеседника на слове, то давайте перепишем то, что вам не понравилось, вот так:


                                    schedule(A, [&A, this] {
                                      schedule(*this, [&A, this, a = A.getA()] {
                                        schedule(A, [&A, this, a] {
                                          schedule(this, [&A, this, a, b = A.getB()] {
                                            schedule(A, [&A, this, a, b] {
                                              A.saveAB(a - b, a + b);
                                              schedule(A, [&A] {...});
                                            });
                                          });
                                        });
                                      });
                                    });

                                    Смысла здесь больше не станет, но зато из кода явным образом будет видно, какие действия куда распределяются.


                                    А метод schedule не является из Вашего примера «кишками»?

                                    Нет, здесь schedule — это тот минимум, без которого статья теряет смысл. Т.к. вам нужно показать, что заданные пользователем лямбды отправляются на работу на какой-то другой контекст. А их результат может быть получен когда-то в будущем.


                                    Тогда как вызовы GetACallback — это и есть кишки вашего наколеночного "акторного" фреймворка, в которые вы обязаны погрузить читателя, иначе ваш механизм диспетчеризации заявок не будет работать.

                                    • svr_91
                                      /#21441290

                                      В моем коде есть несколько гарантий, который даются бесплатно с точки зрения проектирования. Одна из них — это то, что внутри лямбды не будет гонок данных, как бы не стремился к этому программист. В Вашем же коде гонку данных получить как нечего делать. Я Вам это выше продемонстрировал в 5-ти строках кода.

                                      Теперь же, когда вы переписали свой код эквивалентно моему коду, согласитесь, совсем не очевидно, что стало проще, чего Вы так хотели добиться. К томуже, одна мелкая ошибка там все равно есть.

                                      Да, я понимаю, что Ваш подход тоже имеет место быть. Возможно, он лучше в ситуации, когда программисту нужно иметь прямой доступ к настройкам перфоманса, когда каждая наносекунда выполнения на счету. Поэтому я не буду критиковать Ваш код (хотя я все равно не понимаю, где такое может потребоваться). Но и Вы, пожалуйста, не критикуйте мой. В моем коде просто банально другие гарантии надежности. Ну тоесть критикуйте, конечно, но предметно.
                                      Вот если я захочу покритиковать Ваш код, я спрошу «а что Вы будете делать с приведенной выше гонкой данных», например. Могу еще немало примеров для критики набросать.

                                      Вы с таким же успехом могли спросить меня «какого хрена вы исползуете непонятные корутины вместо вызова setContext, getContext», и как при этом я должен был бы Вам отвечать?

                                      Про «теряет смысл, не теряет смысл». Я также не понял. И мой, и Ваш подход теряет смысл демонстрации callback hell, если в моем случае удалить из него Callback, а в вашем — shedule. Так что я не вижу разницы.
                                      Но зато в моем подходе я могу показать «неотвратимость» наказания. То есть я говорю читателям — «смотрите, есть такой код, от него не отвертеться». Причем я не поясняю, откуда такой код взялся. Может, это легаси, может наоборот правильная архитектурная задумка. Не отвертеться и все. С этим надо жить. И это не требует пояснений. Это как использование асинхронного http например. От него не отвертеться. Да, можно полностью перерефакторить код на использование синхронного http, но думаю Вы и читатели понимают некоторые проблемы этого. А так — вот у вас есть асинхронный http, старый код вы трогать не можете, новый пилите. Вот вам таска, идите выполняйте. С этим думаю каждый знаком.

                                      А в Вашем коде нужно долго и упорно пояснять читателю, откуда здесь взялся shedule, почему от него нельзя избавиться. В вашем первом примере shedule вообще в 90% мест был не нужен, как я бы это пояснил читателям? Приведите мне полную цитату. Вот такое легаси? Ну давайте уберем это shedule, ничего не измениться, задача решена, можно публиковать статью. В вашем текущем примере от shedule уже не избавиться, но тут вылезают другие проблемы, которые точно также придется разъяснять читателям.

                                      То есть я не вижу, чем конкретно Ваш подход было бы проще объяснить «читателям» статьи. Можете попробовать набросать целиком формулировку объяснения, посчитаем по словам, чтоли. Чтото мне подсказывает, что выйдет примерно также, может чуть лучше, может чуть хуже (мне кажется, хуже, но спорить не буду). И по итогу этих действий, даже если выяснится, что ваша формулировка на 1 слово короче (очень сомневаюсь), действительно ли это повод критиковать мой метод построения повествования?

                                      • eao197
                                        /#21441316

                                        Уже задолбало ходить по кругу, но давайте еще раз напоследок. Вдруг до кого-то дойдет.


                                        Нет никакого "моего подхода". Есть ваше решение и есть ваше описание вашего решения.


                                        Это решение в том виде, в котором оно написано и описано, вызывает ощущение чрезмерной сложности. Что затрудняет восприятие вашей статьи.


                                        Все.


                                        Если вы с этим не согласны, то ничего не поделать.


                                        Только, имхо, вам следовало бы подумать не о том, как из-зо всех сил защищать свое решение. А о том, что если уж вам говорят, что ваша статья читается с трудом, что это неспроста же ж. И что можно подумать о том, как сделать ее проще.


                                        То, что вы не понимаете, как проще — это не проблема. Сейчас не понимаете. Через какое-то время, если задасться этой целью, может быть и придет решение.

                    • mayorovp
                      /#21441220

                      Если автор статьи всё переусложнил, то вы всё переупростили и никак не видите этого.


                      У автора getA — это асинхронный метод, который не может вернуть результат. Вместо этого он принимает колбек.


                      У вас это синхронный метод, которому какой-то там schedule как пятое колесо телеге.


                      Правильный код, без лишних сложностей, но и без потери свойств, будет примерно таким:


                      // Контекст для операций над A.
                      auto contextA = std::make_shared<thread_executor> contextA(); 
                      
                      // Это "актор" A.
                      auto A = std::make_shared<ExecutionContextBoundDataObjectA>(contextA);
                      
                      A->getA([=] (auto a) {
                          A->getB([=] (auto b) {
                              A->saveAB(a-b, a+b, [=] () {
                                  A->getA([=] (auto a) {
                                      A->getB([=] (auto b) {
                                          std::cout << "Result " << a << " " << b << std::endl;
                                      });
                                  });
                              });
                         });
                      });

                      • eao197
                        /#21441266

                        Если автор статьи всё переусложнил, то вы всё переупростили и никак не видите этого.

                        Может быть. Но у меня нет ни времени, ни желания делать нормально то, что следовало бы сделать автору статьи.


                        У вас это синхронный метод, которому какой-то там schedule как пятое колесо телеге.

                        schedule нужен всего лишь для того, чтобы показать, что какие-то вещи нельзя делать на текущем контексте, а их нужно запланировать для выполнения на каком-то другом контексте.


                        У автора getA — это асинхронный метод, который не может вернуть результат.

                        Это проблема автора. Он никак не объяснил почему это именно так и почему это не может быть по-другому. Вы воспринимаете эту условность как само собой разумеющееся, а я нет. Почему ваше восприятие должно быть правильным — хз.

                        • mayorovp
                          /#21441274

                          Может быть. Но у меня нет ни времени, ни желания делать нормально то, что следовало бы сделать автору статьи.

                          То есть донести вашу точку зрения до других у вас тоже нет желания? Тогда зачем вы тут пишете комментарии?


                          Вы воспринимаете эту условность как само собой разумеющееся, а я нет.

                          Я воспринимаю это как условие задачи. Будет другое условие — будет другое решение (и там не будет корутин).

                          • eao197
                            /#21441304

                            То есть донести вашу точку зрения до других у вас тоже нет желания?

                            Свою точку зрения я донес. Но дальше начался процесс разбора того, что именно вызывает ощущение чрезмерной сложности и как от этого можно избавиться. А вот на такое занятие нужно гораздо больше сил и времени, чем у меня есть в наличии. Поэтому получается так, как получается.


                            Я воспринимаю это как условие задачи.

                            А я нет, поскольку автор не сделал себе труда нормально описать постановку задачи. По крайней мере так, чтобы читателям вроде меня она была понятна.

                            • mayorovp
                              /#21441330

                              Ну так пройдите мимо, раз условия непонятны.


                              Если хоть раз в жизни приходилось писать асинхронный код — должно быть понятно откуда эти колбеки вылазят. Если не приходилось — то зачем вам лишняя информация?

                              • eao197
                                /#21441342 / -1

                                Не говорите куда пройти людям и люди не будут говорить куда пройти вам. Намек, думаю, понятен?


                                Асинхронного кода довелось написать достаточно. Настолько, чтобы скептически отнестись к условиям, выдвигаемым автором статьи.

                      • eao197
                        /#21441296

                        У вас здесь, насколько я понимаю задумку автора, тоже не правильно:


                        A->getA([=] (auto a) {
                            A->getB([=] (auto b) {

                        Ибо одна из сторон проблемы, о которой он говорит, состоит в том, что getA должно выполниться на контексте А, тогда как лямбда, которая воспринимает результат getA, и в коротой инициируется getB, должна быть выполнена на другом контексте. Откуда у автора и возникает потребность в GetACallback/GetBCallback. Что в вашей цепочке никак не выражено.


                        Так что ваш "правильный" вариант является таковым только на ваш взгляд. И вы переупростили не меньше, чем я.

                        • svr_91
                          /#21441310

                          Да, результат коллбэка должен возвращаться в тот контекст, на котором этот коллбэк был создан

                        • mayorovp
                          /#21441314

                          Вот этого как раз я в коде автора не увидел. У него только 1 актор же, и весь код будет выполняться в его контексте. Разве что может понадобиться стартовый schedule добавить.

                          • eao197
                            /#21441320

                            Тем не менее — это одна из особенностей решения автора статьи. И один из камней, о который я споткнулся изначально, не понимая долгое время суть GetACallback и спрятанной за ним механики.

                            • mayorovp
                              /#21441334

                              Именно потому её я предлагаю убрать. Чтобы никто на ней не спотыкался.

                            • svr_91
                              /#21441346 / +1

                              По крайней мере это более конкретная претензия. Теперь я увидел, в чем у некоторых читателей возникают проблемы. Ок, возможно над этим стоило подумать. Мы же с Вами зачемто обсуждаем различие в 2-х подходах, кто лучше, кто хуже.

                              «Не понятно, что результат коллбэка выполняется в том месте, где коллбэк создан» — хорошая претензия, возьму на заметку. Хотя мне казалось, я детально пояснял в статье что и ка работает, ну чтож.
                              «Ваша статья переусложнена, нужно писать как-то по другому» — фиговая претензия, если я за 3 дня обсуждения не понял, в чем она состоит. Вы сами вменяете мне в вину, что я чтото не объяснил, а сами объяснить не хотите. Вот пришел человек и в 3 коротких сообщениях объяснил, что не так

                              • eao197
                                /#21441366 / -1

                                Вы сами вменяете мне в вину, что я чтото не объяснил, а сами объяснить не хотите.

                                Я исхожу из наивных представлений о том, что обязанность автора статьи сделать так, чтобы статья была понятна максимальному количеству читателей с определенным уровнем подготовки.


                                А вот читатель вовсе не обязан что-то расжевывать автору статьи и может, в принципе, ограничится фразой "что-то нифига не понятно". Все остальное — это добрая воля читателя.


                                Вот пришел человек и в 3 коротких сообщениях объяснил, что не так

                                Остается открытым вопрос были бы эти три коротких сообщения, если бы не вся предыдущая дискуссия до этого.

                                • svr_91
                                  /#21441384 / +1

                                  Ну мнеже как автору тоже хочется понять, что не так? Ваше право — объяснять мне это или нет, мое — попытаться понять затруднения. И почемуто разговор с Вами досихпор не добавил мне понимания.

                                  Обидно, что столько времени потрачено впустую. Я просто думал, что у Вас действительно есть какоето супер решение, которое все прояснит. Но я ваше решение не понимаю. Как и Вы мое. Честно. Не троллю, не придираюсь, не понимаю. И, равняя по себе, представляю себе, что и другой читататель не поймет

                                  • eao197
                                    /#21441398

                                    Я вам еще раз объясняю, что нет никакого моего решения.


                                    Есть ощущение, что вы переусложнили свою статью и она читается тяжело.


                                    Попытки донести до вас этот простой факт не помогают. Т.к. вы не желаете включить свою голову и посмотреть на свой же код/текст другими глазами.


                                    А чтобы написать свое решение этой же задачи (которую вы, кстати, не удосужились сформулировать) нужно потратить в разы больше времени, чем уже было потрачено на комментарии в этой теме.

                                    • svr_91
                                      /#21441416

                                      Судя по «независимым комментариям», я статью не переусложнил. А недообъяснил. Не пояснил, почему контекст лямбды должен выполняться на треде, где был создан. Если бы Вы мне это сказали, я бы это понял первым же сообщением.

                                      Сейчас вы скажете, что Вы это и называете «переусложнил». Отлично. Сколько копий сломано из-за обсуждения значения слова, вместо того, чтобы просто взять и показать на проблему

                                      • eao197
                                        /#21441434

                                        Не пояснил, почему контекст лямбды должен выполняться на треде, где был создан.

                                        Я все же придерживаюсь мнения, что для вашей статьи это было не нужно вообще. И для демонстрации короутин вы могли бы использовать пример кода, который показал mayorovp.


                                        Но если вы готовы кидаться помидорами в человека, который попытался донести до вас недостатки вашей же работы, то Ok.

                          • svr_91
                            /#21441332

                            Нет, там 2 актора

                            • mayorovp
                              /#21441338

                              Вот в этом коде я вижу только abActor:


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

                              • eao197
                                /#21441348

                                Подумайте к чему там относится this.

                                • mayorovp
                                  /#21441356

                                  А он что, используется для чего-то кроме получения поля abActor? Ему тут не обязательно быть актором, и в его контексте ничего не исполняется.

                                  • svr_91
                                    /#21441364

                                    В его контексте исполняется std::cout и вызов актора abActor.
                                    Представьте, что вместо std::cout тут было бы сохранение значения в переменную класса this. В данном случае никакой «гонки данных» происходить не будет даже в случае многопоточного кода, так как коллбэк будет возвращаться в контекст того класса, где был порожден

                                  • eao197
                                    /#21441376

                                    А он что, используется для чего-то кроме получения поля abActor?

                                    Нет. И из-за этого так же приходилось прикладывать усилия, чтобы разобраться что к чему.

                              • svr_91
                                /#21441354

                                Это лишь картинка для привлечения внимания. Если ее ктото не понял — ок, это камень в мой огород. По крайней мере мы за 4 сообщения добрались до корня непонимания проблемы, а я с товарищем выше уже третий день разговариваю

                                • mayorovp
                                  /#21441358

                                  Проблема в том, что у вас "лишь картинка для привлечения внимания" занимает треть поста.

              • commenter
                /#21437086 / -1

                Обычно асинхронный код распространяется по исходникам как короновирус, и одним методом тут как правило отделаться не получается

                Но вы же программист, вы должны контролировать свой код. Просто не распространяйте короновирус.
                > иногда делал вызов синхронным
                Вы же понимаете, что это не очень хорошо?

                Почему нехорошо? Возьмём ваш линеаризованный вариант, вот первая строчка:
                const int a = co_await actor.abActor.getAAsync();

                В ней мы синхронно дожидаемся возврата значения а, то есть вы вы тоже сделали синхронный вызов. Почему же мне нельзя?
                У меня была цель дать теоретическое введение в работу корутин

                Но для этого пришлось объяснять читателю, как работает целый фреймворк. И на этом фоне собственно тема «как работают корутины» просто потерялась. Потому что для её понимания нужно продраться через понимание фреймворка, потом понять, как это относится к примеру, потом понять, где здесь корутины, и только потом понять как работают корутины (если получится не потерять нить объяснения гораздо раньше). Вот это и есть сложность, о которой и участник eao197 вам пишет.
                у меня возникла идея, что одной из возможностей корутин могла бы стать линеаризация существующего асинхронного кода.

                Вот! То есть на самом деле вы не объяснение работы корутин давали, а пример того, как с их помощью сделать код последовательным (ну и получается, что синхронным). Но смысл-то здесь простой — спрятать callback-и. То есть, например если мы работаем с оборудованием и ожидаем его готовности при помощи callback-а, который дёргает ось, то мы можем написать универсальную функцию, которая возвращает тот результат, что отдаёт ось callback-у, и отдаёт его синхронно, то есть для внешней вызывающей стороны callback не виден, а виден обычный синхронный вызов функции, которая прячет внутри все эти callback-и.

                Ну и такой подход (с функцией), на мой взгляд, проще написания дополнительного фреймворка.

                ЗЫ. Я тоже сталкивался с callback-hell, ну и обходил его показанным синхронным способом.

                • mayorovp
                  /#21437280

                  В ней мы синхронно дожидаемся возврата значения а, то есть вы вы тоже сделали синхронный вызов.

                  Нет, этот вызов только выглядит синхронным.

                • svr_91
                  /#21437314

                  Не понял про синхронный способ. Вы использовали корутины? Корутины — это по прежднему асинхронный способ, хотя и выглядит как синхронный.
                  Вообще, это конечно особенности именования. Мне тоже не нравится, что код такого типа называется асинхронным, но что поделаешь.
                  Я понимаю, что можно написать небольшую обертку, которая будет до упора ждать, пока исполнится асинхронный код и продолжит синхронное выполнение. Но вы понимаете, что это идет вразрез с использованием того асинхронного кода? Если вы таким образом «синхронизируете» код, значит асинхронный код вам не нужен, зачем же вы его тогда взяли?

                  > То есть, например если мы работаем с оборудованием
                  То есть вместо написания простенького (как по мне) велосипеда вы предлагаете рассматривать особенность работы на какомто оборудование, которое возможно известно только вам и еще небольшой группе людей?

                  • commenter
                    /#21439308

                    Если вы таким образом «синхронизируете» код, значит асинхронный код вам не нужен, зачем же вы его тогда взяли?

                    Я не имел других возможностей. Мне дано оборудование и его обёртка от оси. Далее нужно как-то удобно этим пользоваться. Вот я и сделал удобство в виде простого последовательного исполнения задачи. Что может быть проще?
                    вы предлагаете рассматривать особенность работы на какомто оборудование, которое возможно известно только вам и еще небольшой группе людей?

                    Ну его легко можно абстрагировать. Там всё тривиально — делаем запрос, а результат всегда отдаётся через callback (это так в оси её авторы сочинили). И вот этих ответов нужно дожидаться много раз, при чём вложенно, то есть дождавшись одного ответа в callback-е мы из него же должны создать ещё один callback, потом дождаться его результата, ну и там повторить всё сначала. В результате получается глубокая и неудобная вложенность. А разруливается она примерно как у вас — просто преобразуем в последовательность синхронных вызовов (для внешнего наблюдателя всё синхронно). Вопрос только в реализации разруливающего велосипеда.

                    • svr_91
                      /#21439378

                      Синхронизация может быть разной. «Синхронизировать» корутинами — не тоже самое, что синхронизировать, заблокировав поток. Если производитель поставил асинхронный api, значит это имело по собой какойто смысл.

                      • commenter
                        /#21439774 / -1

                        Если производитель поставил асинхронный api, значит это имело по собой какойто смысл.

                        Ну смысл простой — пока ждём ответа оборудования можно попробовать выполнить какой-то другой код. Это логично для браузера, когда блокируя его единственный поток мы подвешиваем браузер с точки зрения пользователя. Но если поток не единственный, то сразу исчезают подобные ограничения. И при этом раньше, чем пройдут все этапы ожидания ответов от оборудования мы всё равно не сможем продолжать что-то делать. То есть здесь абсолютно последовательная обработка команд. Ну и логично её оформить именно как последовательную, без callback-ов вообще.

                        Ну и так, до кучи, производители бывает такого напоставляют, что думать о смыслах вообще не приходится. Про некоторые китайские поделия я не нахожу других слов, кроме «идиоты». Хотя политкорректность требует промолчать…

                        • mayorovp
                          /#21439940

                          Но если поток не единственный, то сразу исчезают подобные ограничения.

                          А если экземпляров "оборудования" — пара тысяч? Что, на каждый по потоку заводить будете?

                          • commenter
                            /#21442102 / -1

                            Вообще-то речь о реальном железе. Поэтому «пара тысяч» — это явный натяг. Но ради чего?

                            Если были бы некие софтовые сущности в количествах, сопоставимых с вашим, тогда был бы другой разговор. Но здесь опять нужен всё тот же контекст — что за сущности, в каких процессах участвуют, какие ограничения и т.д. Если вы всё это пропускаете, но заявляете лишь о «наличии чего-то», то я вам уверенно отвечаю — для «чего-то» всегда есть «какое-то» решение. Надеюсь вас устраивает столь конкретный ответ? Точно так же меня «устраивает» ваш «конкретный» вопрос.

                            • mayorovp
                              /#21442228

                              Какая разница, что там за сущности? Достаточно того, что они существуют и иногда встречаются в количествах намного больше указанных мною. Задачи-то разные бывают, и если вы с чем-то подобным не сталкивались — это ещё не значит, что ничего такого не существует.


                              Ближе к вашей области — вспомните про IOT. Представьте, что вы пишете MQTT-сервер, который будет собирать телеметрию со всех электрических розеток на заводе.

                              • commenter
                                /#21442466 / -1

                                Какая разница, что там за сущности?

                                Ну если программу писать не надо, то никакой разницы.
                                Представьте, что вы пишете MQTT-сервер, который будет собирать телеметрию со всех электрических розеток на заводе.

                                Это простейший вариант шаблона «очередь». Много поставщиков событий на изменение состояния и (если нужно) много разбирающих события обработчиков. Опять без callback-ов.

                                И что вы этим так таинственно хотели сказать?

                                • mayorovp
                                  /#21442538

                                  А протокол MQTT, который хоть и lightweight, но вовсе не самый простой, вы как будете реализовывать?

                                  • commenter
                                    /#21442904 / -1

                                    То есть здесь вопросы задаёте только вы?

                                    Отвыкайте от таких надменных привычек.