1. Зачем нужны std::promise и std::packaged_task
Если std::async — это «вызови функцию где-то там и верни мне future», то std::promise и std::packaged_task — инструменты для случаев, когда вычисление не похоже на обычный вызов функции. Иногда результат появляется не потому, что мы вызвали return, а потому что «что-то случилось»: пришёл ответ, закончилось ожидание, обработался файл, таймер сработал, пользователь нажал кнопку. В таких сценариях удобно разделить роли: один кусок кода производит результат, другой ждёт и потребляет.
И вот тут std::async становится не таким универсальным. Он хорош, когда у вас есть один callable, который возвращает значение. А если производитель результата живёт своей жизнью, где «возврат» вообще не выглядит как возврат из функции, хочется иметь отдельный канал доставки результата. Этот канал и даёт пара promise/future.
Небольшая подсказка из жизни: promise в названии — это буквально «обещание». Вы как бы говорите: «Я обещаю, что позже положу сюда результат». Только это обещание проверяется стандартной библиотекой (и иногда довольно сурово).
Shared state: связь promise и future
Прежде чем писать код, полезно понять внутреннюю модель на уровне идеи. И std::future, и std::promise работают вокруг одной сущности: общего состояния результата (часто говорят shared state). Представьте себе маленькую «почтовую коробку», в которую можно положить либо значение, либо информацию об ошибке, а другая сторона может эту коробку открыть, дождаться её заполнения и забрать содержимое.
flowchart LR
P["std::promise<T><br/>поставщик (producer)"] -->|"set_value(...)"| S[(shared state<br/>ячейка результата)]
S -->|"get() / wait()"| F["std::future<T><br/>потребитель (consumer)"]
Ключевой момент: future сам по себе не производит результат. Он только ждёт и забирает. А promise сам по себе не ждёт. Он только кладёт результат. Оба они — как две половинки одного кабеля: по отдельности почти бессмысленны, вместе — очень удобно.
2. std::promise<T>: вручную доставляем результат
Минимальный протокол: promise → future → set_value
Сейчас будет важный «скелет» использования std::promise. Он почти всегда одинаковый и повторяется во многих программах. Сначала вы создаёте promise<T>, затем получаете из него future<T>, а потом где-то в другом месте вызываете set_value(...), чтобы записать результат.
#include <future>
#include <iostream>
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
p.set_value(123);
std::cout << f.get() << '\n'; // 123
}
Обратите внимание на слегка «нелогичную» на первый взгляд вещь: get_future() вызывается на promise, а не на future. Это потому что именно promise «владеет» правом создать будущего потребителя результата. И ещё один тонкий момент: обычно get_future() можно вызвать только один раз — это не автомат с бесконечными билетиками.
promise в другом потоке: std::move и mutable
В реальности promise обычно живёт в другом потоке: там, где реально выполняется работа. Поэтому promise часто перемещают (std::move) внутрь лямбды потока. Это классический паттерн.
#include <future>
#include <iostream>
#include <thread>
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
std::jthread worker([pr = std::move(p)]() mutable {
pr.set_value(42);
});
std::cout << f.get() << '\n'; // 42
}
Почему тут mutable? Потому что лямбда по умолчанию делает свой operator() константным, а set_value — это изменение состояния promise. Без mutable компилятор справедливо скажет: «Вы пытаетесь изменить pr внутри const-оператора».
Сигнал завершения: std::promise<void>
Иногда вам не нужно значение, вам нужен только факт завершения. Это как «дойдём до финиша — помашем флажком». Для этого удобно использовать специализацию void.
#include <future>
#include <iostream>
#include <thread>
int main() {
std::promise<void> p;
std::future<void> f = p.get_future();
std::jthread worker([pr = std::move(p)]() mutable {
// ... делаем работу
pr.set_value(); // сигнал "готово"
});
f.get(); // просто ждём
std::cout << "done\n"; // done
}
Такой код хорошо читается: future<void> — это буквально «подожди завершения».
3. TaskConsole: отделяем запуск работы от канала результата
Давайте договоримся о контексте, чтобы примеры не были набором отдельных обрывков. Представим, что у нас есть простое консольное учебное приложение TaskConsole, которое умеет запускать «задачи» в фоне и получать результат. Мы не строим полноценный пул потоков (это отдельная большая тема), но хотим научиться отделять: кто считает, а кто ждёт результат.
Начнём с «задачи», которая что-то считает. Пусть это будет игрушечная, но понятная функция: «тяжёлая сумма» (просто чтобы было что запускать).
#include <chrono>
#include <thread>
int slow_add(int a, int b) {
std::this_thread::sleep_for(std::chrono::milliseconds{200});
return a + b;
}
Теперь сделаем функцию, которая запускает worker и возвращает future<int>. Это будет первый «кусок архитектуры» нашего TaskConsole: submit_* возвращает future, а main решает, когда его ждать.
#include <future>
#include <thread>
std::future<int> submit_slow_add(int a, int b) {
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread([pr = std::move(p), a, b]() mutable {
pr.set_value(slow_add(a, b));
}).detach(); // в реальном коде detach опасен; здесь только демонстрация
return f;
}
Здесь намеренно показан detach() как «демо-ход», чтобы уместить пример в маленький кусок. В реальном проекте мы бы чаще хранили поток, использовали std::jthread без detach, или делали бы другую дисциплину времени жизни. Но идея лекции не про «как правильно держать потоки», а про «как доставить результат».
Мини-проверка в main:
#include <iostream>
int main() {
auto f = submit_slow_add(10, 32);
std::cout << "waiting...\n";
std::cout << f.get() << '\n'; // 42
}
Если у вас сейчас возникло желание сказать: «Подождите, но std::async же так умеет!» — вы правы. Пока пример похож на async. Но сейчас мы сделали важный шаг: отделили канал результата от механизма запуска. Это пригодится дальше, когда результат будет появляться не как return, а по более сложному событию.
Очередь «задача + future» без пула потоков
Сейчас сделаем маленький шаг в сторону «диспетчеризации», но очень осторожно: без пула потоков, без сложной синхронизации и без архитектурного монстра. Мы просто покажем идею, зачем std::packaged_task удобен: задачу можно хранить как объект, а результат — через future.
Создадим «пачку задач» и запустим их по одной в отдельном потоке (это не эффективно, зато понятно).
#include <future>
#include <thread>
std::future<int> run_detached(std::packaged_task<int()> task) {
auto f = task.get_future();
std::thread([t = std::move(task)]() mutable { t(); }).detach();
return f;
}
Теперь можно создавать задачи как лямбды и получать futures:
#include <iostream>
int main() {
auto f1 = run_detached(std::packaged_task<int()>([] { return 40; }));
auto f2 = run_detached(std::packaged_task<int()>([] { return 2; }));
std::cout << (f1.get() + f2.get()) << '\n'; // 42
}
Заметьте, как приятно выглядит API: «вот задача, верни мне future». Это прям то, ради чего packaged_task часто используют: когда вы хотите разделить постановку задачи и её выполнение, но при этом сохранить простой канал результата.
4. std::packaged_task: callable, который сам заполняет future
Зачем нужен packaged_task, если есть promise
std::packaged_task — это удобная обёртка вокруг callable (функции, лямбды, функционального объекта). Она делает две вещи одновременно: хранит callable и управляет общим состоянием результата так, что при вызове задачи результат автоматически попадает в связанный future.
Если promise — это «ручной курьер» (вы сами решаете, что положить и когда), то packaged_task — это «курьер + инструкция», где инструкция — это callable. Вызвал task — он сам доставил результат.
Мини‑пример: упаковали функцию и получили future
#include <future>
#include <iostream>
#include <thread>
int mul(int a, int b) { return a * b; }
int main() {
std::packaged_task<int(int, int)> task(mul);
std::future<int> f = task.get_future();
std::jthread worker([t = std::move(task)]() mutable {
t(6, 7); // после вызова future станет ready
});
std::cout << f.get() << '\n'; // 42
}
Смысл читается почти как по-русски: «упакуй умножение в task, возьми future, потом где-то вызови task — и получишь результат».
И да: packaged_task, как и promise, обычно перемещаемый объект. Это логично: внутри него есть состояние, которое нельзя безопасно копировать как попало.
Мини‑пример: packaged_task с лямбдой без параметров
Иногда удобнее сделать задачу без параметров (например, вы уже «захватили» всё нужное в лямбду). Тогда сигнатура будет R().
#include <future>
#include <iostream>
#include <string>
int main() {
std::string name = "C++";
std::packaged_task<std::string()> task([name] {
return "Hello, " + name;
});
auto f = task.get_future();
task(); // можно вызвать и в текущем потоке
std::cout << f.get() << '\n'; // Hello, C++
}
Это хороший пример того, что packaged_task не обязан выполняться в другом потоке. Он просто «приклеивает» результат к future. Где вы реально выполните task — это уже дизайн вашей программы.
5. Как выбрать инструмент и не сломать дисциплину
promise vs packaged_task vs async
На этом месте легко впасть в панику: три инструмента, все «про асинхронность», похожи, но не одинаковы. Давайте стабилизируем картинку. Ниже таблица, которую полезно держать в голове, чтобы не пытаться забивать гвозди микроскопом.
| Инструмент | Что вы пишете | Что получаете | Кто «кладёт результат» | Когда удобно |
|---|---|---|---|---|
|
callable + аргументы | future<R> | библиотека (через return/исключение callable) | «Запусти вот это и верни результат позже» |
|
вы сами вызываете set_value | future<T> | вы вручную | «Результат появится как событие, не как return» |
|
callable упакован в объект | future<R> + объект task | task при вызове | «Хочу хранить/передавать задачу как объект и получать future» |
Если очень грубо, то async — самый «высокоуровневый», promise — самый «ручной», а packaged_task — это когда вы хотите сделать «задачу как сущность», чтобы её можно было куда-то положить и потом выполнить.
Дисциплина времени жизни и ответственности
Когда вы начинаете пользоваться promise и packaged_task, появляется новая ответственность, которой почти нет в «простом» коде. Вы должны гарантировать, что общий результат будет завершён: либо значением, либо ошибкой. Иначе потребитель будет ждать, а потом получит неприятный сюрприз.
Практически это означает следующее. Если вы создали future, то где-то в мире существует его «поставщик» (promise или task). Поставщик должен либо записать значение, либо сообщить об ошибке. Если поставщик исчезает, не выполнив обещание, потребитель узнает об этом при get() (там будет ошибка «сломанное обещание», broken promise).
На уровне дисциплины полезно принять простое правило: не создавайте канал результата, если вы не можете гарантировать, что когда-нибудь по нему придёт финал. Стандартная библиотека ведёт себя как строгий преподаватель: «обещал — сделай». И это, в целом, справедливо.
6. Типичные ошибки
Ошибка №1: пытаться вызвать get_future() дважды.
Новички иногда ожидают, что можно получить «несколько futures» на один promise или один packaged_task. В базовой модели это не так: get_future() обычно выдаёт единственного потребителя. Если вам нужно несколько потребителей, это уже другая конструкция (и это не тема этой лекции).
Ошибка №2: забыть mutable, когда перемещаете promise/packaged_task в лямбду.
Лямбда по умолчанию константная, а set_value() или вызов packaged_task::operator() меняет состояние объекта. Итог — ошибка компиляции, которая выглядит «как будто ни с того ни с сего». Лечится тем, что вы добавляете mutable и вспоминаете, что «внутри лямбды мы меняем захваченный объект».
Ошибка №3: захватить по ссылке данные, которые не переживут работу потока.
Очень типичная история: вы создаёте promise, запускаете поток, а внутри лямбды используете ссылку на локальную переменную из main. main уже ушёл дальше, переменная умерла, а поток ещё живёт. В лучшем случае получите странные значения, в худшем — падение. В асинхронном коде это происходит особенно легко, поэтому захват по значению и продуманное время жизни — не занудство, а страховка.
Ошибка №4: путать «упаковать задачу» и «выполнить задачу».
std::packaged_task сам по себе ничего не делает. Он не запускается автоматически. Если вы создали task, получили future и забыли вызвать task, то future будет вечно «не готов». Это похоже на ситуацию «купил будильник, но не завёл».
Ошибка №5: строить слишком сложную архитектуру там, где достаточно std::async.
Иногда promise и packaged_task кажутся «взрослее», и рука тянется использовать их везде. Это ловушка. Если задача естественно выражается как функция «посчитай и верни», то std::async часто будет проще и понятнее. promise и packaged_task раскрываются тогда, когда вам реально нужно разделить producer/consumer или хранить задачу как объект (например, для диспетчеризации).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