JavaRush /Курсы /C++ SELF /std::async — политики запуска

std::async — политики запуска

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

1. Зачем нужен std::async и std::future

Если вы только-только подружились с потоками, естественная реакция на std::async звучит так: «А это ещё одна штука, чтобы делать то же самое, но по-другому?». И да, и нет. Поток — это про «запусти выполнение где-то параллельно». А std::async — это про «запусти вычисление, и верни мне ручку, через которую я потом заберу результат». Эта «ручка» называется std::future.

Представьте бытовую сцену: вы заказали пиццу. Поток — это как «я пошёл готовить пиццу на кухню». std::async — это как «я заказал пиццу и получил номер заказа: по нему я потом проверю статус и заберу результат». Номер заказа — это future. Вам не нужно думать, как именно внутри устроена кухня — но вам важно, что результат будет, и его можно получить контролируемо.

И ещё одно важное отличие: std::async умеет отложить выполнение. То есть вы можете сказать: «не готовь пиццу прямо сейчас — приготовь, когда я реально приду её забирать». И это уже политика запуска.

Асинхронность — это контракт, а не магия

Слово async звучит так, будто всё обязательно будет параллельно, быстро и красиво. На практике в C++ это не «магия», а контракт между вами и стандартной библиотекой: вы запускаете задачу, а библиотека решает, когда и где её выполнять — в зависимости от выбранной политики запуска.

В истории стандартизации вокруг std::async и связанных вещей было немало обсуждений про тонкости поведения и формулировок — вплоть до вопросов, можно ли некоторые части спецификации «реально реализовать» одинаково во всех стандартных библиотеках. Это хороший сигнал: тема не игрушечная, и «угадывать» поведение по названию не стоит.

Поэтому цель здесь приземлённая: научиться писать код так, чтобы он не зависел от угадываний, и понимать, когда std::async действительно запускает работу параллельно, а когда — нет.

2. Базовые понятия и синтаксис

Мини-словарь: callable, задача, результат, future

Перед тем как писать код, давайте проговорим термины «по-человечески».

Callable — это «то, что можно вызвать». Обычная функция — callable. Лямбда — callable. Иногда даже объект с operator() — тоже callable (но в эту глубину мы сегодня не ныряем).

Задача — это ваш callable + его аргументы: то, что вы хотите выполнить.

Результат — значение, которое возвращает callable (например, int), либо просто факт завершения (void).

std::future<T> — объект, который связан с «ячейкой результата» (часто говорят shared state, общее состояние результата). Вы получите значение позже, когда оно будет готово.

Для std::async важно запомнить одну мысль: он запускает callable и возвращает future. А вот как именно запускает — зависит от политики.

(Кстати, даже в редакторских отчётах и правках стандарта можно увидеть, что у std::async есть свой «уголок» в разделе futures — это не «побочная утилита», а полноценная часть модели результатов.)

Базовый синтаксис std::async: «запустил и получил future»

Начнём с минимального примера: функция складывает два числа, но мы запускаем её через std::async.

#include <future>
#include <iostream>

int add(int a, int b) {
    return a + b;
}

int main() {
    std::future<int> f = std::async(std::launch::async, add, 2, 3);
    std::cout << f.get() << '\n'; // 5
}

Обратите внимание на три вещи.

  • Первая — подключаем <future>, потому что std::async и std::future живут там.
  • Вторая — мы передали политику std::launch::async. Почему мы сделали это явно, раз уж мы «хотим async»? Потому что без политики вы легко ошибётесь в ожиданиях, и это центральная мысль сегодняшней лекции.
  • Третья — get() пока выглядит как «ну ладно, забрал значение». Сегодня мы не разбираем жизненный цикл future глубоко, но важно понимать: get() — это точка, где вы реально «получаете результат», и если результата ещё нет, вы будете ждать.

3. Политики запуска std::launch

Теперь — самая важная часть.

У std::async есть политики запуска, задаваемые через std::launch.

std::launch::async

Эта политика означает: «выполняй задачу асинхронно». В типичном представлении это будет отдельный поток выполнения (или эквивалентная «асинхронная активность» внутри реализации).

То есть ваш callable начнёт выполняться сразу, а основной поток продолжит жить дальше.

std::launch::deferred

Эта политика означает: «отложи выполнение до тех пор, пока кто-то не попросит результат».

Самое важное здесь: при deferred задача выполняется в том потоке, который вызвал ожидание (get() или wait()), то есть часто — прямо в main. Это не параллельность. Это «ленивый запуск».

Почему это может быть полезно? Например, вы хотите описать вычисление, но запускать его только если оно реально понадобится. Это похоже на «я приготовлю отчёт, но только если вы реально нажмёте кнопку “показать отчёт”».

Демонстрация: deferred — это «позже и в том же потоке»

Давайте сделаем пример, который печатает порядок событий. Тут даже не нужен отдельный результат — главное увидеть момент выполнения.

#include <future>
#include <iostream>

