std::async и future в C++: чем платишь за простоту
std::async кажется простейшим способом запустить задачу в фоне, но у него есть три ловушки: скрытая политика запуска, блокирующий деструктор future и отсутствие пула потоков. Разбираем, где это стреляет.

Вы заменили ручное создание std::thread на std::async, потому что так короче и не нужно вручную звать join(). Код собрался, тесты прошли. А в проде под нагрузкой сервис начал создавать по потоку на каждый вызов и упёрся в лимит потоков ОС — либо, наоборот, все ваши «фоновые» задачи выполнились строго последовательно, потому что реализация выбрала deferred. Оба поведения допустимы по стандарту, и оба легко словить, не понимая, что именно возвращает std::async и когда блокируется деструктор std::future.
Статья про C++17 и новее (примеры собирал на GCC 13 и Clang 17, стандарт C++17). Разбираем не «как запустить задачу» — это одна строка, — а цену этой строки: три места, где std::async ведёт себя не так, как ожидает человек, пришедший из языков с нормальным пулом потоков. Инвалидация shared-состояния между потоками и корутины C++20 — отдельные темы, здесь не трогаем.
Что такое future и откуда он берётся
std::future<T> — это дескриптор результата, который появится позже. Сам результат живёт в общем shared state¹, куда пишет исполнитель задачи и откуда читает вызывающий код. std::async запускает функцию и сразу отдаёт вам future, связанный с этим состоянием. Вызов future.get() блокирует поток до готовности результата и возвращает его один раз.
#include <future>
#include <iostream>
int slow_sum(int a, int b) {
// имитация тяжёлого вычисления
std::this_thread::sleep_for(std::chrono::milliseconds(200));
return a + b;
}
int main() {
// запускаем в фоне, получаем future
std::future<int> f = std::async(std::launch::async, slow_sum, 2, 3);
// здесь можно заниматься другой работой
std::cout << "считаем...\n";
// get() блокирует до готовности и возвращает результат
std::cout << "результат: " << f.get() << '\n';
return 0;
}Обратите внимание на первый аргумент — std::launch::async. Именно он отделяет предсказуемое поведение от лотереи. Без него начинаются проблемы.
Ловушка 1: политика запуска по умолчанию — лотерея
Если вызвать std::async(slow_sum, 2, 3) без явной политики, стандарт разрешает реализации выбрать одно из двух:
std::launch::async— функция запускается в отдельном потоке немедленно;std::launch::deferred— функция не запускается вообще, пока вы не вызоветеget()илиwait(). Тогда она выполнится синхронно, в вашем же потоке.
По умолчанию флаги комбинируются как async | deferred, и выбор остаётся за реализацией. На практике это значит: если вы наплодили сотню future без get(), рассчитывая, что они уже крутятся в фоне, реализация может решить отложить их все — и они выполнятся строго по очереди в момент, когда вы дойдёте до get().
Политика по умолчанию — это не «умный выбор оптимального режима». Это «реализация вправе сделать что угодно из двух». Полагаться на неё в коде, где важна параллельность, нельзя.
Вывод простой: если вам нужен реальный фоновый поток — всегда указывайте std::launch::async явно. Если нужна ленивая синхронная задача — std::launch::deferred. Смешанный дефолт не используйте.
Как проверить, что задача пошла в поток
Быстрый способ — сравнить идентификаторы потоков:
#include <future>
#include <thread>
#include <iostream>
int main() {
std::cout << "main: " << std::this_thread::get_id() << '\n';
auto f = std::async(std::launch::async, [] {
return std::this_thread::get_id();
});
std::cout << "task: " << f.get() << '\n';
// при launch::async идентификаторы разные
}Ловушка 2: деструктор future блокирует
Это место ломает больше всего кода. Если future получен именно от std::async с политикой async, его деструктор блокирует поток до завершения задачи. То есть вот такой «запусти и забудь» не работает так, как выглядит:
void handle_request() {
// намерение: запустить логирование в фоне и не ждать
std::async(std::launch::async, write_log, "event");
// на самом деле: временный future разрушается здесь же,
// и деструктор ждёт завершения write_log
} // <-- блокировка ровно тутВременный future, возвращённый из std::async, уничтожается в конце полного выражения — то есть на той же строке. Его деструктор дождётся write_log. В итоге ваш «асинхронный» вызов стал синхронным, только неочевидным образом.
Это поведение специфично именно для future из std::async. Future, полученный от std::promise или std::packaged_task, в деструкторе не блокирует. Асимметрия неприятная, но её нужно держать в голове.
Что делать, если результат действительно не нужен
Готового «fire-and-forget» в стандартной библиотеке нет — и это сознательное решение комитета, потому что бесконтрольный запуск потоков опаснее. Варианты по возрастанию честности:
- сохранить
futureв контейнер с временем жизни дольше выражения — тогда блокировка сместится в момент разрушения контейнера, а не сразу; - использовать
std::threadсdetach()— но тогда вы сами отвечаете за время жизни захваченных объектов; - завести собственный пул потоков с очередью задач и не использовать
std::asyncдля этого сценария вовсе.
Ловушка 3: пула потоков нет
Стандарт не обязывает std::async с политикой async переиспользовать потоки. На типичных реализациях каждый вызов создаёт новый поток ОС. Создание потока — это системный вызов, стек в несколько мегабайт по умолчанию и работа планировщика. На десятке фоновых задач это незаметно. На тысяче параллельных запросов, где каждый плодит несколько std::async, вы получаете тысячи потоков и деградацию из-за переключений контекста, либо исключение при исчерпании лимита.
| Механизм | Кто управляет потоком | Деструктор блокирует | Когда брать |
|---|---|---|---|
std::async(launch::async) | реализация, обычно новый поток на вызов | да | единичные задачи, скрипты, тесты |
std::thread | вы вручную (join/detach) | terminate, если не join/detach | долгоживущий поток, полный контроль |
std::packaged_task + свой пул | ваш пул | нет | высокая нагрузка, много коротких задач |
std::async(launch::deferred) | никто, ленивое выполнение в get() | нет (задача ещё не запущена) | ленивые вычисления без параллельности |
Когда std::async — правильный выбор
Несмотря на три ловушки, инструмент рабочий в своей нише:
- нужно распараллелить несколько независимых тяжёлых вычислений и дождаться всех — например, обработать 4–8 кусков данных и собрать результат;
- время жизни задачи чётко ограничено скоупом, где вы вызываете
get(); - это одноразовая утилита, тест или прототип, где пул потоков — избыточная сложность.
Для сервиса, обслуживающего поток запросов, std::async — плохая основа: нет контроля над количеством потоков и нет обратного давления. Там нужен пул с ограниченной очередью.
Как ловить ошибки из задачи
Плюс модели future — исключения переносятся автоматически. Если функция бросила исключение, оно сохраняется в shared state и перевыбрасывается при вызове get():
#include <future>
#include <stdexcept>
#include <iostream>
int may_throw() {
throw std::runtime_error("что-то пошло не так");
}
int main() {
auto f = std::async(std::launch::async, may_throw);
try {
f.get(); // исключение перевыбрасывается здесь
} catch (const std::exception& e) {
std::cerr << "поймали: " << e.what() << '\n';
}
}Важно: если вы не вызовете get(), исключение молча потеряется вместе с future. Ещё одна причина не бросаться результатами вслепую.
Что делать дальше
Если std::async в вашем коде используется где-то кроме единичных вычислений — проверьте два места. Первое: везде ли указана явная политика запуска, или где-то остался блокирующий дефолт. Второе: сохраняются ли возвращённые future, или они разрушаются на той же строке и превращают фон в синхронный вызов.
Для высоконагруженной части выносите фоновую работу в собственный пул потоков на std::packaged_task и очереди задач — так вы ограничите число потоков и получите контроль над бэкпрешером. Если проект уже на C++20, следующий шаг — посмотреть в сторону корутин и executor-ов из грядущих ревизий стандарта, но это отдельный разговор. Замерять стоит число живых потоков процесса (в Linux — /proc/<pid>/status, поле Threads) под нагрузкой: резкий рост — прямой признак, что std::async создаёт поток на каждый вызов.
¹ Shared state — внутренний объект, общий для future и того, кто вычисляет результат. Хранит либо значение, либо пойманное исключение, либо признак «ещё не готово». Синхронизация доступа к нему — забота реализации, не ваша.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: std::async — cppreference.com, std::future — cppreference.com
Частые вопросы
А если я на C++11 или C++14, а не C++17?
Почему мой std::async без политики иногда работает параллельно, а иногда нет?
Как сделать настоящий fire-and-forget, чтобы не ждать задачу?
Не сработало: программа падает с исключением о нехватке ресурсов при многих async
Что будет, если задача бросила исключение, а я не вызвал get()?
Можно ли переиспользовать один future после get()?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.