В 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++ и не только.

Комментарии (48)


  1. AxisPod
    27.09.2018 09:46

    Что-то в пункте «стало» не хватает типа CoroTask и PromiseType. Что-то с такой реализацией не очень похоже на удобство.


    1. antoshkka Автор
      27.09.2018 10:05

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


  1. ElegantBoomerang
    27.09.2018 10:23

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


    1. antoshkka Автор
      27.09.2018 11:01

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

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


      1. ElegantBoomerang
        27.09.2018 11:49

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


        1. antoshkka Автор
          27.09.2018 17:04

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


          1. ElegantBoomerang
            27.09.2018 17:48
            +1

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


  1. vmc1
    27.09.2018 11:07

    Интересно было бы услышать о сравнении потребеления памяти/процессора потоками/корутинами


    1. antoshkka Автор
      27.09.2018 11:18

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


  1. svr_91
    27.09.2018 11:49

    В примерах с корутинами мне обычно не понятно, как возобновить работу корутины.
    С приостановить то все боле-менее ясно, сохраняем стек и все (ну может есть какие-то внутренние сложности, не важно)
    А вот как возобновить его работу?
    То есть, ждем мы какого-то события по сети (например, нам в сокет что-то написали). При этом событии корутина пробудится сама, или нужно время от времени ее опрашивать в while (true)?

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

    Например, в этом примере. Хотелось бы увидеть код, который вызывает функцию CoroToDealWith()


    1. svr_91
      27.09.2018 12:02

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


      1. antoshkka Автор
        27.09.2018 12:12

        Давайте для примера возьмём сеть. Большинство библиотек для асинхронной работы с сетью построены через callback. Когда пакет получен — вызывается callback. Вот в качестве callback и надо передавать coroutine_handle.

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


        1. svr_91
          28.09.2018 10:58

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

          То есть, в обычном однопоточном коде я могу написать что-то вроде
          while (true) {
          int n = read(fileFd, buffer);
          }
          А в коде с корутинами, даже если я напишу
          while (true) {
          int n = co_await read(fileFd, buffer);
          }
          и даже если read будет поддерживать коллбэки, то получается, что где-то все равно должен быть отдельный поток, который бы проверял статус готовности file descriptor-а?


          1. antoshkka Автор
            28.09.2018 12:15

            Это не обязательно отдельный поток — можно реализовать через ОС специфичные методы для асинхронной работы с сокетами/дескрипторами и обойтись одним потоком. Будет ожидание одного из сетевых событий на множестве сокетов, а при его наступлении — будет пробуждаться нужная корутина и запускаться в том же потоке.


            1. svr_91
              28.09.2018 13:26

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

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


              1. antoshkka Автор
                28.09.2018 13:56

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

                Если же есть promise_type и они правильно написаны под то, как вы используете корутины — то да, всё заработает.


                1. svr_91
                  28.09.2018 14:09
                  +1

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

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


                  1. antoshkka Автор
                    28.09.2018 14:15

                    Да, идёт работа над Networking TS. В нём предусмотрена возможность в дальнейшем интегрироваться с корутинами. + в стандартную библиотеку должны завести набор примитивов, упрощающих разработку проектов с использованием сопрограмм.

                    Но всё это будет после C++20. А до тех пор, придётся либо писать самому, либо использовать наработки из сторонних библиотек (например Boost.ASIO).


                    1. Antervis
                      30.09.2018 04:56

                      а не получится как со строками — на каждый крупный проект свой велосипед?


  1. Costic
    27.09.2018 21:05

    Неужели я один такой в шоке от того что за последние 15 лет сделали с С++… Интересно, что думает Б.Страуструп, Г.Шилдт, П.Нортон об этих «стандартах»? С момента появления STL код растёт в объёме на порядки, производительность падает, классы и ООП лепят там где надо и не надо, память динамически всюду, про фрагментацию не думают, лишь бы сказать, что код написан с паттернами "***". (*** — то что модно в этом году).


    1. gorodnev
      27.09.2018 21:18
      +1

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


    1. antoshkka Автор
      27.09.2018 21:23

      Вопрос двоякий:
      * «код растёт в объёме на порядки» — да, бинарники становятся всё больше и больше. Это связано не только с шаблонами, но и с исключениями и с RTTI (и с объёмом написанного кода/функционалом приложения). Многие проекты успешно борются с размером бинарников — LLVM например использует type erased вещи во многих местах (вот пример контейнера, который специально имеет нешаблонный базовый класс, дабы передавать именно его в функции, не раздувая размер бинарников).
      * «производительность падает» — тут не согласен. Всё зависит от того, как код написать. Для хорошо написанного кода производительность от стандарта к стандарту только растёт.
      * «память динамически всюду» — зависит от проекта. В стандартной библиотеке тоже есть места, где происходят динамические аллокации… Это конечно зло, но кажется что зло неизбежное.


    1. 0xd34df00d
      27.09.2018 21:56
      +1

      Какой код растёт? Бинарный или исходный?
      Производительность падает? Особенно с move semantics и guaranteed RVO. И с возможностью больше вещей перевести в компилтайм, да.
      Классы и ООП — наоборот, я бы сказал, что современные плюсы от этой ерунды как-то отходят даже.
      Память динамически — о, про те же корутины весьма активно обсуждают, как бы сделать так, чтобы они поменьше хипа дёргали.

      А Страуструп разве что в печали, судя по всему, что concepts выродились в concepts lite, даже аксиом не осталось.


      1. finlandcoder
        27.09.2018 22:15

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


        1. 0xd34df00d
          27.09.2018 22:22

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

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


        1. antoshkka Автор
          27.09.2018 22:46

          > Особенность С++ проектов в том, что там код больше читают, чем пишут.

          Это особенность не C++, а любого серьезного проекта, вне зависимости от языка программирования.


      1. biseptol
        28.09.2018 00:08

        современные плюсы от этой ерунды как-то отходят даже


        А куда отходят, и что вместо них?


        1. 0xd34df00d
          28.09.2018 07:54

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

          На современных плюсах в таком стиле, по крайней мере, писать сильно проще.


          1. antoshkka Автор
            28.09.2018 08:36

            А у вас есть пара примеров проектов написанных в подобном стиле с исходниками в открытом доступе? Очень интересно посмотреть!


            1. 0xd34df00d
              28.09.2018 18:33
              +1

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

              У меня есть пара идей, дойдут руки сделать — наваяю статеечку.


    1. Antervis
      28.09.2018 00:30

      Интересно, что думает Б.Страуструп, Г.Шилдт, П.Нортон об этих «стандартах»?

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


  1. gorodnev
    27.09.2018 21:18

    del


  1. Antervis
    27.09.2018 23:05

    а всё-таки, что с исключениями? Улетят ли они по умолчанию в вызов coroutine_handle, как это сделано в std::async?


    1. antoshkka Автор
      28.09.2018 09:16

      Нет. Все выкинутые из корутины исключения будут пойманы, а в блоке catch(...) будет вызвана функция promise_type::unhandled_exception. В ней вы можете сохранить исключение в promise_type.

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


      1. lamer84
        28.09.2018 09:23

        Хотим!


  1. robo2k
    28.09.2018 09:36

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


    1. antoshkka Автор
      28.09.2018 12:29

      Проблемы связаны с тем, что туториалы не совсем базовые. Согласитесь, если в примере есть многопоточность и неявные состояния — то тут сложностей не избежать.

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


    1. antoshkka Автор
      28.09.2018 12:34

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

      Тот promise_type, что описан в статье, должен быть в стандартной библиотеке. Тогда всё решение задачи сократится до одного параграфа.


  1. AlexPublic
    29.09.2018 06:19

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


    1. eao197
      29.09.2018 09:14
      +1

      ИМХО, конкретно у данной статьи есть два фактора, которые сказываются на простоте ее восприятия.

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

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

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


      1. Antervis
        29.09.2018 17:25
        +1

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

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


        1. eao197
          29.09.2018 18:03
          +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. Antervis
            29.09.2018 20:34
            +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());
            }
            

            Думаю, такой аналог читается достаточно просто независимо от предпочтений


            1. eao197
              30.09.2018 10:09

              Думаю, такой аналог читается достаточно просто независимо от предпочтений
              Я не знаком с 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});
              ... 


      1. AlexPublic
        29.09.2018 21:16

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

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

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

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


        1. antoshkka Автор
          29.09.2018 22:35

          А какие преимущества от применения сопрограмм являются для вас основными?


          1. AlexPublic
            30.09.2018 00:21

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

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

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

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

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


            1. antoshkka Автор
              30.09.2018 09:32

              Как бы вы записали этот код красиво и без сопрограмм?