int main() {
    auto f = std::async(std::launch::deferred, [] {
        std::cout << "work\n";
        return 10;
    });

    std::cout << "before get\n";
    std::cout << f.get() << '\n';
}

Ожидаемый вывод:

// before get
// work
// 10

Смысл такой: пока вы не вызвали get(), «работа» даже не начиналась.

«Когда это реально async»: ловушка политики по умолчанию

Вот здесь начинается место, где новичок чаще всего говорит: «Ну я же вызвал std::async, значит оно async!». А стандартная библиотека отвечает: «Ха-ха. Милый наивный человек».

Если вы вызываете std::async без явной политики, то вы обычно получаете «разрешено и так, и так»: реализация может выполнить задачу асинхронно, а может отложить. На практике это означает: на одной платформе вы увидите параллельность, на другой — нет, а на третьей — «иногда да, иногда нет».

Именно поэтому в курсе полезно придерживаться правила: если вам принципиальна параллельность — пишите std::launch::async явно.

Покажем это на ощущениях через «искусственно долгую» работу.

#include <chrono>
#include <future>
#include <iostream>
#include <thread>

int slow() {
    std::this_thread::sleep_for(std::chrono::seconds{1});
    return 7;
}

int main() {
    auto f = std::async(std::launch::async, slow);
    std::cout << "doing something...\n";
    std::cout << f.get() << '\n'; // 7
}

Если бы тут была политика deferred, «doing something...» могло бы напечататься, но сама slow() началась бы только на get(), и вы бы получили «псевдо-асинхронность»: вроде «я что-то делал», но параллельно ничего не считалось.

Наглядная схема: async против deferred

Чтобы это не было «магией из воздуха», зафиксируем в виде простой схемы.

flowchart TD
    A[main: вызывает std::async] --> B{политика запуска}
    B -->|launch::async| C[задача стартует сразу в другом потоке/активности]
    B -->|launch::deferred| D[задача НЕ стартует сразу]
    D --> E["main: позже вызывает get()/wait()"]
    E --> F[задача выполняется в потоке main]
    C --> G["main: позже вызывает get()/wait()"]
    G --> H[main получает результат]
    F --> H

Эта схема — ваш «анти-мистицизм». Если вы понимаете её, вы уже не будете писать код в стиле «ну, вроде должно распараллелиться».

4. Результаты, время жизни и дизайн

std::future<void>: когда результат не нужен, но завершение важно

Иногда вам не нужно значение. Вам нужно только дождаться, что задача завершилась. Например, «сохрани лог», «сбрось кеш», «закрой соединение красиво».

Тогда удобно возвращать void — и получить std::future<void>.

#include <future>
#include <iostream>

void work() {
    // делаем что-то полезное
}

int main() {
    std::future<void> f = std::async(std::launch::async, work);
    f.get(); // ждём завершения; значения нет
    std::cout << "done\n"; // done
}

Заметьте, что get() для future<void> — это «дождаться и проверить, что всё нормально». Сегодня мы не разбираем исключения в future, но мораль такая: future<void> — это тоже «канал результата», просто результатом является «факт успешного завершения».

Аргументы и время жизни: почему async «вдруг падает»

Сейчас будет важный момент, который ломает новичков чаще, чем запятые в cout.

std::async запускает работу «где-то потом» (или «где-то параллельно»). Значит, всё, что используется внутри задачи, должно жить достаточно долго.

Если вы захватили ссылку на локальную переменную, а функция уже закончилась — ссылка станет висячей. И это может проявиться как «рандомный краш», «рандомный мусор» или «всё работает на моём компьютере».

Плохой (опасный) стиль — захват по ссылке без гарантии времени жизни:

#include <future>
#include <string>

int main() {
    std::string name = "Alice";

    auto f = std::async(std::launch::async, [&] {
        return name.size();
    });

    return 0; // риск: задача может ещё читать name
}

Хороший (безопаснее) стиль — захват по значению:

#include <future>
#include <string>

int main() {
    std::string name = "Alice";

    auto f = std::async(std::launch::async, [name] {
        return name.size();
    });

    (void)f.get();
}

Да, это копия. Но для строки копия — цена за спокойствие. А оптимизировать мы будем тогда, когда код станет корректным (и когда появится ясная необходимость).

Нюанс проектирования: std::async — не «fire-and-forget»

У новичка очень быстро появляется соблазн: «О! Так я сейчас накидаю std::async по всей программе, и всё будет летать!». Это примерно как раздать всем сотрудникам по рации и думать, что компания стала эффективнее.

std::async подразумевает, что вы держите future, то есть у вас есть точка, где вы либо заберёте результат, либо хотя бы дождётесь завершения. Это дисциплина: если вы «теряете» future, вы теряете контроль над задачей.

И вот тут хороший стиль выглядит так: создали future → сохранили его в переменную → позже осознанно сделали get() (или wait(), но это будет дальше).

Аккуратная мысль на будущее: future и «общая ячейка результата»

