JavaRush /Курсы /C++ SELF /Исключения в async‑коде

Исключения в async‑коде

C++ SELF
71 уровень , 3 лекция
Открыта

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 (например, сохранение результата в переменную).

1
Задача
C++ SELF, 71 уровень, 3 лекция
Недоступна
Бомба в фоне
Бомба в фоне
1
Задача
C++ SELF, 71 уровень, 3 лекция
Недоступна
Ожидание не успех
Ожидание не успех
1
Задача
C++ SELF, 71 уровень, 3 лекция
Недоступна
Повторный вывод
Повторный вывод
1
Задача
C++ SELF, 71 уровень, 3 лекция
Недоступна
Среднее с ошибкой
Среднее с ошибкой
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