Pull to refresh

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. Но не факт, что получится без танцев с бубном

У типобезопасности и метапрограммных трюков есть некоторая цена, как минимум в эталонной реализации от 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, которые в пользовательском коде нигде не используются. Зачем пользователю информация об этих сигнатурах? Её смысл не очевиден, и, по-моему, она выглядит как пример плохой архитектуры стандартной библиотеки, потому что требует знания внутренностей реализации, которые не используются пользователем напрямую.

Sign up to leave a comment.

Articles