Мы сегодня сознательно не углубляемся в жизненный цикл future, но полезно знать, что внутри есть «ячейка результата», и стандартная библиотека много лет шлифовала формулировки вокруг того, как эта ячейка создаётся и освобождается. Даже для future::get поднимался вопрос, насколько явно должно быть сказано, что общее состояние результата «отцепляется» и освобождается.

Это не для того, чтобы вас напугать. Это чтобы вы понимали: future — не «просто int в коробочке». Это объект с жизненным циклом, и вы должны относиться к нему дисциплинированно.

5. Практический пример: консольный генератор отчётов

Чтобы примеры не были набором «сферического коня в вакууме», давайте начнём собирать маленькое приложение, которое удобно расширять дальше.

Идея простая: у нас есть список «задач» (не потоки, а задачи проекта), и мы хотим уметь строить «отчёт» — это дорогая операция (в жизни: чтение, парсинг, агрегация). Пока отчёт строится, основной поток может продолжать что-то делать (в учебном примере — хотя бы печатать сообщения).

Шаг 1: модель данных и «дорогая функция»

#include <chrono>
#include <string>
#include <thread>
#include <vector>

struct Task {
    std::string title;
    int estimateHours;
};

int buildReport(const std::vector<Task>& tasks) {
    std::this_thread::sleep_for(std::chrono::milliseconds{300}); // имитируем работу
    int total = 0;
    for (const auto& t : tasks) total += t.estimateHours;
    return total;
}

Функция возвращает int: суммарную оценку в часах.

Шаг 2: запуск отчёта через std::async с явной политикой

#include <future>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{"Parse logs", 5}, {"Generate PDF", 2}};

    auto futureTotal = std::async(std::launch::async, buildReport, std::cref(tasks));
    std::cout << "report started...\n";

    int total = futureTotal.get();
    std::cout << "total hours = " << total << '\n'; // total hours = 7
}

Здесь появляется новая деталь: std::cref(tasks). Это аккуратный способ передать ссылку как аргумент (а не копировать весь vector). Если вы пока не уверены, можно упростить и передавать копию — для учебных размеров данных это нормально. Но я показываю std::cref, потому что он часто встречается и помогает не «перетаскивать вагон» данных туда-сюда.

Важно: мы явно указали std::launch::async, потому что именно сегодня мы учимся не гадать «а запустится ли параллельно».

Ещё одна демонстрация: два вычисления параллельно

Сделаем пример, который запускает два вычисления. Даже без замеров времени вы увидите разницу в подходе.

#include <chrono>
#include <future>
#include <iostream>
#include <thread>

int slowCalc(int x) {
    std::this_thread::sleep_for(std::chrono::milliseconds{200});
    return x * 2;
}

int main() {
    auto a = std::async(std::launch::async, slowCalc, 10);
    auto b = std::async(std::launch::async, slowCalc, 20);

    std::cout << a.get() + b.get() << '\n'; // 60
}

Если обе задачи действительно стартуют параллельно, суммарное время будет ближе к 200 мс (плюс накладные расходы), а не к 400 мс. Если бы вы поставили deferred, обе задачи начали бы выполняться только на get(), и тогда вы почти гарантированно получили бы «одна за другой» (потому что get() вызывается последовательно).

6. Типичные ошибки при работе с std::async и политиками запуска

Ошибка №1: использовать std::async без политики и ожидать гарантированную параллельность.
Такой код может «случайно» работать параллельно на вашей машине и внезапно стать отложенным на другой. Если параллельность — часть логики (например, вы рассчитываете перекрыть ожидание диска/сети/CPU), лучше явно писать std::launch::async, чтобы контракт был очевиден и вам, и читателю кода.

Ошибка №2: думать, что std::launch::deferred — это «запуск через время».
deferred не означает «подожди 2 секунды и начни». Он означает «не начинай вообще, пока я не попрошу результат». То есть время запуска определяется не таймером, а моментом get()/wait().

Ошибка №3: захватывать по ссылке локальные переменные, которые могут умереть раньше задачи.
Это классическая ловушка лямбд в конкурентном коде. Вы смотрите на переменную, она такая живая и симпатичная, а через миллисекунду main уже закончился, переменная исчезла, а задача всё ещё пытается к ней обратиться. Если нет железной гарантии времени жизни — захватывайте по значению или передавайте данные так, чтобы владелец жил дольше.

Ошибка №4: «потерять» future и считать, что задача теперь живёт сама по себе.
std::async — это не detach. Если вы не храните future, вы лишаете себя нормальной точки синхронизации и точки наблюдения результата. Правильнее считать future обязательной частью контракта: запустил задачу — сохранил ручку — позже осознанно дождался и/или забрал результат.

Ошибка №5: пытаться засунуть в std::async всё подряд, включая работу с общими структурами данных
std::async запускает вычисление, но не отменяет правила конкурентности. Если внутри задачи вы лезете в общие данные, вам всё так же нужны договорённости: кто владелец, кто читатель, где мьютекс, где условная переменная. std::async — это способ получить результат «позже», а не универсальный амулет от гонок данных.

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