5.2.28 · D3C++ Programming

Worked examples — std - promise and std - future

3,209 words15 min readBack to topic

Before we start, one picture of the whole thing so every later example points back to it. A promise and a future share a single heap block — the shared state.

Figure — std - promise and std - future

Read the figure left to right. The magenta box on the left is the std::promise — the writing end. The violet box on the right is the std::future — the reading end. The dashed navy arrow across the top is prom.get_future(): called once, it hands the reading end its link to the shared block. The orange box at the bottom is the shared state itself, living on the heap, holding four things: a slot for the value or an exception, a ready flag, and the two synchronization tools — a mutex (call it m, a lock that lets only one thread touch the box at a time) and a condition variable (call it cv, a "waiting bench" a thread can sleep on until it is woken). The magenta arrow is set_value / set_exception writing into the box once; the violet arrow is get() reading out of it once. Every example below is a different journey along these two arrows.

Two identifiers from that picture will keep coming back, so we name them now, once:

  • ==m== — the mutex (from mutex): a lock guaranteeing only one thread reads or writes the box at any instant, so there is no data race.
  • ==cv== — the condition variable (from condition_variable): the bench a thread sleeps on inside get() until set_value wakes it. A ==lk== is just a unique_lock on m — the handle a thread holds while it sleeps on cv, released automatically while asleep and re-acquired on waking.

The scenario matrix

Every use of promise/future lands in exactly one of these cells. Think of the columns as "what got written" and the rows as "the timing / who reads".

