Как std::execution меняет асинхронное программирование в C++26
|
Асинхронное программирование долго было одной из самых сложных сторон C++. Мы были вынуждены прибегать к различным библиотекам и способам: от стандартных потоков и future/promise до сторонних решений вроде Boost.Asio или даже полностью самописных систем. C++11, C++14 и C++17 постепенно улучшали ситуацию, но недостаточно. C++26 может это изменить с помощью нового компонента std::execution. Этот фреймворк представляет собой не просто новый API, а полноценную систему для асинхронного и параллельного выполнения кода. Предложение P2300R10, на котором основан std::execution, было разработано с учетом множества реальных сценариев использования. Оно предлагает несколько ключевых положений:
В центре архитектуры std::execution находятся понятия "sender" (отправитель), "receiver" (получатель) и "scheduler" (планировщик). Такая модель позволяет абстрагироваться от конкретных механизмов многопоточности и сосредоточиться на самой логике асинхронных вычислений. Особенно важно, что std::execution предоставляет встроенный метод составления асинхронных операций в цепочки, что делает код более читаемым и менее подверженным ошибкам, чем традиционные подходы с обратными вызовами или вложенными блоками.Технические основыМодель sender/receiver, на которой построен std::execution меняет подход к асинхронному программированию в C++. В отличие от традиционных абстракций вроде потоков или задач, она разделяет асинхронные операции на два чётко определённых компонента:Sender (отправитель) — это объект, представляющий асинхронную операцию, которая ещё не началась. Важно понимать, что сендер сам по себе не выполняет никаких действий — это только описание того, что будет выполнено позже. Сендеры могут быть скомпонованы вместе с помощью алгоритмов-адаптеров, создавая законченные цепочки вычислений, которые будут выполнены в будущем. Receiver (получатель) — это объект, который обрабатывает результат выполнения сендера. Он имеет три ключевых метода: set_value (вызывается при успешном завершении), set_error (вызывается при ошибке) и set_stopped (вызывается при отмене операции).Взаимодействие между ними происходит через connect, который связывает сендер и ресивер, создавая операцию, которую можно запустить вызовом start:
schedule создаёт сендер, then преобразует сендер, а sync_wait запускает выполнение цепочки сендеров и ожидает результат.Важной частью архитектуры является scheduler (планировщик). Это абстракция ресурса выполнения, например пул потоков, основной поток или специализированное оборудование, предоставляющая унифицированный интерфейс для планирования работы. Scheduler отвечает на вопрос "где выполнять код?", а не "что выполнять". Сравнивая с существующими моделями асинхронности, можно выделить следующие отличия: 1. В отличие от std::future/promise, модель sender/receiver:- Не создаёт неявных потоков или состояний - Поддерживает отмену операций - Позволяет явно указать, где будет выполняться код - Обеспечивает более эффективную композицию операций 2. По сравнению с callback-подходами: - Избегает глубокой вложенности (callback hell) - Обеспечивает более явную обработку ошибок - Позволяет алгоритмически трансформировать операции 3. По сравнению с корутинами: - Работает без поддержки компилятора - Может быть более эффективной по производительности в некоторых случаях - Легче интегрируется с существующими алгоритмами STL Однако std::execution отлично совместим с корутинами C++20, и во многих случаях их комбинирование даёт наилучшие результаты.Фреймворк предлагает несколько важных классов сендеров: Фабрики сендеров (sender factories): schedule, just, just_error, just_stopped, read_env Адаптеры сендеров (sender adaptors): then, let_value, let_error, let_stopped, upon_error, upon_stopped, bulk, split и др. С этими компонентами вы можете выразить практически любые асинхронные алгоритмы компактно и без лишней сложности. Не могу разобраться как обновить в std::map<std::string, вектор_структур> std::weak_ptr & std::enable_shared_for_this. Как передаем this? Как устанавливать QT offline или online std::pair<std::list<std::pair< >>::iterator, > ломается при возврате из функции Практическое применениеТеоретические основы std::execution производят впечатление, но давайте попробуем его на практике. Начнем с простейшего примера — последовательного выполнения нескольких операций:
Для параллельного выполнения независимых операций используется адаптер when_all:
Для обработки массивов данных std::execution предлагает bulk, который применяет функцию к каждому элементу диапазона:
Одним из важных моментов асинхронного программирования является правильная обработка ошибок. В std::execution эта задача решается с помощью адаптеров upon_error и let_error:
upon_error выполняет функцию только если предыдущая операция завершилась с ошибкой. Если нужно вернуться в "счастливый путь" после обработки ошибки, используется let_error.Иногда нужно переключаться между различными контекстами выполнения. Например, начать работу в фоновом потоке, а завершить в главном потоке для обновления UI:
continues_on указывает, что следующая операция должна выполняться в контексте указанного планировщика.Для более сложных сценариев когда результат одной операции определяет, какие операции выполнять далее, используется let_value:
Преобразование обычных алгоритмов в асинхронные становится более понятным. Например, параллельная редукция:
Производительность и оптимизацияАрхитектурные решения, лежащие в основе std::execution, нацелены не только на удобство использования, но и на максимальную производительность. Можно сказать, одним из главных преимуществ модели sender/receiver является оптимизация на этапе компиляции, что существенно снижает накладные расходы во время выполнения. При сравнении с традиционными подходами асинхронного программирования в C++, std::execution демонстрирует действительно впечатляющие результаты. Рассмотрим сценарий параллельной обработки данных:
1. Отсутствие лишних выделений памяти и синхронизации для каждой отдельной задачи. 2. Более эффективное планирование работы в пуле потоков. 3. Возможность объединять и переупорядочивать операции на этапе компиляции. Особенно заметен прирост производительности при выполнении большого количества мелких задач. В таких сценариях накладные расходы традиционных подходов, связанные с созданием и планированием задач, часто перевешивают пользу от параллелизма. Важную роль в оптимизации играют планировщики (schedulers). Различные типы планировщиков могут быть настроены под конкретные задачи:
Исследования, проведённые в Meta (ранее Facebook), показывают, что правильный выбор планировщика может дать до 5 раз больше производительности для некоторых типов задач. В их работе "Efficient Composition of Asynchronous Operations" (2022) описаны методики оптимизации, многие из которых нашли своё отражение в std::execution. Для достижения максимальной производительности важно также учитывать особенности кэширования данных. В многопоточной среде неэффективная работа с кэшем может привести к значительным потерям. Используя bulk, можно организовать работу так, чтобы данные, обрабатываемые одним потоком, были локализованы в памяти:
Ещё одним важным аспектом оптимизации является избежание ненужных копирований данных. std::execution предоставляет механизмы для эффективной передачи данных между этапами обработки, включая возможность использования семантики перемещения:
Ложка дёгтяНесмотря на все преимущества std::execution, важно понимать его ограничения и потенциальные проблемы. В первую очередь, это довольно сложная концептуальная модель, требующая времени на освоение. Разработчикам, привыкшим к более простым абстракциям вроде std::thread или std::async, может потребоваться значительное время для перехода.Асинхронный код сам по себе труден для отладки, а композиция множества сендеров может создать цепочки, поведение которых трудно прогнозировать при возникновении ошибок. Исключения, распространяющиеся через несколько уровней асинхронных вычислений, могут быть особенно сложны для отслеживания. Производительность std::execution не постоянна. Хотя в большинстве случаев она превосходит традиционные подходы, в некоторых специфических сценариях накладные расходы на абстракцию могут превышать выигрыш от параллелизма, особенно для очень маленьких задач.Существуют и альтернативные решения, которые могут быть более подходящими в определённых контекстах:
Как проинициализировать std::stack<const int> obj ( std::stack<int>{} ); Не освобождается память std::string после использования std::bind MutationObserver не перехватывает программные события Не получается изменить имя родительского блока в цикле массива Найти подстановку, при которой заданное множ-во дизъюнктов~P(x)~Q(g(a),y)Q(x,f(x))∨R(y)P(x)∨Q(x,f(x))становится невыполн Блокировка интерфейса pyside (Qt) при реализации многопоточных приложений STEAM VR , Liv, синхронизация видео в реальности и Vr( tilt brush ) Как сделать аутентификация по SMS без пароля с использованием Xamarin Как сделать, чтобы в случае ошибки увеличения ЗП должна отправлять уведомление администратору, но не пользователю? Видеорегистатор NVR8016 Неисправность планок SDRAM? Использование std::execution для MinGW-8.3.0 компилятора | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
-
Есть ещё ложка дёгтя: в отличие от акторного подхода (например, в моём rotor'е) отправитель должен знать (так или иначе) исполнителя в момент создания задачи, т.е. знать шедулер на который отравит задачу исполняться "куда надо".Запись от basiliscos размещена 29.04.2025 в 12:58


