Comments 30
Такое впечатление, что разработчики стандартов C++ решили ещё раз пройтись по всем граблям, накопленным в программировании. Параллельный JCL вот придумали. Дальше они лет через 5 поймут, что очень редко реальный поток вычислений можно выразить графом, и революция закончится.
Концепция сама по себе привлекательная, но испытания практикой не выдерживает. Причём это известно уж лет 40, но очередные первопроходцы на этом славном пути не переводятся (каюсь, и сам был таким; у меня дипломная работа посвящена, а два выполненных проекта - касаются графовой модели комплексирования вычислительных процессов).
В принципе, весь сок из этого подхода был выжат Засыпкиным в 1989 году в его диссертации "Алгоритмы планирования на вычислительных моделях" (к сожалению, не имею чести быть знакомым с этим умнейшим человеком лично). Там прямо вот это всё, о чём статья. Только на Фортране-77, вроде. И автоматически строится, а не руками.
При этом в стандартной библиотеке всё еще нет ведения логов, и, помечтаем, сетевых коммуникаций хотя бы на уровне сокетов.
Так все ровно для более-менее серьезного проекта придется свое дописывать, если понадобиться реализовать, например сетевой троттлинг, реконнекты и пр, и так или иначе нужен доступ к нативному сокету.
Стандарт тупо ещё не умеет работать с юникодом, с файлами в разных кодировках. Потом уже будет сеть и всё остальное.
Последние несколько лет пишу на Rust вместо C++, и теперь, каждый раз, когда краем глаза слежу за развитием плюсов, не могу отделаться от ощущения лёгкого ужаса.
Толсто :-)
В Расте и своих ужасов много. Похоже, подобное заявление имеют право делать только программисты на Си без плюсов :)
Слабоватый список ужасов из пункта про отсутствие стандарта, но и эта тема потихоньку развивается.
Там второй пункт ужасов про библиотеки не раскрыт. А ведь это просто: поскольку нет механизма передачи прав на библиотеку, то если автор её забросил, то усё - пишите форк. Из того, что я видел - это реализация mmap, которая в библиотеке под названием memmap2. ;-)
Вот если сравнить C++ и Rust то есть нотки ужаса. 🥲
Но и попутно мысли есть, что языки созданы для разных целей и у всех есть свой недостатки.
С профилированием, кажется, не все так просто. Время начала захватывается когда, когда лямбда создается? А создается она когда, когда мы pipeline описываем? А запустится он когда, если вообще запустится?
Да, всё именно так, лямбда создаётся в момент выполнения tag_invoke
, то есть при построении sender'а ex::schedule(ps)
, а исполнение лямбды происходит внутри пула, как часть start(os)
-операции, в примере - при вызове sync_wait(pipeline)
. Поэтому корректнее будет сказать, что измеряется задержка между моментом построения sender-а и моментом фактического запуска работы в пуле.
Видимо, слишком размытой получилась формулировка:
<...> выводит задержку переводa задачи в пул <...>, иначе говоря, замеряет время от вызова
schedule(...)
до реального начала выполнения задачи в пуле.
Речь идет о замере времени с момента описания/конструирования, до фактического старта.
Моя личная проблема с чтением всех подобных обзорных статей -- это то, что примеры все какие-то слишком мелкие, тривиальные и, если так можно выразиться, притянутые за уши. Ну т.е. понятно, что что-то сложное вставлять в обзорную статью в стиле "галопом по Европам" нет смысла, т.к. это серьезно "утяжелит" материал. Но, с другой стороны, сложно понять из таких мало кому нужных в реальной жизни примеров в чем же профит.
Интересно было бы посмотреть на использование всей этой кухни в чем-то посерьезнее. Может есть какие-то открытые проекты, в которых подобное уже применяется?
Эти библиотеки были и ранее, если поискать, думаю, можно много найти.
Из того, что выходит по первым кликам: Серверные примеры от NVIDIA, Библиотека Intel для микроконтроллеров, реализующая частичную поддержку P2300, + обсуждения intel о интеграции sender/receiver.
Если наткнетесь на интересные примеры - присылайте, будет, что разобрать
Адаптор
upon_error
позволяет унестиtry/catch
изmain
, сделав логику ошибок отдельной частью кода.
А есть какой-то механизм для работы с опционалами, в частности std::expected? Можно ли иметь несколько блоков обработки типа
...
| ex::then(failiable_1)
| ex::upon_error(on_failiable_1_fail)
| ex::then(failiable_2)
| ex::upon_error(on_failiable_2_fail)
...
Да, можно встроить несколько независимых веток обработки ошибок в один pipeline, как в вашем примере. И да, вы можете адаптировать std::expected
к модели senders/receivers, но для этого потребуется свой адаптер или комбинация существующих (let_value
, just_error
, variant_sender
).
Первый способ - преобразовать expected
в sender, второй способ - танцевать с let_error. Но не факт, что получится без танцев с бубном
Походу многопоточность/параллельность лучше всего писать на verilog.
У типобезопасности и метапрограммных трюков есть некоторая цена, как минимум в эталонной реализации от NVIDIA -- template instantiation stack почти переполняется даже при компиляции hello_world-а. Боюсь себе представить что будет, когда и если этим начнут пользоваться в реальном мире.
Глядя на то что добавляют в новый стандарт, скоро появится встроенный аналог SDL3
А как мне асинхронищину на Boost::ASIO переписать на этот новый "stdexec"? Мне никто не предлагает pool/epoll/uring/.... Наверное, пул-тредов для файловых операций, будет интересно, но сетевые вещи как?
Как согласовать эти да утверждения:
then(sender, func) — принимает sender (можно и от предыдущего адаптера) и возвращает обычное готовое значение (или tuple значений), и запаковывает этот результат в новый sender (если звать подряд - будет sender<sender> и т.д.
и далее в примере два then
подряд:
Следующим примером пойдем через пул потоков считать количество слов в файлах, добавим обработку исключений и суммарный подсчет слов.
| ex::then( [ p = std::move( path ) ] { return p; } )
| ex::then(
[]( std::string&& p ) -> size_t
Действительно, путаница вышла, я поправлю описание. Спасибо.
then
действительно всегда создаёт новый sender
, заворачивая в него результат вызова, но с той разницей от let_value
, что последний не будет плодить sender'ы, и если возвращаемый тип уже является sender'ом - он его пробросит напрямую:
then(just(42),
[](int x) {
return just(x + 1); // sender<int>
} // then вернет sender<sender<int>>
let_value(just(42),
[](int x) {
return just(x + 1); // возвращает sender<int>
} // let_value вернет sender<int>
Поэтому аккумулировать через then результат прошлых вызововthen/let_value
, напрямую пробрасывая sender'ы без распаковки - чревато слоями-sender'ов.
А, вот, let_value
вернет честный sender
в одной плоскости -> sender<int>
Интересно было бы узнать, откуда появилась необходимость такого многослойного оборачивания и, как следствие, наличия let_value
.
По случайности отправил комментарий не в эту ветку.
Как минимум, потому что у него есть еще одна полезная функция.
Ссылки, переданные в func, остаются действительными до тех пор, пока возвращаемый отправитель не завершит работу, после чего переменные выйдут из области видимости.
Если приходится запускать асинхронные задачи, которые требуют собственного состояния — например, буфера или соединений, но мы хотим гарантировать, что оно живёт, пока задача выполняется, то нужен let_value
, ниже покажу, какие проблемы создает then
:
then([] {
auto buf = std::make_shared<std::vector<char>>(1024);
return read_from_socket(buf); // возвращает sender<void>
}); // возвращает sender<sender<void>>
scope.spawn(task);
Невозможно передать напрямую в spawn,
ожидается один уровень вложенности sender'a и главное: buf
может быть уничтожен до завершения read_from_socket
, т.к. then
не знает, что надо продлить его жизнь.
А let_value
гарантирует, что buf
жив до конца всей цепочки.
Без let_value
мы имеем нестабильный lifetime данных, ручное распутывание sender<sender<U>>
и во многих случаях довольно сложно написать что-то читаемое.
Кстати, scope
из примера - он же async_scope
- API из предложения P3149, который используется совместно с P2300
позволяет запускать множество асинхронных операций через
.spawn(sender)
;отслеживает их завершение;
предоставляет способ дождаться окончания всех операций (через
.join()
).
execution::async_scope scope;
scope.spawn(
ex::let_value(ex::just(), [] {
return ex::just(); // Любой sender
})
);
co_await scope.join(); // Ждём завершения всех задач
buf может быть уничтожен до завершения read_from_socket, т.к. then не знает, что надо продлить его жизнь.
Это каким образом он может быть уничтожен?.. Ну, при плохом дизайне, - несомненно может, но в данном примере мы видим просто функцию-лямбду, переданную в then
, и естественно, что ни then
, ни волшебный let_value
не могут сами знать ничего про то, что творится в теле лямбды.
Я уже не говорю про то, почему read_from_socket(buf)
возвращает sender<void>
- вот уж, как по мне, пример действительно дурной архитектуры: прочитай из сокета и забудь что прочел.
В целом я согласен с мнением @eao197 выше: концепция executors предназначена для описания асинхронного выполнения, и описывать ее надо именно на живых примерах с асинхронностью, событиями, чтением сокетов и всем тем, для чего она предназначена. Иначе возникает больше вопросов, чем ответов, и главный вопрос: все это хорошо и круто, но зачем, если для приведенных примеров можно проще и понятней?..
главный вопрос: все это хорошо и круто, но зачем, если для приведенных примеров можно проще и понятней?..
Инструменты нового механизма и синтаксис как-то интуитивно понятнее на самом простом.
Простыни кода и все нюансы Senders/Receivers в рамках одной статьи упихнуть точно невозможно, но от простого к сложному, шаг за шагом
Соберу обратную связь, дополню второй статьей, где будет больше реальных примеров.
Пока что идейно, но по мере дополнений будет и практическая сторона данной темы.
А какой scheduler используется в 1-м примере?
По 2-му примеру: зачем указывать scheduler в make_count? Там создаётся фабрика sender-а, который формирует задачу по подсчёту слов в файле. Зачем ему знать о том, что его будут запускать в thread пуле? По-моему, это неправильное разделение ответственности, и конкретный scheduler нужно указать позже. И, кстати, непонятно, зачем вообще использовать senders/receivers в первой части, где подсчитываются слова. Подсчёт слов можно написать и обычным способом, и это будет гораздо более читаемо. Что хотелось бы сделать, так это взять функцию типа "count_words(string path)", сделать из неё 3 сендера в трёх строчках кода, сделать scheduler с thread пулом, и потом вызвать when_all, чтобы выполнить эти функции параллельно, внутри какого-то более сложного графа исполнения. Тогда целесообразность сендеров/ресиверов была бы более очевидной.
completion_signatures_of_t содержит сигнатуры set_value, set_stopped и set_error, которые в пользовательском коде нигде не используются. Зачем пользователю информация об этих сигнатурах? Её смысл не очевиден, и, по-моему, она выглядит как пример плохой архитектуры стандартной библиотеки, потому что требует знания внутренностей реализации, которые не используются пользователем напрямую.
Senders/Receivers в C++26: от теории к практике