Комментарии 30
В то же время не могу не отметить, что сходу разобраться в представленных материалах и исходниках нелегко, особенно для тех программистов, которые пока еще не достаточно хорошо познакомились с сопрограммами в C++.
...и далее простыня из магических структур, перегрузок операторов, без какого-либо объяснения зачем в этих структурах те, или иные методы нужны. Что они делают, в какой момент вызываются.
Так и в чём соббсно разница между теми материалами и вашим?
Задумка была в следующем: не писать сразу ОС, а предложить минимальный пример и последовательно его усложнять.
ИМХО нужно было еще рассказать, что такое например std::coroutine_handle, std::suspend_always() и т.д. Судя по всему, названия методов get_return_object(), initial_suspend(), final_suspend(), unhandled_exception() и т.д. являются какими-то магическими встроенными в компилятор или в код системной библиотеки именами, как "begin()" и "end()" для коллекций - ведь никакого наследования от каких-либо интерфейсов в вашем коде я не увидел. Мне кажется, вот с таких вещей и нужно начинать...
Но статья в любом случае полезная, чем больше такой информации тем лучше.
Важно! Данный код не заработал с опцией -Os, что я связываю с использованием std::queue. Без оптимизации (с опцией -O0) всё работает корректно.
Сударь, если у вас оптимизация ломает код — у вас UB. Надо не оптимизации выключать, а код чинить.
Но "виновата", скорее всего, и правда очередь. Нельзя обычную очередь использовать в конкурентном режиме, тут нужен потокобезопасный контейнер на атомиках. Или что-нибудь специальное для прерываний.
Другие ошибки:
Метод await_resume в "событийной" реализации должен не только сбросить событие, но и очистить поле consumer, причём атомарно. А в методе set надо проверять есть ли вообще consumer чтобы его вызвать. Наконец, при установке awaiter на установленное событие надо вызывать его сразу же, причём эта проверка снова должна быть атомарной. Если всё это не сделать — два прерывания, произошедшие слишком быстро и подряд, уведут всю вашу программу в UB (скорее всего, это будет повреждение памяти).
Единственный плюс нового стандарта что я нашёл для себя - теперь можно паковать задачи как корутины и передавать их в многопоточных планировщик. Мол, раньше приходилось ваять костыли, а теперь это стандарт.
for(;;)
{
// Последовательное продолжение выполнения задач
for (auto& t : tasks)
t.resume();
}
Это называется последовательное исполнение программы, для стеклесс корутин не нужен шедулер, он необходим для стекфул корутин(управления их фреймами и переключениями контекста)
Другими словами С++20 корутины это инструменты объединения асинхронных задач в одну логическую задачу, они управляют своим исполнением сами и сами его назначают(иногда с помощью генераторов ивентов по типу boost::asio::io_context). То есть вместо "шедулера" который у вас бесполезен корутина внутри своего кода должна делать нечто типа подписки на событие или перехода на экзекьютор(засыпая перед этим)
Подписаться на событие с засыпанием может и "стекфул корутина", причём совершенно тем же самым способом; необходимость планировщика с наличием стека вообще никак не связана.
Но это не значит что планировщик не нужен! Он нужен, и вот для чего:
Отложенная обработка событий. Прерывание — это такая штука, в обработчике которой многие операции просто запрещены. Вот и приходится делать очередь событий и цикл их обработки. Которые вместе образуют тот самый планировщик.
(Псевдо-)Параллельное выполнение CPU-bound задач. Если задача "тяжёлая" — то приходится периодически прерывать её и передавать управление другим задачам. Из чего следует необходимость вести список готовых к возобновлению задач и выбора среди них очередной. Что и является задачей планировщика.
каким образом вы собрались прерывать стеклес корутину снаружи неё?
А почему обязательно снаружи? Автор вон в своём первом примере co_await suspend_always();
предлагает расставлять. При добавлении ожидания событий этот вариант ломается, но починить его нетрудно.
тогда внезапно корутина сама себя прерывает, так зачем же нужен шелудер тогда? И очевидно если это co_await suspend_always{}; то корутина надеется на нечто внешнее что её потом разбудит при этом не назначая никак когда же ей нужно проснуться. Это просто неэффективно, это похоже на логику шедулера ОС, который ничерта не знает о том что делают треды и будит/усыпляет в рандомные моменты. Теряется смысл вообще вводить шедулер и корутины
И очевидно если это co_await suspend_always{}; то корутина надеется на нечто внешнее что её потом разбудит при этом не назначая никак когда же ей нужно проснуться
Ага, это и называется "планировщик". И именно за этим он и нужен.
Это просто неэффективно
А у вас что, есть более эффективное решение для CPU-bound задач?
Теряется смысл вообще вводить шедулер и корутины
Почему?
Ага, это и называется "планировщик". И именно за этим он и нужен.
это бессмысленный планировщик о чём я и говорю
А у вас что, есть более эффективное решение для CPU-bound задач?
разумеется, корутина должна сигнализировать когда и как ей хочется проснуться, тогда и будет получен выигрыш от введения этой абстракции
И другой вариант, когда корутины завязаны друг на друга и таким образом исполняются по очереди передавая управление через симметричный трансфер. Опять же максимально эффективно т.к. ими управляет не шедулер, который не знает что и когда на самом деле должно произойти, а они сами, обладая всей полнотой информации
Повторюсь: что вы будете делать с CPU-bound задачами? Ещё раз: CPU-bound.
Вы постоянно пытаетесь рассказать решение для задач, которые ограничены вводом-выводом либо какими-то другими внешними событиями. Но бывают ситуации, когда ждать нечего, надо просто что-то долго считать.
берите да считайте, зачем вам вносить оверхед на потенциальные аллокации корутин, полиморфные вызовы с резумами и абсолютно бесполезный "шедулер"?
Потому что расчёт долгий, и обработку событий прерывать во время расчёта не следует.
Представьте, что у вас есть десяток IO-bound задач и парочка CPU-bound и надо как-то всем этим жонглировать.
Если вы "возьмёте и посчитаете", то CPU-bound задачи будут работать по очереди, а IO-bound только если вы будете что-то делать в прерываниях (этот код довольно сложно корректно написать).
Если ваша ОС и железо поддерживают в каком-то виде многопоточность - можно запустить каждую задачу в своём потоке.
А если настоящей многопоточности нет, то можно использовать кооперативную многозадачность, для которой корутины НАКОНЕЦ-ТО предоставляют стандартизованную поддержку со стороны языка.
(комментарий был удалён)
Возможно, это тема для какой-то следующей статьи, но я никак не уведел даже упоминания о возможных проблемах синхронизации совместного доступа к ресурсам, о меж-корутинном обмене информацией.
В примере есть получение байтов из последовательного порта и он как-то ненароком делает пуш в очередь, что может оказаться (я не смог обнаружить в статье сам код) не безопасным в прерывании вызовом, если там не используется уже заранее выделенная память.
Если делать очередь событий — то небезопасных в прерывании вызовов не будет, ведь корутины сами выбирают когда им прерваться.
Но саму очередь событий надо делать потокобезопасной.
Это одна из прелестей корутин, из-за которых их активно используют даже в больших системах: если они все работают в одном потоке, то за исключением редких случаев синхронизация не нужна. Там даже прерываний не будет, потому что шедулер будет спать в epoll().
Недостатком является то, что если синхронизация нужна, то придётся переписывать примитивы: обычный mutex наверняка вызовет дедлок.
mtx.lock();
protected_foo = co_await long_call_with_suspend();
protected_bar = co_await another_long_call_with_suspend();
mtx.unlock();
В приведённом примере были прерывания. Даже если поток один, то это не значит, что его не прервут в любой момент, а, значит, нужна синхронизация.
"Традиционный" дизайн корутин (который я нагло списываю с folly) это отдельно executor, который занимается ожиданием событий, активацией корутин и т.д., фактически является рантаймом, и отдельно уже пользовательские корутины.
Синхронизация нужна между обработчиками прерываний и executor. Корутины про неё ничего знать не будут, им незачем: с обработчиком прерываний они напрямую не общаются. А если следить за тем, чтобы корутины достаточно часто отдавали управление, то для синхронизации между прерываниями и executor достаточно будет атомарных флагов, ну может для UART однобайтового буфера.
Между собой же корутины (пока исполняются в одном потоке) могут общаться практически как угодно. Примитивы типа очереди и mailbox делаются просто из стандартных контейнеров.
следить за тем, чтобы корутины достаточно часто отдавали управлениеВряд ли можно вычислить значение этого «часто». Да, конечно, чем больше переключений, тем меньше вероятность словить проблему, но, как известно, если что-нибудь может пойти не так, оно пойдёт не так. Так что пусть уж лучше это выяснится сразу, чем в тот момент, когда условная ракета должна пристыковаться к МКС.
Считаю абсолютно справедливыми замечания по поводу UB и синхронизации, однако пока это хобби, а код больше «proof of concept», то закрываю глаза. Когда придется писать в продакшн (и если придется, надеюсь, что кто-нибудь возьмет на работу:) ), то надо максимально всем этим озадачиться, причем на уровне теории понимая, что и где может пойти не так.
Вряд ли можно вычислить значение этого «часто».
В примере с UART - чаще, чем UART может выплюнуть байт. Или забить буфер, если он используется. Вычисляется делением :)
Корутины это по большому счёту сахарок для конечных автоматов: вместо того, чтобы фигачить состояния руками и городить колбэки для переключений, можно писать линейный код и поручить преобразование компилятору. Никакой магии там нет. Если сумеете написать hard realtime для ракеты в виде конечного автомата, то с корутинами будет то же самое, но не так мучительно.
Пожалуй, моё последнее замечание на тему тут. Ядер в микроконтроллерах внезапно больше одного бывает. Два конечных автомата, работающих на соседних ядрах, таки бывает должны синхронизировать работу друг с другом. Конкретно, у меня есть ESP32, например. Так же обдумываю, но никак не могу придумать зачем, взять RP2040.
Спасибо, именно такого базового примера мне не хватало.
Корутины C++20 и многозадачность на примере контроллеров stm32