1. Почему исключения в многопоточке «всплывают» не там
Если вы только начинаете писать конкурентный код, то ожидание обычно такое: «Ну, если там ошибка, значит я её поймаю рядом, где запускал задачу». Но у многопоточности своё чувство юмора. Исключение возникает в одном контексте выполнения, а наблюдаем мы его часто в другом. Это ломает привычную картину мира, где throw и catch стоят недалеко друг от друга и не устраивают вам квест по поиску места преступления.
В классическом std::thread ситуация ещё жёстче: если исключение вылетело из функции потока и никто внутри потока его не поймал, программа обычно заканчивается через std::terminate() (то есть «аварийное завершение без вопросов»). std::async/std::future устроены гуманнее: они дают «канал», по которому исключение доедет до того места, где вы забираете результат.
Как future хранит результат или исключение
Когда мы говорим «std::future хранит результат», полезно мысленно уточнить: future хранит не сам результат, а ссылку на общее состояние (shared state). Это такая внутренняя «ячейка», где в конце окажется ровно один из двух вариантов: либо значение типа T, либо исключение. Важно, что get() забирает (потребляет) это состояние — стандарт даже отдельно обсуждает поведение «освобождения shared state» при future::get().
Нарисуем упрощённо:
flowchart LR
A["Запуск задачи (async / promise / packaged_task)"] --> B["Общее состояние (shared state)"]
B --> C["value: T"]
B --> D["exception: exception_ptr"]
E["Потребитель (future)"] -->|wait / get| B
Из этой картинки рождается главный практический вывод лекции: исключение становится видимым не там, где оно случилось, а там, где вы делаете future.get().
2. Где ловить ошибки при std::async
Когда вы используете std::async, ошибки бывают двух типов по времени появления. Это важно, потому что разные ошибки ловятся в разных местах (и именно тут новичкам обычно больнее всего).
Ошибка при вызове std::async
Сначала небольшая подводка. std::async — это не магическая кнопка «сделай параллельно», а обычная функция, которая должна подготовить задачу: скопировать/переместить аргументы, упаковать callable, иногда создать поток (в зависимости от политики запуска). И на этом шаге что-то может пойти не так.
Например, теоретически может не получиться создать поток (системная ошибка), или может бросить исключение копирование аргументов (редко, но возможно). Такие ошибки появляются сразу, прямо на строке std::async(...), и ловятся обычным try/catch вокруг запуска.
Мини-пример (демонстрационный; реальная причина исключения зависит от ситуации):
#include <future>
#include <iostream>
#include <stdexcept>
int main() {
try {
auto f = std::async(std::launch::async, [] {
return 1;
});
std::cout << f.get() << '\n'; // 1
} catch (const std::exception& e) {
std::cout << "launch failed: " << e.what() << '\n';
}
}
Ошибка во время выполнения задачи
Теперь важная часть. Если callable внутри async бросает исключение, оно не печатается само и не прилетает к вам сразу. Оно «складывается» в shared state (как exception_ptr) и будет повторно выброшено при future.get().
#include <future>
#include <iostream>
#include <stdexcept>
int main() {
auto f = std::async(std::launch::async, []() -> int {
throw std::runtime_error("boom in async");
});
try {
std::cout << f.get() << '\n'; // сюда не дойдём
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n'; // caught: boom in async
}
}
Обратите внимание на «психологическую ловушку»: ошибка случилась «где-то там», а поймали мы её «вот здесь».
4. Почему wait() и wait_for() не гарантируют успех
После того как вы увидели, что исключение вылезает на get(), возникает следующая типичная мысль: «Ок, тогда я сначала сделаю wait(), убедюсь что оно готово, и всё будет хорошо». Увы, «готово» не означает «успешно». Это означает только «в shared state уже лежит итог» — а итог может быть и исключением.
Покажем это коротко:
#include <future>
#include <iostream>
#include <stdexcept>
int main() {
auto f = std::async(std::launch::async, []() -> int {
throw std::runtime_error("fail");
});
f.wait(); // дождались, но не узнали, чем закончилось
try {
(void)f.get(); // исключение проявится именно тут
} catch (const std::exception& e) {
std::cout << "after wait: " << e.what() << '\n'; // after wait: fail
}
}
С wait_for() и таймаутами картина та же. Таймаут — это вообще не «ошибка задачи», а всего лишь «не успели дождаться». А если успели — это всё равно не обещает успех, потому что «успех» проявляется при get().
5. Режим std::launch::deferred: исключение выглядит синхронным
std::launch::deferred — это режим, при котором задача не стартует сразу, а выполняется в момент wait()/get() в потоке ожидания. И вот тут мозг делает «а, ну значит исключение будет прямо тут» — и да, так и будет. Но это не отменяет правила «наблюдаем на get()»: просто теперь «место выполнения» и «место наблюдения» совпали.
#include <future>
#include <iostream>
#include <stdexcept>
int main() {
auto f = std::async(std::launch::deferred, []() -> int {
throw std::runtime_error("deferred fail");
});
try {
std::cout << f.get() << '\n';
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n'; // caught: deferred fail
}
}
Практический смысл: если вы видите deferred, не ждите параллельности, и помните, что «фонового потока» тут могло вообще не быть.
6. Доставка ошибки через std::promise: set_exception и exception_ptr
Иногда std::async не подходит, потому что результат появляется не как return, а как событие: «получили ответ», «закончилась обработка», «поймали ошибку». Тогда у нас в руках std::promise<T>, и мы сами решаем, что положить в shared state: значение или исключение.
Для этого используется std::promise<T>::set_exception(...), который принимает std::exception_ptr. А вот получить exception_ptr можно двумя базовыми способами.
std::make_exception_ptr(...): создаём исключение явно
Небольшая подводка. Иногда вы не ловите реальное исключение, а хотите «сигнализировать ошибку» по условию: входные данные плохие, файл не найден, формат неправильный. Тогда удобно создать исключение вручную и упаковать его.
#include <exception>
#include <future>
#include <iostream>
#include <stdexcept>
int main() {
std::promise<int> p;
auto f = p.get_future();
p.set_exception(std::make_exception_ptr(std::runtime_error("bad input")));
try {
(void)f.get();
} catch (const std::exception& e) {
std::cout << e.what() << '\n'; // bad input
}
}
std::current_exception(): пересылаем пойманное исключение
Если вы уже в catch (...), то std::current_exception() даёт exception_ptr на текущее пойманное исключение. Это базовый паттерн для «проброса» ошибки из worker-кода к consumer-коду.
#include <exception>
#include <future>
#include <stdexcept>
void worker(std::promise<int> p) {
try {
throw std::runtime_error("worker failed");
} catch (...) {
p.set_exception(std::current_exception());
}
}
Сам факт существования и активного развития exception_ptr как механизма передачи исключений между контекстами выполнения регулярно обсуждается в материалах комитета, так что это не «хак», а нормальная часть модели исключений в современном C++.
7. Практический пример: отчёт TaskBook и обработка ошибок
Сделаем связку с «единым приложением курса». Допустим, у нас уже есть консольная программа TaskBook: она хранит список задач (название + оценка времени), умеет печатать их и сохранять/загружать (неважно, как именно — к этому моменту курса вы видели и файлы, и JSON, и CLI).
Теперь мы добавляем фичу: «Сгенерировать отчёт по задачам». Отчёт может быть тяжёлым (например, анализ, сортировка, агрегации) — и мы хотим делать его асинхронно через std::async. А ещё мы хотим, чтобы если отчёт не получился, пользователь увидел внятное сообщение, а программа не умерла молча.
Модель данных
#include <string>
struct Task {
std::string title;
int minutes = 0;
};
Функция генерации отчёта
#include <numeric>
#include <stdexcept>
#include <string>
#include <vector>
std::string buildReport(const std::vector<Task>& tasks) {
if (tasks.empty()) {
throw std::runtime_error("no tasks to report");
}
int total = 0;
for (const Task& t : tasks) total += t.minutes;
return "Tasks: " + std::to_string(tasks.size()) +
", total minutes: " + std::to_string(total);
}
Здесь мы намеренно бросаем исключение на пустом списке. Это учебный пример: в реальной жизни вы могли бы вернуть «пустой отчёт», но нам сейчас важно увидеть механику доставки ошибки.
Асинхронный запуск и правильная точка try/catch
#include <future>
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks; // допустим, пока пусто
auto reportFuture = std::async(std::launch::async, buildReport, tasks);
try {
std::string report = reportFuture.get();
std::cout << report << '\n';
} catch (const std::exception& e) {
std::cout << "Cannot build report: " << e.what() << '\n';
// Cannot build report: no tasks to report
}
}
Ключевое: ловим вокруг get(). Если вы обернёте try/catch вокруг std::async, то поймаете только «ошибки запуска», но не «ошибки выполнения».
8. Полезные нюансы future: broken promise и ошибки контракта
Этот раздел про ситуации, которые часто воспринимаются как «задача упала», хотя на деле это либо нарушение контракта promise/future, либо неочевидная часть API.
Broken promise: «обещали результат, но исчезли»
Broken promise — это ситуация, когда std::promise уничтожен, но так и не сделал ни set_value, ни set_exception. Для потребителя (future) это выглядит как нарушение контракта: «я ждал результат, а поставщик пропал». В итоге future.get() выбросит исключение типа std::future_error.
Для новичка это очень полезная диагностика, потому что она быстро показывает: «вы забыли завершить канал результата».
Мини-демо:
#include <future>
#include <iostream>
int main() {
std::future<int> f;
{
std::promise<int> p;
f = p.get_future();
// p уничтожится, но set_value/set_exception не будет
}
try {
(void)f.get(); // бросит std::future_error (broken promise)
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
}
В реальном коде broken promise часто означает одно из трёх: поток завершился раньше времени, логика «раннего выхода» забывает установить ошибку, либо promise был перемещён не туда/не тем способом и «потерялся».
get() одноразовый: второй get() — это не ошибка вычисления
Тут маленькая, но важная подводка. Если вы вызвали get(), то вы потребили результат и shared state (и стандарт отдельно акцентирует освобождение shared state на get()). Второй get() — это нарушение контракта использования, и оно обычно выражается через std::future_error.
#include <future>
#include <iostream>
int main() {
auto f = std::async(std::launch::async, [] { return 5; });
std::cout << f.get() << '\n'; // 5
try {
std::cout << f.get() << '\n'; // ошибка: повторный get()
} catch (const std::exception& e) {
std::cout << "second get: " << e.what() << '\n';
}
}
Практическое правило очень простое: если значение нужно больше одного раза — сохраните его в переменную (или используйте другие механизмы, но сегодня мы их не трогаем).
wait_for() может вернуть ready, а get() всё равно бросит
Это логическое продолжение темы. ready означает «готов итог». Итогом может быть исключение.
#include <chrono>
#include <future>
#include <iostream>
#include <stdexcept>
int main() {
auto f = std::async(std::launch::async, []() -> int {
throw std::runtime_error("fail after ready");
});
auto st = f.wait_for(std::chrono::seconds{1});
if (st == std::future_status::ready) {
std::cout << "ready\n"; // ready
}
try {
(void)f.get();
} catch (const std::exception& e) {
std::cout << e.what() << '\n'; // fail after ready
}
}
Шаблон дисциплины: всегда завершайте shared state
Сейчас аккуратно сформулируем дисциплину, которая спасает нервные клетки. Когда у вас есть producer/consumer через promise/future, у producer должна быть железная привычка: любой выход из producer‑кода обязан привести к set_value или set_exception.
Покажем безопасный шаблон (и да, тут чуть больше кода, но он того стоит):
#include <exception>
#include <future>
#include <stdexcept>
void produceReport(std::promise<std::string> p) {
try {
// ... делаем работу
throw std::runtime_error("report failed");
// p.set_value("ok"); // в “хорошем” сценарии было бы так
} catch (...) {
p.set_exception(std::current_exception());
}
}
Смысл в том, что consumer всегда может сделать get() и получить либо значение, либо нормальное исключение, а не «тишину и broken promise».
9. Типичные ошибки
Ошибка №1: ставить try/catch вокруг std::async, а не вокруг future.get().
Такой код ловит только «ошибки запуска», но не ловит ошибки, которые произошли во время выполнения callable. Из-за этого программа выглядит так, будто «иногда падает без причины». Лекарство простое: точка наблюдения результата — это get(), значит, и try/catch ставим там.
Ошибка №2: считать, что wait() означает успех.
wait() и wait_for() отвечают на вопрос «готово ли общее состояние», а не «успешно ли вычисление». В итоге новички делают wait(), потом без try/catch вызывают get() и удивляются, что исключение всё равно прилетело. Правильная ментальная модель: wait — про время, get — про результат.
Ошибка №3: забыть вызвать get() и тем самым «потерять» исключение.
future — это ваш контракт получить итог. Если вы запустили задачу, но нигде не сделали get() (или хотя бы не обработали итог иначе), вы по сути выбросили результаты в чёрную дыру. В учебных примерах это выглядит безобидно, а в реальных системах превращается в «в фоне иногда что-то ломается, но никто не знает что».
Ошибка №4: разрушить promise, не установив ни значение, ни исключение (broken promise).
Это классика: ранний return, исключение внутри producer‑кода, забытый set_exception — и consumer получает std::future_error. Исправляется дисциплиной «catch (...) → set_exception(current_exception())» и правилом «каждый путь выхода завершает shared state».
Ошибка №5: путать исключение «задачи» и исключение «контракта future».
Исключение из buildReport() — это ошибка вашей бизнес‑логики. А std::future_error на втором get() — это ошибка использования API. Если не различать эти два класса проблем, можно начать «чинить» вычисление там, где нужно чинить жизненный цикл future (например, сохранение результата в переменную).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