company_banner

Готовимся к С++20. Coroutines TS на реальном примере

    В C++20 вот-вот появится возможность работать с корутинами из коробки. Нам в Яндекс.Такси эта тема близка и интересна (под собственные нужды мы разрабатываем асинхронный фреймворк). Поэтому сегодня мы на реальном примере покажем читателям Хабра, как можно работать с C++ stackless корутинами.

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


    void FuncToDealWith() {
        InCurrentThread();
    
        writerQueue.PushTask([=]() {
            InWriterThread1();
    
            const auto finally = [=]() {
                InWriterThread2();
                ShutdownAll();
            };
    
            if (NeedNetwork()) {
                networkQueue.PushTask([=](){
                    auto v = InNetworkThread();
                    if (v) {
                        UIQueue.PushTask([=](){
                            InUIThread();
                            writerQueue.PushTask(finally);
                        });
                    } else {
                        writerQueue.PushTask(finally);
                    }
                });
            } else {
                finally();
            }
        });
    }
    


    Введение


    Корутины или сопрограммы – это возможность остановить выполнение функции в заранее определённом месте; передать куда-либо всё состояние остановленной функции вместе с локальными переменными; запустить функцию с того же места, где мы её остановили.
    Есть несколько разновидностей сопрограмм: stackless и stackful. Об этом поговорим позднее.

    Постановка задачи


    У нас есть несколько очередей задач. В каждую очередь помещаются определенные задачи: есть очередь для отрисовки графики, есть очередь для сетевых взаимодействий, есть очередь для работы с диском. Все очереди – это инстансы класса WorkQueue, у которых есть метод void PushTask(std::function<void()> task);. Очереди живут дольше, чем все задачи в них помещённые (ситуация, что мы уничтожили очередь когда в ней есть невыполненные задачи, происходить не должна).

    Функция FuncToDealWith() из примера выполняет какую-то логику в разных очередях и, в зависимости от результатов выполнения, ставит новую задачу в очередь.

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

    void CoroToDealWith() {
        InCurrentThread();
    
        // => перейти в writerQueue
        InWriterThread1();
        if (NeedNetwork()) {
            // => перейти в networkQueue
            auto v = InNetworkThread();
            if (v) {
                // => перейти в UIQueue
                InUIThread();
            }
        }
    
        // => перейти в writerQueue
        InWriterThread2();
        ShutdownAll();
    }

    Приблизительно такого результата и хочется добиться.

    При этом есть ограничения:

    • Интерфейсы очередей менять нельзя – ими пользуются в других частях приложения сторонние разработчики. Ломать код разработчиков или добавлять новые инстансы очередей нельзя.
    • Нельзя менять способ использования функции FuncToDealWith. Можно изменить только её имя, но нельзя делать так, чтобы она возвращала какие-то объекты, которые пользователь должен у себя хранить.
    • Полученный код должен быть таким же производительным, как первоначальный (или даже производительнее).

    Решение


    Переписываем функцию FuncToDealWith


    В Coroutines TS настройка корутины производится заданием типа возвращаемого значения функции. Если тип удовлетворяет определённым требованиям, то внутри тела функции можно пользоваться новыми ключевыми словами co_await/co_return/co_yield. В данном примере, для переключения между очередями будем использовать co_yield:

    CoroTask CoroToDealWith() {
        InCurrentThread();
    
        co_yield writerQueue;
        InWriterThread1();
        if (NeedNetwork()) {
            co_yield networkQueue;
            auto v = InNetworkThread();
            if (v) {
                co_yield UIQueue;
                InUIThread();
            }
        }
    
        co_yield writerQueue;
        InWriterThread2();
        ShutdownAll();
    }

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

    CoroTask


    В простейшем (в нашем) случае содержимое класса «настройщика» сопрограммы состоит всего из одного алиаса:

    #include <experimental/coroutine>
    
    struct CoroTask {
        using promise_type = PromiseType;
    };


    promise_type — это тип данных, который мы должны сами написать. В нём содержится логика, описывающая:

    • что делать при выходе из корутины
    • что делать при первом заходе в корутину
    • кто освобождает ресурсы
    • как поступать с исключениями вылетающими из корутины
    • как создавать объект CoroTask
    • что делать, если внутри корутины позвали co_yield

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

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

    PromiseType


    Приступаем к самому интересному. Описываем поведение корутин:

    class WorkQueue; // forward declaration
    
    class PromiseType {
    public:
        // Когда выходим из корутины через `co_return;` или просто выходим из функции, то...
        void return_void() const { /* ... ничего не делаем :) */ }
    
        // Когда в самый первый раз заходим в функцию, возвращающую CoroTask, то...
        auto initial_suspend() const {
            // ... говорим что останавливать выполнение корутины не нужно.
            return std::experimental::suspend_never{};
        }
    
        // Когда в корутина завершается и вот-вот уничтожится, то...
        auto final_suspend() const {
            // ... говорим что останавливать выполнение корутины не нужно 
            // и компилятор сам должен уничтожить корутину.
            return std::experimental::suspend_never{};
        }
    
        // Когда из корутины вылетает исключение, то...
        void unhandled_exception() const {
            // ... прибиваем приложение (для простоты примера).
            std::terminate();
        }
    
        // Когда нужно создать CoroTask, для возврата из корутины, то...
        auto get_return_object() const {
            // ... создаём CoroTask.
            return CoroTask{};
        }
    
        // Когда в корутине вызвали co_yield, то...
        auto yield_value(WorkQueue& wq) const; // ... <смотрите описание ниже>
    };

    В коде выше можно заметить тип данных std::experimental::suspend_never. Это специальный тип данных, который говорит, что корутину останавливать не надо. Есть ещё его противоположность – тип std::experimental::suspend_always, который велит обязательно остановить корутину. Эти типы – так называемые Awaitables. Если вам интересно их внутреннее устройство, то не переживайте, мы скоро напишем свои Awaitables.

    Самое нетривиальное место в приведённом выше коде – это final_suspend(). Функция обладает неожиданными эффектами. Так, если в этой функции мы не будем останавливать выполнение, то ресурсы, выделенные для корутины компилятором, подчистит за нас сам компилятор. А вот если в этой функции мы остановим выполнение корутины (например, вернув std::experimental::suspend_always{}), то освобождением ресурсов придётся заниматься вручную откуда-то извне: придётся где-то сохранять умный указатель на корутину и явно вызывать у него destroy(). К счастью, для нашего примера это не нужно.

    НЕПРАВИЛЬНЫЙ PromiseType::yield_value


    Кажется, что написать PromiseType::yield_value достаточно просто. У нас есть очередь; корутина, которую надо приостановить и в эту очередь поставить:

    auto PromiseType::yield_value(WorkQueue& wq) {
        // Получаем умный невладеющий указатель на нашу корутину
        std::experimental::coroutine_handle<> this_coro
            = std::experimental::coroutine_handle<>::from_promise(*this);
    
        // Отправляем его в очередь. У this_coro определён operator(), так что для
        // wq наша корутина будет казаться обычной функцией. Когда настанет время,
        // из очереди будет извлечена корутина, вызван operator(), который
        // возобновит выполнение сопрограммы.
        wq.PushTask(this_coro);
    
        // Говорим что сопрограмму надо остановить.
        return std::experimental::suspend_always{};
    }

    И тут нас поджидает очень большая и сложно обнаруживаемая проблема. Дело в том, что мы сначала корутину ставим в очередь и только потом приостанавливаем. Может случиться так, что корутина будет извлечена из очереди и начнёт выполняться еще до того, как мы её приостановим в текущем потоке. Это приведёт к состоянию гонки, неопределённому поведению и абсолютно невменяемым рантайм ошибкам.

    Корректный PromiseType::yield_value


    Итак, нам надо сначала остановить корутину и только после этого добавлять её в очередь. Для этого мы напишем свой Awaitable и назовём его schedule_for_execution:

    auto PromiseType::yield_value(WorkQueue& wq) {
        struct schedule_for_execution {
            WorkQueue& wq;
    
            constexpr bool await_ready() const noexcept { return false; }
            void await_suspend(std::experimental::coroutine_handle<> this_coro) const {
                wq.PushTask(this_coro);
            }
            constexpr void await_resume() const noexcept {}
        };
    
        return schedule_for_execution{wq};
    }

    Классы std::experimental::suspend_always, std::experimental::suspend_never, schedule_for_execution и прочие Awaitables должны содержать в себе 3 функции. await_ready вызывается для проверки, надо ли останавливать сопрогармму. await_suspend вызывается после остановки программы, в него передаётся handle остановленной корутины. await_resume вызывается, когда выполнение корутины возобновляется.
    А что можно написать в треугольных скобрах std::experimental::coroutine_handle<>?
    Можно указать там тип PromiseType, и пример будет работать абсолютно так же :)

    std::experimental::coroutine_handle<> (он же std::experimental::coroutine_handle<void>) является базовым типом для всех std::experimental::coroutine_handle<ТипДанных>, где ТипДанных должен быть promise_type текущей корутины. Если вам не нужно обращаться к внутреннему содержимому ТипДанных, то можно писать std::experimental::coroutine_handle<>. Это может быть полезно в тех местах, где вам хочется абстрагироваться от конкретного типа promise_type и использовать type erasure.

    Готово


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

    А а если мне не нравится co_yield, можно ли его заменить на что-то?
    Можно заменить на co_await. Для этого в PromiseType надо добавить вот такую функцию:

    auto await_transform(WorkQueue& wq) { return yield_value(wq); }
    

    А а если мне и co_await не нравится?
    Дело плохо. Ничего не изменить.


    Шпаргалка


    CoroTask – класс, настраивающий поведение корутины. В более сложных случаях позволяет общаться с остановленной корутиной и забирать какие-либо данные из неё.

    CoroTask::promise_type описывает, как и когда корутине останавливаться, как освобождать ресурсы и как конструировать CoroTask.

    Awaitables (std::experimental::suspend_always, std::experimental::suspend_never, schedule_for_execution и прочие) говорят компилятору, что делать с корутиной в конкретной точке (надо ли останавливать корутину, что делать с остановленной корутиной и что делать когда корутина пробуждается).

    Оптимизации


    В нашем PromiseType есть недостаток. Даже если мы в данный момент выполняемся в правильной очереди задач, вызов co_yield всё равно приостановит корутину и заново поместит её в эту же очередь задач. Куда оптимальнее было бы не останавливать выполнение корутины, а сразу продолжить выполнение.

    Давайте мы исправим этот недостаток. Для этого добавим в PromiseType приватное поле:

    WorkQueue* current_queue_ = nullptr;

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

    Дальше подправим PromiseType::yield_value:

    auto PromiseType::yield_value(WorkQueue& wq) {
        struct schedule_for_execution {
            const bool do_resume;
            WorkQueue& wq;
    
            constexpr bool await_ready() const noexcept { return do_resume; }
            void await_suspend(std::experimental::coroutine_handle<> this_coro) const {
                wq.PushTask(this_coro);
            }
            constexpr void await_resume() const noexcept {}
        };
    
        const bool do_not_suspend = (current_queue_ == &wq);
        current_queue_ = &wq;
        return schedule_for_execution{do_not_suspend, wq};
    }

    Здесь мы подправили schedule_for_execution::await_ready(). Теперь эта функция сообщает компилятору, что корутину не надо приостанавливать, если текущая очередь задач совпадает с той, на которой мы пытаемся запуститься.

    Готово. Можно всячески экспериментировать.

    Про производительность


    В первоначальном примере при каждом вызове WorkQueue::PushTask(std::function<void()> f) у нас создавался экземпляр класса std::function<void()> от лямбды. В реальном коде эти лямбды зачастую достаточно большие по размеру, из-за чего std::function<void()> вынужден динамически аллоцировать память для хранения лямбды.

    В примере с корутинами мы создаём экземпляры std::function<void()> из std::experimental::coroutine_handle<>. Размер std::experimental::coroutine_handle<> зависит от имплементации, но большинство имплементаций стараются держать его размер минимальным. Так на clang размер его равен sizeof(void*). При конструировании std::function<void()> от небольших объектов динамической аллокации не происходит.
    Итого – с корутинами мы избавились от нескольких лишних динамических аллокаций.

    Но! Компилятор зачастую не может просто сохранить всю корутину на стеке. Из-за этого возможна одна дополнительная динамическая аллокация при заходе в CoroToDealWith.

    Stackless vs Stackful


    Мы только что поработали со Stackless корутинами, для работы с которыми требуется поддержка от компилятора. Есть ещё Stackful корутины, которые можно реализовать целиком на уровне библиотеки.

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

    Итоги


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

    Код с ним становится более читабельным и чуть более производительным, чем при наивном подходе:
    Было С корутинами
    void FuncToDealWith() {
      InCurrentThread();
    
      writerQueue.PushTask([=]() {
          InWriterThread1();
    
          const auto fin = [=]() {
              InWriterThread2();
              ShutdownAll();
          };
    
          if (NeedNetwork()) {
              networkQueue.PushTask([=](){
                  auto v = InNetThread();
                  if (v) {
                      UIQueue.PushTask([=](){
                          InUIThread();
                          writerQueue.PushTask(fin);
                      });
                  } else {
                      writerQueue.PushTask(fin);
                  }
              });
          } else {
              fin();
          }
      });
    }
    
    CoroTask CoroToDealWith() {
      InCurrentThread();
    
      co_yield writerQueue;
      InWriterThread1();
      if (NeedNetwork()) {
          co_yield networkQueue;
          auto v = InNetThread();
          if (v) {
              co_yield UIQueue;
              InUIThread();
          }
      }
    
      co_yield writerQueue;
      InWriterThread2();
      ShutdownAll();
    }

    За бортом остались моменты:

    • как вызывать из корутины другую корутину и ждать её завершения
    • что полезного можно напихать в CoroTask
    • пример, на котором чувствуется разница между Stackless и Stackful

    Прочее


    Если вы хотите узнать про другие новинки языка С++ или пообщаться лично с соратниками по плюсам, то загляните на конференцию C++Russia. Ближайшая состоится 6 октября в Нижнем Новгороде.

    Если у вас есть боль, связанная с C++, и вы хотите что-то улучшить в языке или просто желаете обсудить возможные нововведения, то добро пожаловать на https://stdcpp.ru/.

    Ну а если вас удивляет, что в Яндекс.Такси есть огромное количество задач, не связанных с графами, то надеюсь, что это оказалось для вас приятным сюрпризом :) Приходите к нам в гости 11 октября, поговорим о C++ и не только.

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

    Хотите услышать про корутины ещё?

    Яндекс

    470,00

    Как мы делаем Яндекс

    Поделиться публикацией
    Комментарии 54
      +2
      Что-то в пункте «стало» не хватает типа CoroTask и PromiseType. Что-то с такой реализацией не очень похоже на удобство.
        +1
        CoroTask и PromiseType пишутся один раз, используются по всему проекту во всех функциях. Можно конечно вынести их в пункт «стало», да и WorkQueue заодно… или оставить всё как есть и сравнивать именно тела функций :)
        +1

        Хм… Интересно, но я не очень понял как это соотносится, например, с корутинами Котлина, где в library space выставляется примитивы suspend_coroutine/resume, позваляющие делать с корутиной что хочешь (выложить в фоновый пул, ждать там завершения, генераторы...). Кажется, здесь что-то похожее, но требуется явно писать сo_yield — правда же, что там можно как хочешь сохранять корутину и где угодно продолжить?

          0
          С Котлин я особо не знаком, но кажется что весьма похоже: хотите остановить корутину — пишите co_await (для генераторов будет удобнее co_yield), хотите корутину возобновить в каком-то другом потоке/функции — вызывайте operator() на нужном вам coroutine_handle.

          Возобновлять можно в любом месте, приостанавливать можно почти что в любом (вы не сможете написать co_await/co_yield в конструкторе или деструкторе)
            0

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

              0
              Да, но придётся самому ручками прописать логику. В следующей статье постараюсь описать, как это делается.
                +1

                Ждём, вы прекрасно пишете! На самом деле, если сможете собрать пример, похожий на генераторы в Питоне, ещё и с abort, то он покроет все примеры.

          +1
          Интересно было бы услышать о сравнении потребеления памяти/процессора потоками/корутинами
            0
            Конкретные цифры я не приведу но дела обстоят приблизительно так:
            * новый поток отъедает где-то 2 MB оперативной памяти под стек. Корутина отъедает ровно столько памяти, чтобы можно было сохранить все локальные переменные функции. В зависимости от функции, это на 3-4 порядка меньше.
            * переключение потоков — это тяжёлая операция связанная с переключением контекстов ОС. 1000 активных потоков выест ваш процессор одними только переключениями контекстов. Возобновление корутины — это приблизительно так же тяжело, как и вызов функции по указателю. Тоесть опять на порядки легче.
            0
            В примерах с корутинами мне обычно не понятно, как возобновить работу корутины.
            С приостановить то все боле-менее ясно, сохраняем стек и все (ну может есть какие-то внутренние сложности, не важно)
            А вот как возобновить его работу?
            То есть, ждем мы какого-то события по сети (например, нам в сокет что-то написали). При этом событии корутина пробудится сама, или нужно время от времени ее опрашивать в while (true)?

            Почему-то про всех статьях про корутины от меня этот момент ускользает, или у меня не хватает терпения дочитать до конца. Было бы хорошо, если бы это кто-то объяснил на пальцах

            Например, в этом примере. Хотелось бы увидеть код, который вызывает функцию CoroToDealWith()
              +1
              В данном случае примерно понял. Корутина возобновится, когда поток возьмет задание из очереди и попытается его выполнить.
              Но остался вопрос про «автоматическое» возобновление, возможно ли такое
                0
                Давайте для примера возьмём сеть. Большинство библиотек для асинхронной работы с сетью построены через callback. Когда пакет получен — вызывается callback. Вот в качестве callback и надо передавать coroutine_handle.

                Вот так например выглядит эхо сервер с использованием ASIO и Coroutines TS. Если упрощённо, то выражение `co_await socket.async_read_some` получает coroutine_handle на текущую функцию и регистрирует этот coroutine_handle как callback при получении пакета. Если пакет не получен — ничего не просыпается и не опрашивается. Когда пакет получен, вызывается coroutine_handle и функция echo возобновляет свою работу.
                  +1
                  Ага, понятно. То есть, где-то на низком уровне код все равно должен поддерживать корутины хотябы через обычные коллбэки. То есть невозможно прямо весь код снизу доверху написать на корутинах?

                  То есть, в обычном однопоточном коде я могу написать что-то вроде
                  while (true) {
                  int n = read(fileFd, buffer);
                  }
                  А в коде с корутинами, даже если я напишу
                  while (true) {
                  int n = co_await read(fileFd, buffer);
                  }
                  и даже если read будет поддерживать коллбэки, то получается, что где-то все равно должен быть отдельный поток, который бы проверял статус готовности file descriptor-а?
                    0
                    Это не обязательно отдельный поток — можно реализовать через ОС специфичные методы для асинхронной работы с сокетами/дескрипторами и обойтись одним потоком. Будет ожидание одного из сетевых событий на множестве сокетов, а при его наступлении — будет пробуждаться нужная корутина и запускаться в том же потоке.
                      0
                      Ага, ну примерно про это у меня и был первый вопрос. То есть, корутина каким-то чудом пробуждается сама (на самом деле, ее будет дергать ядро, но это детали). Вот и интересно, насколько такое «чудо» глубоко проникнет по различным функциям, и можно ли будет это «чудо» организовать самому (например я захочу сделать что-то вроде сокета, чтобы при изменении его в другом потоке корутина пробуждалась)

                      Просто мне интересно, если например в произвольном коде в (почти) произвольных местах понарасставить co_await, то будет ли это все работать корректно и асинхронно?
                      Вроде нечто подобное делают здесь (https://habr.com/company/jugru/blog/422519/), если я правильно это понимаю
                        0
                        Просто понарасставлять co_await компилятор не позволит. Если возвращаемое значение функции не содержит правильный promise_type — будут ошибки компиляции.

                        Если же есть promise_type и они правильно написаны под то, как вы используете корутины — то да, всё заработает.
                          +1
                          Да, имеется в виду, что promise_type проставлен будет. В этом и вопрос, собираются ли разработчики компиляторов добавлять нужный promise_type в существующие функции, типа read, poll, mutex и т.д. То есть, о чем выше говорили, что ядро + компилятор обеспечивают «бесшовное» внедрение корутин.

                          То есть, если я правильно понимаю статью по ссылке выше, в яве как раз пилят файберы, которые просто позволяют сделать обычный синхронный код «типа corutines». Вот будет ли что-то подобное в C++?
                            0
                            Да, идёт работа над Networking TS. В нём предусмотрена возможность в дальнейшем интегрироваться с корутинами. + в стандартную библиотеку должны завести набор примитивов, упрощающих разработку проектов с использованием сопрограмм.

                            Но всё это будет после C++20. А до тех пор, придётся либо писать самому, либо использовать наработки из сторонних библиотек (например Boost.ASIO).
                              0
                              а не получится как со строками — на каждый крупный проект свой велосипед?
              +3
              Неужели я один такой в шоке от того что за последние 15 лет сделали с С++… Интересно, что думает Б.Страуструп, Г.Шилдт, П.Нортон об этих «стандартах»? С момента появления STL код растёт в объёме на порядки, производительность падает, классы и ООП лепят там где надо и не надо, память динамически всюду, про фрагментацию не думают, лишь бы сказать, что код написан с паттернами "***". (*** — то что модно в этом году).
                0
                Мне кажется, что Вы путаете развитие языка С++ и то, как на нем разрабатываются приложения. Никто не заставит Вас использовать С++17, если Вы того не захотите, ведь обратная совместимость никуда не девается (в большинстве случаев). Пишите в процедурном стиле, делайте свои аллокаторы с непрерывной памятью и без паттернов. Принцип «не платишь за то, что не используешь» никуда не делся.
                  0
                  Вопрос двоякий:
                  * «код растёт в объёме на порядки» — да, бинарники становятся всё больше и больше. Это связано не только с шаблонами, но и с исключениями и с RTTI (и с объёмом написанного кода/функционалом приложения). Многие проекты успешно борются с размером бинарников — LLVM например использует type erased вещи во многих местах (вот пример контейнера, который специально имеет нешаблонный базовый класс, дабы передавать именно его в функции, не раздувая размер бинарников).
                  * «производительность падает» — тут не согласен. Всё зависит от того, как код написать. Для хорошо написанного кода производительность от стандарта к стандарту только растёт.
                  * «память динамически всюду» — зависит от проекта. В стандартной библиотеке тоже есть места, где происходят динамические аллокации… Это конечно зло, но кажется что зло неизбежное.
                    +1
                    Какой код растёт? Бинарный или исходный?
                    Производительность падает? Особенно с move semantics и guaranteed RVO. И с возможностью больше вещей перевести в компилтайм, да.
                    Классы и ООП — наоборот, я бы сказал, что современные плюсы от этой ерунды как-то отходят даже.
                    Память динамически — о, про те же корутины весьма активно обсуждают, как бы сделать так, чтобы они поменьше хипа дёргали.

                    А Страуструп разве что в печали, судя по всему, что concepts выродились в concepts lite, даже аксиом не осталось.
                      0
                      Особенность С++ проектов в том, что там код больше читают, чем пишут. А вообще почему Яндексу не использовать какой-нибудь аналог scheme для построения коррутин? Неужели так удобнее…
                        0
                        Я бы сказал, что это особенность любых более-менее больших и неодноразовых проектов.

                        Думаю, у яндекса основная кодовая база на плюсах, поэтому логичнее топить за корутины в С++20 (которые там и так уже почти наверняка будут), чем переписывать всё на схеме или хаскеле.
                          +1
                          > Особенность С++ проектов в том, что там код больше читают, чем пишут.

                          Это особенность не C++, а любого серьезного проекта, вне зависимости от языка программирования.
                          +1
                          современные плюсы от этой ерунды как-то отходят даже


                          А куда отходят, и что вместо них?
                            0
                            Больше свободных функций, больше стейтлесс-ерунды, больше функционального подхода.

                            На современных плюсах в таком стиле, по крайней мере, писать сильно проще.
                              0
                              А у вас есть пара примеров проектов написанных в подобном стиле с исходниками в открытом доступе? Очень интересно посмотреть!
                                +1
                                … и тут я внезапно понял, что стал мало писать на плюсах нового открытого кода.

                                У меня есть пара идей, дойдут руки сделать — наваяю статеечку.
                          +1
                          Интересно, что думает Б.Страуструп, Г.Шилдт, П.Нортон об этих «стандартах»?

                          не знаю за остальных, но посмотрев видеозаписи выступлений Страуструпа кажется, что он настроен оптимистично. Но вам, разумеется, виднее
                          0
                          del
                            +1
                            а всё-таки, что с исключениями? Улетят ли они по умолчанию в вызов coroutine_handle, как это сделано в std::async?
                              0
                              Нет. Все выкинутые из корутины исключения будут пойманы, а в блоке catch(...) будет вызвана функция promise_type::unhandled_exception. В ней вы можете сохранить исключение в promise_type.

                              Могу расписать подробности в след. статье. Хотите?
                                0

                                Хотим!

                              +1
                              Не очень понимаю, почему в каждом туториале по корутинам начинаются какие-то сложности. Это ведь всего лишь функция с сохраняемым стеком, она должна быть почти такой же простой, как и обычный вызов функции?
                                0
                                Проблемы связаны с тем, что туториалы не совсем базовые. Согласитесь, если в примере есть многопоточность и неявные состояния — то тут сложностей не избежать.

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

                                  Тот promise_type, что описан в статье, должен быть в стандартной библиотеке. Тогда всё решение задачи сократится до одного параграфа.
                                  0
                                  Обычно с удовольствием читаю этот блог по C++, но данная статья исключение. Причём отторжение вызывает сама концепция статьи: возьмём абсолютно невозможный в приличном проекте код и успешно победим его с помощью новых технологий. Подобная сомнительная демонстрация совсем не убеждает в преимуществах новых подходов, т.к. если показанное является главным примером применения, то значит оно просто не нужно в реальных проектах.
                                    +1
                                    ИМХО, конкретно у данной статьи есть два фактора, которые сказываются на простоте ее восприятия.

                                    1. Мало кто (и я, например, точно не из их числа) разбирался с TS-ом по короутинам и представляет себе, что именно делает компилятор, когда встречает co_await/co_yield/co_return, какой код генерируется и как это затем работает. А с таким пробелом в знаниях и понимании работы предложенных в TS-е короутин воспринимать приведенный в статье код очень тяжело.

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

                                    Так вот, если сложить эти два фактора, то и получается реакция, вроде вашей. Но когда материалов по принципам работы stackless coroutines из будущего C++ станет больше и все больше и больше сиплюсплюсников с этими вещам окажутся знакомы, тогда данная тема может перестать казаться такой сложной и сомнительной.
                                      +1
                                      Мне, например, привычнее было бы работать с первоначальным кодом, где лямбды с действиями явным образом распихивались по разным очередям задач. Более четко видно что и куда уходит. Но это, возможно, всего лишь дело привычки.

                                      С корутинами бизнес-логику приложения и логику планировщика можно полностью изолировать друг от друга. При этом первое описывается в императивном стиле, а не раскидано по разным лямбдам
                                        +1
                                        Давайте я еще раз поясню свою мысль. Когда у меня есть код вида:
                                        void foo(some_task params) {
                                          auto data = allocate_and_prepare_task(params);
                                          data.save_to(params.data_file());
                                          show_data(data.presentation_view());
                                        }

                                        И в этом коде для выполнения каждой операции происходит перевод foo() с одного контекста на другой, т.е.:
                                        void foo(some_task params) {
                                          // Здесь неявное переключение на нить для "тяжелых" вычислений.
                                          auto data = allocate_and_prepare_task(params);
                                          // Здесь неявное переключение на IO-нить.
                                          data.save_to(params.data_file());
                                          // Здесь неявное переключение на GUI-нить.
                                          show_data(data.presentation_view());
                                        }

                                        то лично мне (повторю специально: лично мне) не хочется иметь дело с таким кодом. Я бы предпочел что-то более явное:
                                        void foo(some_task params) {
                                          auto data = perform_on(cpu_thread, [&]{ return allocate_and_prepare_task(params); });
                                          perform_on(io_thread, [&]{ data.save_to(params.data_file()); });
                                          perform_on(gui_thread, [&]{ show_data(data.presentation_view()); });
                                        }

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

                                        При этом свои предпочтения никому не навязываю. Любители короутин, бизнес-логики вообще и бизнес-логики планировщиков в частности могут спать спокойно, их любимым извраще игрушкам ничего не угрожает ;)
                                          +1
                                          Я бы предпочел что-то более явное:

                                          Я так полагаю, аналогичный код на корутинах будет выглядеть так:
                                          coro_type foo(some_task params) {
                                              co_yield cpu_thread;
                                              auto data = allocate_and_prepare_task(params);
                                              co_yield io_thread;
                                              data.save_to(params.data_file());
                                              co_yield gui_thread;
                                              show_data(data.presentation_view());
                                          }
                                          

                                          Думаю, такой аналог читается достаточно просто независимо от предпочтений
                                            0
                                            Думаю, такой аналог читается достаточно просто независимо от предпочтений
                                            Я не знаком с coroutine TS, поэтому у меня нет понимания следующих вещей:

                                            1. Можно ли явным образом ограничивать область действия co_yield-а? Т.е. писать что-то вроде:
                                            co_yield some_context {
                                              action_one();
                                              action_two();
                                              ...
                                            }
                                            Поскольку меня смущает то, что co_yield меняет контекст для всего последующего кода. Это может вызывать сложности, если кто-то напишет что-то вроде:
                                            if(some_condition)
                                              co_yield first_context;
                                            else if(another_condition) {
                                              co_yield second_context;
                                              some_action();
                                            }
                                            else
                                              another_action();
                                            main_action(); // ???

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

                                            2. Как можно передавать дополнительные аргументы в co_yield. С некой условной функцией perform_on можно без проблем сделать так:
                                            perform_on(cpu_thread, priority{low}, deadline_timer{20s}, [&]{...});

                                            В случае с co_yield можно ли будет делать, например, вот так:
                                            co_yield requirements(cpu_thread, priority{low}, deadline_timer{20s});
                                            ... 
                                              0
                                              1. Можно ли явным образом ограничивать область действия co_yield-а? Т.е. писать что-то вроде:

                                              co_yield же не вводит никакую «область действия». co_yield arg; по сути просто вызывает yield_value(arg) для возвращаемой таски. В примере yield_value указывает в какой поток передать корутину перед тем как она заснет. А реально контекст (поток выполнения) переключает PromiseType
                                              В случае с co_yield можно ли будет делать, например, вот так:

                                              requirements(...) может возвращать конфиг в виде структуры/тапла, который примет yield_value. Выглядит это как-то так:
                                              пример
                                              // Конфиг, который будет передаваться в co_yield
                                              struct YieldConfig {
                                                  WorkQueue &wq;
                                                  int a;
                                                  int b;
                                              };
                                              
                                              // Перегрузка yield_value, принимающая конфиг
                                              auto yield_value(YieldConfig c) {
                                                  std::clog << "yield config: a=" << c.a << ", b=" << c.b << std::endl;
                                                  yield_value(c.wq);
                                              }
                                              
                                              // Вызов yield_value(YieldConfig) + designated initializers
                                              co_yield {.wq = networkQueue, .b = 5}; 
                                              

                                              Модифицированный пример из статьи

                                              вообще конечно лучше бы вызов yield_value из co_yield происходил через std::invoke, чтобы можно было делать так:
                                              auto yield_value(auto&& ..args);
                                              // ...
                                              co_yield tuple(args...);
                                              

                                              Но это уже к комитету

                                              Это может вызывать сложности, если кто-то напишет что-то вроде:

                                              люди и без корутин иногда пишут сложный/неоптимальный/некорректный код.
                                        0
                                        Сопрограммы точно не вызывают у меня никакой сложности или отторжения — если что, я для регистрации на Хабре (лет 5 назад) написал вот habr.com/post/185706 такую статейку.

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

                                        Безусловно есть узкие области, где применение асинхронного кода, «выпрямленного» сопрограммами крайне полезно (примеры можно увидеть скажем в Boost.Asio). А так же есть специфические очень полезные применения сопрограмм вообще без многопоточности (например вместе с Ranges). Однако про это всё в статье нет ни слова.

                                        Т.е. для меня данная статья выглядит приблизительно так:
                                        Давайте рассмотрим постройку дачи из стекла. В её процессе рабочие частенько бьют стекло и много матерятся. Однако теперь у нас появился новейший инструмент (липучки для стекла!), с которым постройка дачи из стекла будет сопровождаться гораздо меньшим матом. При этом никаких упоминаний о том, что любой нормальный архитектор будет строить дачу из дерева/бетона/ещё чего, а конструкции из стекла применит только для очень особенных зданий, в статье конечно же нет…
                                          0
                                          А какие преимущества от применения сопрограмм являются для вас основными?
                                            0
                                            На данный момент я на практике встречал три области, в которых ощущалась потребность в сопрограммах:

                                            1. Линеаризация асинхронного кода. Актуально для многопоточного кода с тысячами одновременных задач (когда системные потоки становятся неэффективными). Хорошим примером является реализация нагруженного сервера с помощью Boost.Asio (кстати такой пример есть прямо в самой документации Asio).

                                            2. Написание различных генераторов. Это становится особо удобно и актуально с приходом в язык диапазонов (Ranges). Хорошие примеры можно увидеть здесь youtu.be/LNXkPh3Z418?t=1992.

                                            3. Переписывание обычного кода в парадигму реактивного программирования (причём с помощью Stackful сопрограмм это можно делать даже без модификации кода). Например можно взять парсер из Boost.Spirit и в пару строк сделать его реактивным (можно будет кормить его данными по байтам). Кстати, по этой области тоже есть хорошие примеры в видео из предыдущего пункта.

                                            Да, так вот код, использованный в данной статье в качестве примера, явно не относится ни к одной из этих областей. А причиной его изначальной лапшевидности является вовсе не отсутствие сопрограмм в языке, а криворукость архитектора (это можно было записать красиво и без всяких сопрограмм), если конечно же это реальный код, в чём я сильно сомневаюсь (подозреваю что это всего лишь искусственный пример, выдуманный ради статьи). И соответственно очень трудно продемонстрировать преимущества сопрограмм, на примере задачи, в котором они банально не нужны.
                                              0
                                              Как бы вы записали этот код красиво и без сопрограмм?
                                                0
                                                Ну например вот так:
                                                код
                                                void FuncToDealWith() {
                                                	InCurrentThread();
                                                	Send(writer, Op1{});
                                                }
                                                
                                                class Writer: public Actor{
                                                //...
                                                	OnOp1(const Op1&){
                                                		InWriterThread1();
                                                		if (NeedNetwork()) Send(network, Op1{});
                                                		else Send(writer, Finally{});
                                                	}
                                                	OnFinally(const Finally&){
                                                		InWriterThread2();
                                                		ShutdownAll();
                                                	}
                                                };
                                                
                                                class Network: public Actor{
                                                //...
                                                	OnOp1(const Op1&){
                                                		auto v = InNetworkThread();
                                                		if(v) Send(ui, Op1{});
                                                		else Send(writer, Finally{});
                                                	}
                                                };
                                                
                                                class UI: public Actor{
                                                //...
                                                	OnOp1(const Op1&){
                                                		InUIThread();
                                                		Send(writer, Finally{});
                                                	}
                                                };
                                                


                                                И кстати в таком виде мы не только избегаем лапшевидного кода, но и делаем разделение кода по областям. Т.е. скажем человек занимающийся программированием UI сможет видеть и редактировать весь «свой» код в одном файле, а не искать его по всем лямбдам проекта.
                                                  0
                                                  На мой вкус — не лучше лапши.

                                                  При программировании на ASIO всегда раньше были упрёки «У нас 1 действие разваливается на кучу мелких шагов, разбросанных по исходникам. Не видна последовательность шагов при беглом взгляде». Почему при прграммировании с акторами это считается плюсом — мне не ведомо.

                                                  Поставьте ваш пример рядом с финальным результатом с сопрограмами и сравните, что лучше.
                                                    0
                                                    На мой вкус — не лучше лапши.

                                                    Ну как же. Тут у нас:
                                                    1. Принципиальное отсутствие лапши — добавление функции любой сложности не добавляет ни одного уровня вложенности.
                                                    2. Нормальная модульность, которая не просто удобна для разделения работы между разными специалистами, но и позволяет безболезненно менять приложение. К примеру в моём коде я могу без каких-либо сложностей сменить весь UI код (скажем решив поменять используемую GUI библиотеку), в то время как сделать аналогичное, при размазанном по всему проекту UI коду, практически не реально.
                                                    3. И данный пример может даже легко поддержать старый интерфейс (для любителей лапши) — достаточно добавить в акторы ещё один обработчик сообщений, принимающий и исполняющий лямбды.

                                                    При программировании на ASIO всегда раньше были упрёки «У нас 1 действие разваливается на кучу мелких шагов, разбросанных по исходникам. Не видна последовательность шагов при беглом взгляде». Почему при прграммировании с акторами это считается плюсом — мне не ведомо.

                                                    Не стоит смешивать пример из статьи и ASIO, т.к. есть как минимум пара принципиальных различий:
                                                    1. Асинхронный код при реализации сервера (работающего со многими клиентами) является необходимость из соображений производительности. В то время как для примера выше он не просто ненужен, а даже менее эффективен (в ситуации когда число одновременных задач сравнимо с числом ядер процессора).
                                                    2. В ASIO разбивается на кусочки (и склеивается с помощью сопрограмм) конвейер обработки сетевого запроса. Это не имеет ничего общего с примером выше, в котором смешиваются сетевые запросы, работа с UI и ещё чёрт знает что.

                                                    Поставьте ваш пример рядом с финальным результатом с сопрограмами и сравните, что лучше.

                                                    Ха, так даже сравнивать нечего. Мой пример — это практически законченный код, в который осталось добавить конструкторы акторов (в которых задаётся соответствие между типом обрабатываемого сообщения и конкретной функцией-обработчиком) и можно компилировать и использовать. А для аналогичной ситуации с кодом на сопрограммах, к нему надо ещё добавить:
                                                    — код CoroTask
                                                    — код PromiseType
                                                    — код WorkQueue.

                                                    В итоге мой пример будет выглядеть в разы лаконичнее. )))

                                                    P.S. Самое забавное, что изначальный пример статьи по своей сути является реализацией модели акторов, только сильно упрощённой, со всего одним типом сообщений (std::function). Стоит убрать это упрощение и понять, что можно передавать сообщения разных типов, как сразу пропадает необходимость в лямбдах (и соответственно порождаемая их вложенностью лапша).
                                                      0
                                                      Если задача из статьи напоминает вашу любимую задачу, это ещё не значит что ваше любимое решение подходит.

                                                      Можно решать задачу через акторов, можно через сопрограммы, можно через сигналы, можно «лапшой», можно через visitor, можно через монады… Что выбрать — зависит от множества факторов. В статье про сопрограммы даётся пример, что можно решать через сопрограммы.

                                                      Про Акторов
                                                      Расскажите разработчику, что ему надо реализовать 1 маленькую функцию UpdateMap, которая «если надо — обновляет карту по сети, отрисовывает её пользователю и сохраняет на диск». При этом все нужные методы уже написаны в сторонних библиотеках.

                                                      Звучит как задача для стажёра.

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

                                                      Это уже задача не для стажёра.

                                                      Вам придётся объяснять что делать, как делать и что такое акторы. Особенно много вам придётся рассказывать о передаче в акторов данных, которые в примере захватываются через [=].


                                                      Если вы так уверены в безоговорочной победе акторов, давайте всё же сравним полные реализации. Напишите пожалуйста полный рабочий пример с акторами, за основу возьмите код из статьи
                                                        0
                                                        Можно решать задачу через акторов, можно через сопрограммы, можно через сигналы, можно «лапшой», можно через visitor, можно через монады… Что выбрать — зависит от множества факторов.

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

                                                        В статье про сопрограммы даётся пример, что можно решать через сопрограммы.

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

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

                                                        Ну вообще то, как я уже говорил, код выше — это практически всё что надо. Ему осталось добавить только конструкторы, main и include'ы. Однако если так уж хочется взглянуть на полный компилируемый пример, то накидаю на днях.

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

                                    Самое читаемое