Cell Situation What the future's get() does
A. Value, reader waits Producer sets a value; consumer already blocked in get() Sleeps, is woken, returns the value
B. Value, value-set-first Producer sets the value before consumer calls get() Returns instantly — no sleep
C. Exception transported Producer stores an exception get() re-throws it in the consumer thread
D. Broken promise Producer is destroyed without setting anything get() throws future_error(broken_promise)
E. One-shot violation Consumer calls get() twice, or get_future() twice Throws future_error(no_state / future_already_retrieved)
F. Zero / degenerate payload promise<void> — a pure "done" signal, no data get() returns nothing, just unblocks
G. Multiple readers Many threads must all see the same result Plain future fails (one-shot); use [[std - shared_future
H. Limiting: timeout Consumer can't wait forever wait_for returns a status, get() avoided until ready
I. Real-world word problem Two heavy computations run in parallel, main thread sums both Two mailboxes, two get()s
J. Exam twist Chained: packaged_task feeds a future; where is the promise? The task is the writing end

The nine examples below hit every cell A–J. Each one names its cell.


Example 1 — Cell A: value, reader is already waiting

Forecast: guess the printed number, and guess whether main slept or busy-spun.

#include <future>
#include <thread>
#include <chrono>
#include <iostream>
 
void worker(std::promise<int> p) {
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    p.set_value(6 * 7);                 // Step 3
}
 
int main() {
    std::promise<int> prom;
    std::future<int>  fut = prom.get_future();   // Step 1
    std::thread t(worker, std::move(prom));      // Step 2
    std::cout << fut.get() << "\n";              // Step 4
    t.join();
}
  1. get_future() before the move. Why this step? Once we std::move(prom) in step 2 the local prom is empty; asking it for a future then would throw. Grab the reading end first.
  2. std::move(prom) into the thread. Why this step? std::promise is move-only (see Move semantics and std::move); copying would create two writers to one shared state.
  3. set_value(6 * 7) after the sleep. Why this step? This writes the value , flips the ready flag, and wakes any waiter on cv — exactly the set_value block in the parent's hand-built version.
  4. fut.get(). Why this step? main reaches here well before 50 ms are up, so the shared state is not ready. get() puts main to sleep on the condition variable cv (not a busy loop), and the CPU is free until step 3 wakes it.

Verify: the printed value is . Sanity on timing: main cannot print before ~50 ms, proving it genuinely blocked rather than reading a stale zero.


Example 2 — Cell B: value set before the reader waits

Forecast: will get() return instantly, hang forever, or throw?

std::promise<int> prom;
auto fut = prom.get_future();
prom.set_value(100);        // ready flag flips to true HERE
int x = fut.get();          // called AFTER ready is already true
  1. set_value(100) runs first. Why this step? It sets ready = true and calls notify_all() on cv. There is no one waiting yet, so the notify wakes nobody — and that is fine.
  2. fut.get() runs second. Why this step? Recall the parent's predicate cv.wait(lk, []{ return ready; }) — where cv is the condition variable and lk is the lock the thread holds while sleeping (both named in the overview above). Because ready is already true, the predicate is satisfied before sleeping, so wait returns immediately without ever touching cv. A missed notify is harmless — the predicate, not the signal, is the source of truth.

Verify: x == 100, returned with zero blocking. This is precisely why the standard library uses a predicate wait and not a bare wait(lk).


Example 3 — Cell C: transporting an exception across threads

Forecast: does the program crash, or does main catch the message?

void worker(std::promise<int> p) {
    try {
        throw std::runtime_error("disk on fire");
    } catch (...) {
        p.set_exception(std::current_exception());   // Step 1
    }
}
 
int main() {
    std::promise<int> prom;
    auto fut = prom.get_future();
    std::thread t(worker, std::move(prom));
    try {
        int x = fut.get();                            // Step 2
        std::cout << x;
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << "\n";  // Step 3
    }
    t.join();
}
  1. set_exception(std::current_exception()). Why this step? std::current_exception() grabs the exception currently in flight and packages it as an exception_ptr. We store it in the shared state's exception slot instead of a value. (See Exception handling in C++.)
  2. fut.get(). Why this step? When the shared state holds an exception, get() does not return — it calls rethrow_exception, re-raising the same exception object here in main's thread.
  3. The catch. Why this step? Because the exception was re-thrown at the get() call, an ordinary try/catch around it works. The thread boundary vanished.

Verify: output is exactly caught: disk on fire, and the program does not terminate. Sanity check: only the exception slot was used — set_value was never called, so there is no half-written value.


Example 4 — Cell D: the broken promise

Forecast: does get() hang forever (deadlock), or does it throw a specific error?

void worker(std::promise<int> p) {
    // oops: no set_value, no set_exception — p is destroyed here
}
 
int main() {
    std::promise<int> prom;
    auto fut = prom.get_future();
    std::thread t(worker, std::move(prom));
    t.join();
    try {
        int x = fut.get();                                   // Step 2
        std::cout << x;
    } catch (const std::future_error& e) {
        std::cout << e.code().value() << "\n";               // Step 3
    }
}
  1. Promise destroyed unset. Why this matters? The standard says: if a promise's shared state is abandoned (destructor runs with no value and no exception), the destructor automatically stores a future_error with code broken_promise and marks the state ready. This is a safety net so the reader never deadlocks.
  2. fut.get(). Why this step? The state is ready (because of the auto-stored error), so get() unblocks — but it holds an exception, so it re-throws future_error.
  3. Catch and inspect e.code(). Why this step? Confirms the error class is broken_promise, not something else.

Verify: get() throws, it does not hang. The library's guarantee: an unset promise still wakes its future, with broken_promise. Every path in your producer must therefore either set_value or set_exception.


Example 5 — Cell E: one-shot violations (two get()s, two get_future()s)

Forecast: for each abuse, which call throws and what does fut.valid() read just before it?

// Abuse (a): two get_future() calls
std::promise<std::string> prom;
auto f1 = prom.get_future();
// auto f2 = prom.get_future();   // THROWS: future_already_retrieved
 
// Abuse (b): two get() calls
prom.set_value("hello");
std::cout << f1.valid() << "\n";  // Step 2 -> 1
std::string s = f1.get();         // Step 3 : moves the string out
std::cout << f1.valid() << "\n";  // Step 4 -> 0
// f1.get();                      // Step 5 THROWS: no_state
  1. Second get_future() (abuse a). Why illegal? The shared state can be handed to one reading end. A second retrieval would create two futures fighting over one value, so it throws future_already_retrieved.
  2. valid() before the first get(). Why this step? valid() asks "am I still attached to a shared state?" Here it is 1 (true) — attached and unread.
  3. First get(). Why this step? It moves the string out of the shared state and detaches the future.
  4. valid() after get(). Why this step? Now 0 (false) — the future has been consumed and holds no state.
  5. Second get() (abuse b). Why illegal? With no state left, there is nothing to return, so it throws future_error(no_state). If you need many reads, convert to a shared_future (Example 8).

Verify: valid() prints 1 then 0, bracketing the single legal get(). It is the cheap flag that tells you whether a get() is still allowed.


Example 6 — Cell F: the zero / degenerate payload, promise<void>

Forecast: what does set_value() look like with no T, and what does get() return?

#include <future>
#include <thread>
#include <iostream>
 
int main() {
    std::promise<void> done;                 // T is void: no storage slot
    std::future<void>  fut = done.get_future();
 
    std::thread t([p = std::move(done)]() mutable {
        // ... do setup work ...
        p.set_value();                       // Step 2 : no argument!
    });
 
    fut.get();                               // Step 3 : returns void, just unblocks
    std::cout << "proceeding\n";
    t.join();
}
  1. promise<void>. Why this step? When there is no result, the value slot degenerates to nothing. The ready flag, mutex m and condition variable cv are still there — the signalling machinery is what we want, not the data.
  2. p.set_value() with empty parentheses. Why this step? void has no object to store, so set_value is the pure "flip ready and notify" operation.
  3. fut.get() returning void. Why this step? It still blocks until ready, then returns nothing. This is a clean one-shot barrier between two threads — same guarantees as condition_variable but with the plumbing hidden.

Verify: conceptually, promise<void> = the value cell removed, ready/mutex/cv retained. "proceeding" prints only after the worker's set_value(), proving the empty-payload signal still synchronizes.


Example 7 — Cell H: the limiting case, a deadline with wait_for

Forecast: after 100 ms of waiting, does wait_for say ready, timeout, or deferred?

using namespace std::chrono;
 
std::promise<int> prom;
auto fut = prom.get_future();
std::thread t([p = std::move(prom)]() mutable {
    std::this_thread::sleep_for(milliseconds(300));   // slow producer
    p.set_value(7);
});
 
auto status = fut.wait_for(milliseconds(100));        // Step 1
if (status == std::future_status::timeout) {
    std::cout << "gave up waiting\n";                 // Step 2
} else if (status == std::future_status::ready) {
    std::cout << fut.get() << "\n";
}
t.join();
  1. wait_for(100ms). Why this step? Unlike wait() (blocks unconditionally) or get() (blocks and consumes), wait_for blocks at most the given duration and returns a future_status. It never consumes the value, so a later get() is still allowed once ready.
  2. Branch on future_status::timeout. Why this step? After 100 ms the producer (300 ms) is not done, so the status is timeout and we take the give-up branch — no crash, no hang.

Verify: with producer 300 ms and deadline 100 ms, , so wait_for returns timeout. (The ready branch and get()==7 would only fire if the deadline exceeded 300 ms.)


Example 8 — Cell G: many readers with shared_future

Forecast: what one conversion turns a single-use future into a broadcastable one, and can each thread call get()?

std::promise<int> prom;
std::shared_future<int> shared = prom.get_future().share();   // Step 1
 
auto reader = [shared](int id) {
    int v = shared.get();                                     // Step 3 : each reads OK
    std::cout << "reader " << id << " saw " << v << "\n";
};
 
std::thread a(reader, 1), b(reader, 2), c(reader, 3);
prom.set_value(55);                                           // Step 2
a.join(); b.join(); c.join();
  1. .share(). Why this step? future::share() moves the shared state into a std::shared_future, which is copyable and whose get() is not one-shot — it returns a const&/copy each call.
  2. set_value(55). Why this step? One producer still fulfils exactly once; the broadcast is on the reading side.
  3. Three get() calls. Why legal now? Each of the three copies of the shared_future can call get(); they all observe the same 55. With a plain future, only the first would succeed and the others would throw no_state.

Verify: all three readers print 55; total of three successful get()s (impossible with a plain one-shot future).


Example 9 — Cells I + J: real word problem, and the packaged_task twist

Forecast: what final number does main print, and how many futures are needed?

auto compute = [](std::promise<int> p, int a, int b) {
    p.set_value(a * b);
};
 
std::promise<int> pr, pp;
auto f_risk = pr.get_future();
auto f_pnl  = pp.get_future();
std::thread t1(compute, std::move(pr), 3, 100);   // risk = 300
std::thread t2(compute, std::move(pp), 5,  40);   // pnl  = 200
 
int total = f_risk.get() + f_pnl.get();           // Step 3
std::cout << total << "\n";
t1.join(); t2.join();
  1. Two promises, two futures. Why? Two independent results ⇒ two shared states running concurrently — that is Cell I's parallel-fan-out.
  2. Two threads compute simultaneously. Why? No data dependency between risk and pnl, so they overlap. See std::thread for how each is launched.
  3. f_risk.get() + f_pnl.get(). Why this step? main blocks on each in turn; total time ≈ the slower of the two, not their sum.

Verify: .

Now the exam twist (Cell J)where is the promise when you use a std::packaged_task?

std::packaged_task<int()> task([]{ return 6 * 7; });   // task hides a promise inside
std::future<int> fut = task.get_future();              // future from the task itself
std::thread(std::move(task)).detach();                 // running the task = fulfilling it
std::cout << fut.get() << "\n";                         // -> 42
  • Why no explicit promise? A packaged_task owns an internal promise. Invoking the task calls set_value with the return value (or set_exception if it throws) automatically. You interact only with the future. Contrast with std::async, which builds the whole promise/thread/future trio for you.

Verify: the packaged-task future yields , identical to Example 1 but with the promise machinery hidden inside the task.


Recall

Recall Which cell throws instead of blocking, and why?

Cell D (broken promise) ::: The promise's destructor auto-stores future_error(broken_promise) and marks ready, so get() throws rather than deadlocking.

Recall Why did Example 2's

get() not miss the notify? Value-set-first ::: cv.wait(lk, []{return ready;}) checks the predicate before sleeping; since ready was already true it returned instantly — the missed notify is irrelevant.

Recall What turns a one-shot future into a broadcast one?

.share() ::: It moves the state into a copyable std::shared_future whose get() may be called many times, once per reader.

Recall Which call gives a status instead of consuming the value?

wait_for ::: Returns future_status::ready / timeout / deferred after at most the given duration, without invalidating the future.