В качестве примера возьмём что-то простое: без работы с асинхронными сетевыми интерфейсами, асинхронными таймерами, состоящее из одной функции. Например, попробуем осознать и переписать вот такую «лапшу» из колбеков:
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 же можно менять как вам вздумается.
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<> (он же std::experimental::coroutine_handle<void>) является базовым типом для всех std::experimental::coroutine_handle<ТипДанных>, где ТипДанных должен быть promise_type текущей корутины. Если вам не нужно обращаться к внутреннему содержимому ТипДанных, то можно писать std::experimental::coroutine_handle<>. Это может быть полезно в тех местах, где вам хочется абстрагироваться от конкретного типа promise_type и использовать type erasure.
Готово
Можно компилировать, запускать пример онлайн и всячески экспериментировать.
auto await_transform(WorkQueue& wq) { return yield_value(wq); }
Шпаргалка
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, который можно использовать для создания и других сопрограмм.
Код с ним становится более читабельным и чуть более производительным, чем при наивном подходе:
Было | С корутинами |
---|---|
|
|
За бортом остались моменты:
- как вызывать из корутины другую корутину и ждать её завершения
- что полезного можно напихать в CoroTask
- пример, на котором чувствуется разница между Stackless и Stackful
Прочее
Если вы хотите узнать про другие новинки языка С++ или пообщаться лично с соратниками по плюсам, то загляните на конференцию C++Russia. Ближайшая состоится 6 октября в Нижнем Новгороде.
Если у вас есть боль, связанная с C++, и вы хотите что-то улучшить в языке или просто желаете обсудить возможные нововведения, то добро пожаловать на https://stdcpp.ru/.
Ну а если вас удивляет, что в Яндекс.Такси есть огромное количество задач, не связанных с графами, то надеюсь, что это оказалось для вас приятным сюрпризом :) Приходите к нам в гости 11 октября, поговорим о C++ и не только.
Комментарии (48)
ElegantBoomerang
27.09.2018 10:23Хм… Интересно, но я не очень понял как это соотносится, например, с корутинами Котлина, где в library space выставляется примитивы suspend_coroutine/resume, позваляющие делать с корутиной что хочешь (выложить в фоновый пул, ждать там завершения, генераторы...). Кажется, здесь что-то похожее, но требуется явно писать сo_yield — правда же, что там можно как хочешь сохранять корутину и где угодно продолжить?
antoshkka Автор
27.09.2018 11:01С Котлин я особо не знаком, но кажется что весьма похоже: хотите остановить корутину — пишите co_await (для генераторов будет удобнее co_yield), хотите корутину возобновить в каком-то другом потоке/функции — вызывайте operator() на нужном вам coroutine_handle.
Возобновлять можно в любом месте, приостанавливать можно почти что в любом (вы не сможете написать co_await/co_yield в конструкторе или деструкторе)ElegantBoomerang
27.09.2018 11:49Угу, действительно похоже. А если захочется абортнуть корутину, можно ей Exception запихать при возобновлении?
antoshkka Автор
27.09.2018 17:04Да, но придётся самому ручками прописать логику. В следующей статье постараюсь описать, как это делается.
ElegantBoomerang
27.09.2018 17:48+1Ждём, вы прекрасно пишете! На самом деле, если сможете собрать пример, похожий на генераторы в Питоне, ещё и с abort, то он покроет все примеры.
vmc1
27.09.2018 11:07Интересно было бы услышать о сравнении потребеления памяти/процессора потоками/корутинами
antoshkka Автор
27.09.2018 11:18Конкретные цифры я не приведу но дела обстоят приблизительно так:
* новый поток отъедает где-то 2 MB оперативной памяти под стек. Корутина отъедает ровно столько памяти, чтобы можно было сохранить все локальные переменные функции. В зависимости от функции, это на 3-4 порядка меньше.
* переключение потоков — это тяжёлая операция связанная с переключением контекстов ОС. 1000 активных потоков выест ваш процессор одними только переключениями контекстов. Возобновление корутины — это приблизительно так же тяжело, как и вызов функции по указателю. Тоесть опять на порядки легче.
svr_91
27.09.2018 11:49В примерах с корутинами мне обычно не понятно, как возобновить работу корутины.
С приостановить то все боле-менее ясно, сохраняем стек и все (ну может есть какие-то внутренние сложности, не важно)
А вот как возобновить его работу?
То есть, ждем мы какого-то события по сети (например, нам в сокет что-то написали). При этом событии корутина пробудится сама, или нужно время от времени ее опрашивать в while (true)?
Почему-то про всех статьях про корутины от меня этот момент ускользает, или у меня не хватает терпения дочитать до конца. Было бы хорошо, если бы это кто-то объяснил на пальцах
Например, в этом примере. Хотелось бы увидеть код, который вызывает функцию CoroToDealWith()svr_91
27.09.2018 12:02В данном случае примерно понял. Корутина возобновится, когда поток возьмет задание из очереди и попытается его выполнить.
Но остался вопрос про «автоматическое» возобновление, возможно ли такое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 возобновляет свою работу.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-а?antoshkka Автор
28.09.2018 12:15Это не обязательно отдельный поток — можно реализовать через ОС специфичные методы для асинхронной работы с сокетами/дескрипторами и обойтись одним потоком. Будет ожидание одного из сетевых событий на множестве сокетов, а при его наступлении — будет пробуждаться нужная корутина и запускаться в том же потоке.
svr_91
28.09.2018 13:26Ага, ну примерно про это у меня и был первый вопрос. То есть, корутина каким-то чудом пробуждается сама (на самом деле, ее будет дергать ядро, но это детали). Вот и интересно, насколько такое «чудо» глубоко проникнет по различным функциям, и можно ли будет это «чудо» организовать самому (например я захочу сделать что-то вроде сокета, чтобы при изменении его в другом потоке корутина пробуждалась)
Просто мне интересно, если например в произвольном коде в (почти) произвольных местах понарасставить co_await, то будет ли это все работать корректно и асинхронно?
Вроде нечто подобное делают здесь (https://habr.com/company/jugru/blog/422519/), если я правильно это понимаюantoshkka Автор
28.09.2018 13:56Просто понарасставлять co_await компилятор не позволит. Если возвращаемое значение функции не содержит правильный promise_type — будут ошибки компиляции.
Если же есть promise_type и они правильно написаны под то, как вы используете корутины — то да, всё заработает.svr_91
28.09.2018 14:09+1Да, имеется в виду, что promise_type проставлен будет. В этом и вопрос, собираются ли разработчики компиляторов добавлять нужный promise_type в существующие функции, типа read, poll, mutex и т.д. То есть, о чем выше говорили, что ядро + компилятор обеспечивают «бесшовное» внедрение корутин.
То есть, если я правильно понимаю статью по ссылке выше, в яве как раз пилят файберы, которые просто позволяют сделать обычный синхронный код «типа corutines». Вот будет ли что-то подобное в C++?antoshkka Автор
28.09.2018 14:15Да, идёт работа над Networking TS. В нём предусмотрена возможность в дальнейшем интегрироваться с корутинами. + в стандартную библиотеку должны завести набор примитивов, упрощающих разработку проектов с использованием сопрограмм.
Но всё это будет после C++20. А до тех пор, придётся либо писать самому, либо использовать наработки из сторонних библиотек (например Boost.ASIO).
Costic
27.09.2018 21:05Неужели я один такой в шоке от того что за последние 15 лет сделали с С++… Интересно, что думает Б.Страуструп, Г.Шилдт, П.Нортон об этих «стандартах»? С момента появления STL код растёт в объёме на порядки, производительность падает, классы и ООП лепят там где надо и не надо, память динамически всюду, про фрагментацию не думают, лишь бы сказать, что код написан с паттернами "***". (*** — то что модно в этом году).
gorodnev
27.09.2018 21:18+1Мне кажется, что Вы путаете развитие языка С++ и то, как на нем разрабатываются приложения. Никто не заставит Вас использовать С++17, если Вы того не захотите, ведь обратная совместимость никуда не девается (в большинстве случаев). Пишите в процедурном стиле, делайте свои аллокаторы с непрерывной памятью и без паттернов. Принцип «не платишь за то, что не используешь» никуда не делся.
antoshkka Автор
27.09.2018 21:23Вопрос двоякий:
* «код растёт в объёме на порядки» — да, бинарники становятся всё больше и больше. Это связано не только с шаблонами, но и с исключениями и с RTTI (и с объёмом написанного кода/функционалом приложения). Многие проекты успешно борются с размером бинарников — LLVM например использует type erased вещи во многих местах (вот пример контейнера, который специально имеет нешаблонный базовый класс, дабы передавать именно его в функции, не раздувая размер бинарников).
* «производительность падает» — тут не согласен. Всё зависит от того, как код написать. Для хорошо написанного кода производительность от стандарта к стандарту только растёт.
* «память динамически всюду» — зависит от проекта. В стандартной библиотеке тоже есть места, где происходят динамические аллокации… Это конечно зло, но кажется что зло неизбежное.
0xd34df00d
27.09.2018 21:56+1Какой код растёт? Бинарный или исходный?
Производительность падает? Особенно с move semantics и guaranteed RVO. И с возможностью больше вещей перевести в компилтайм, да.
Классы и ООП — наоборот, я бы сказал, что современные плюсы от этой ерунды как-то отходят даже.
Память динамически — о, про те же корутины весьма активно обсуждают, как бы сделать так, чтобы они поменьше хипа дёргали.
А Страуструп разве что в печали, судя по всему, что concepts выродились в concepts lite, даже аксиом не осталось.finlandcoder
27.09.2018 22:15Особенность С++ проектов в том, что там код больше читают, чем пишут. А вообще почему Яндексу не использовать какой-нибудь аналог scheme для построения коррутин? Неужели так удобнее…
0xd34df00d
27.09.2018 22:22Я бы сказал, что это особенность любых более-менее больших и неодноразовых проектов.
Думаю, у яндекса основная кодовая база на плюсах, поэтому логичнее топить за корутины в С++20 (которые там и так уже почти наверняка будут), чем переписывать всё на схеме или хаскеле.
antoshkka Автор
27.09.2018 22:46> Особенность С++ проектов в том, что там код больше читают, чем пишут.
Это особенность не C++, а любого серьезного проекта, вне зависимости от языка программирования.
biseptol
28.09.2018 00:08современные плюсы от этой ерунды как-то отходят даже
А куда отходят, и что вместо них?0xd34df00d
28.09.2018 07:54Больше свободных функций, больше стейтлесс-ерунды, больше функционального подхода.
На современных плюсах в таком стиле, по крайней мере, писать сильно проще.antoshkka Автор
28.09.2018 08:36А у вас есть пара примеров проектов написанных в подобном стиле с исходниками в открытом доступе? Очень интересно посмотреть!
0xd34df00d
28.09.2018 18:33+1… и тут я внезапно понял, что стал мало писать на плюсах нового открытого кода.
У меня есть пара идей, дойдут руки сделать — наваяю статеечку.
Antervis
28.09.2018 00:30Интересно, что думает Б.Страуструп, Г.Шилдт, П.Нортон об этих «стандартах»?
не знаю за остальных, но посмотрев видеозаписи выступлений Страуструпа кажется, что он настроен оптимистично. Но вам, разумеется, виднее
Antervis
27.09.2018 23:05а всё-таки, что с исключениями? Улетят ли они по умолчанию в вызов coroutine_handle, как это сделано в std::async?
robo2k
28.09.2018 09:36Не очень понимаю, почему в каждом туториале по корутинам начинаются какие-то сложности. Это ведь всего лишь функция с сохраняемым стеком, она должна быть почти такой же простой, как и обычный вызов функции?
antoshkka Автор
28.09.2018 12:29Проблемы связаны с тем, что туториалы не совсем базовые. Согласитесь, если в примере есть многопоточность и неявные состояния — то тут сложностей не избежать.
Можно сделать пример без всего этого — например сделать какой-нибудь генератор. Но это будет скучно, да и вообще без корутин можно будет обойтись :)
antoshkka Автор
28.09.2018 12:34Ну и множество сложностей связано с тем, что пока в стандартную библиотеку не добавили готовые классы для работы с корутинами.
Тот promise_type, что описан в статье, должен быть в стандартной библиотеке. Тогда всё решение задачи сократится до одного параграфа.
AlexPublic
29.09.2018 06:19Обычно с удовольствием читаю этот блог по C++, но данная статья исключение. Причём отторжение вызывает сама концепция статьи: возьмём абсолютно невозможный в приличном проекте код и успешно победим его с помощью новых технологий. Подобная сомнительная демонстрация совсем не убеждает в преимуществах новых подходов, т.к. если показанное является главным примером применения, то значит оно просто не нужно в реальных проектах.
eao197
29.09.2018 09:14+1ИМХО, конкретно у данной статьи есть два фактора, которые сказываются на простоте ее восприятия.
1. Мало кто (и я, например, точно не из их числа) разбирался с TS-ом по короутинам и представляет себе, что именно делает компилятор, когда встречает co_await/co_yield/co_return, какой код генерируется и как это затем работает. А с таким пробелом в знаниях и понимании работы предложенных в TS-е короутин воспринимать приведенный в статье код очень тяжело.
2. Сам подход, когда мы пишем «типа линейный» код, разные куски которого затем должны работать на разных рабочих контекстах и переключение этих контекстов происходит каким-то непривычным «магическим» образом, так же с непривычки вызывает… эээ… если не отторжение, то сложности с пониманием. Мне, например, привычнее было бы работать с первоначальным кодом, где лямбды с действиями явным образом распихивались по разным очередям задач. Более четко видно что и куда уходит. Но это, возможно, всего лишь дело привычки.
Так вот, если сложить эти два фактора, то и получается реакция, вроде вашей. Но когда материалов по принципам работы stackless coroutines из будущего C++ станет больше и все больше и больше сиплюсплюсников с этими вещам окажутся знакомы, тогда данная тема может перестать казаться такой сложной и сомнительной.Antervis
29.09.2018 17:25+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()); }); }
Это мои личные заморочки и тот факт, что «С корутинами бизнес-логику приложения и логику планировщика можно полностью изолировать друг от друга» (чтобы под этой умной фразой не подразумевалось) в моих личных предпочтениях ничего не меняет.
При этом свои предпочтения никому не навязываю. Любители короутин, бизнес-логики вообще и бизнес-логики планировщиков в частности могут спать спокойно, их любимымизвращеигрушкам ничего не угрожает ;)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()); }
Думаю, такой аналог читается достаточно просто независимо от предпочтенийeao197
30.09.2018 10:09Думаю, такой аналог читается достаточно просто независимо от предпочтений
Я не знаком с coroutine TS, поэтому у меня нет понимания следующих вещей:
1. Можно ли явным образом ограничивать область действия co_yield-а? Т.е. писать что-то вроде:
Поскольку меня смущает то, что co_yield меняет контекст для всего последующего кода. Это может вызывать сложности, если кто-то напишет что-то вроде:co_yield some_context { action_one(); action_two(); ... }
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}); ...
AlexPublic
29.09.2018 21:16Сопрограммы точно не вызывают у меня никакой сложности или отторжения — если что, я для регистрации на Хабре (лет 5 назад) написал вот habr.com/post/185706 такую статейку.
А не нравится мне в данной статье полное отсутствие внятной аргументации преимуществ от применения сопрограмм. И да, это на самом деле не такой тривиальный вопрос, потому как главным применением сопрограмм в данном контексте является линеаризация асинхронного кода, который сам по себе далеко не всегда полезен. Указанную в начале статьи задачку можно было решить множеством разных способов. Например с помощью древних банальных системные потоки с блокировками (и кстати с ними код тоже получается линейным, только автоматически). Или же можно было взять более продвинутый инструмент — одну из многих готовых библиотек, реализующих модель акторов (прямо идеально ложится на обсуждаемую задачу). И всё это отлично работает (с более высоким быстродействием и вполне лаконичным кодом) прямо сейчас, без необходимости использования не вошедших в стандарт языка расширений.
Безусловно есть узкие области, где применение асинхронного кода, «выпрямленного» сопрограммами крайне полезно (примеры можно увидеть скажем в Boost.Asio). А так же есть специфические очень полезные применения сопрограмм вообще без многопоточности (например вместе с Ranges). Однако про это всё в статье нет ни слова.
Т.е. для меня данная статья выглядит приблизительно так:
Давайте рассмотрим постройку дачи из стекла. В её процессе рабочие частенько бьют стекло и много матерятся. Однако теперь у нас появился новейший инструмент (липучки для стекла!), с которым постройка дачи из стекла будет сопровождаться гораздо меньшим матом. При этом никаких упоминаний о том, что любой нормальный архитектор будет строить дачу из дерева/бетона/ещё чего, а конструкции из стекла применит только для очень особенных зданий, в статье конечно же нет…antoshkka Автор
29.09.2018 22:35А какие преимущества от применения сопрограмм являются для вас основными?
AlexPublic
30.09.2018 00:21На данный момент я на практике встречал три области, в которых ощущалась потребность в сопрограммах:
1. Линеаризация асинхронного кода. Актуально для многопоточного кода с тысячами одновременных задач (когда системные потоки становятся неэффективными). Хорошим примером является реализация нагруженного сервера с помощью Boost.Asio (кстати такой пример есть прямо в самой документации Asio).
2. Написание различных генераторов. Это становится особо удобно и актуально с приходом в язык диапазонов (Ranges). Хорошие примеры можно увидеть здесь youtu.be/LNXkPh3Z418?t=1992.
3. Переписывание обычного кода в парадигму реактивного программирования (причём с помощью Stackful сопрограмм это можно делать даже без модификации кода). Например можно взять парсер из Boost.Spirit и в пару строк сделать его реактивным (можно будет кормить его данными по байтам). Кстати, по этой области тоже есть хорошие примеры в видео из предыдущего пункта.
Да, так вот код, использованный в данной статье в качестве примера, явно не относится ни к одной из этих областей. А причиной его изначальной лапшевидности является вовсе не отсутствие сопрограмм в языке, а криворукость архитектора (это можно было записать красиво и без всяких сопрограмм), если конечно же это реальный код, в чём я сильно сомневаюсь (подозреваю что это всего лишь искусственный пример, выдуманный ради статьи). И соответственно очень трудно продемонстрировать преимущества сопрограмм, на примере задачи, в котором они банально не нужны.
AxisPod
Что-то в пункте «стало» не хватает типа CoroTask и PromiseType. Что-то с такой реализацией не очень похоже на удобство.
antoshkka Автор
CoroTask и PromiseType пишутся один раз, используются по всему проекту во всех функциях. Можно конечно вынести их в пункт «стало», да и WorkQueue заодно… или оставить всё как есть и сравнивать именно тела функций :)