Visual walkthrough — std - promise and std - future
Step 1 — Two threads that cannot talk
WHAT. Imagine two independent workers running at the same time. A thread is just "a line of code executing on its own", like a second cursor stepping through instructions while the first keeps going (see std - thread). Draw them as two parallel lanes.
WHY. The whole reason promise/future exists is that these two lanes are separate. Thread A (the consumer) wants a number. Thread B (the producer) is the one actually computing it. But B cannot simply return its answer to A — a thread function's return value goes nowhere the creator can read.
PICTURE. Two lanes side by side, a big gap between them, and a red "?" showing A has no way to reach B's result.
Step 2 — Put a mailbox in the gap: the shared state
WHAT. We drop a small box in the middle, reachable by both lanes. The parent calls it the shared state. It is a chunk of memory on the heap holding four things:
value— where the number will eventually sit.ex— an alternative slot if instead of a number, something went wrong.ready— abool(true/false) meaning "is the box filled yet?".m,cv— the two synchronization tools we earn in Steps 5–6.
WHY. A single shared box means both threads point at the same memory, so a value written by one is visible to the other. That is the only way information crosses the red gap.
PICTURE. The box sitting in the gap, both lanes drawing an arrow to it. ready starts false (empty box).
Step 3 — Two handles onto one box
WHAT. We never touch the box directly. Instead each thread gets a handle:
You create the promise, then ask it for the matching future:
Call get_future() once; a second call throws future_error.
WHY. Splitting into write-only and read-only handles enforces a one-directional flow: producer fills, consumer reads. No one can accidentally do both.
PICTURE. The box unchanged; a blue promise handle clipped to the left lane, an orange future handle clipped to the right lane, both wired to the same box.
Step 4 — The consumer arrives early and must wait
WHAT. Real timing: thread A often reaches fut.get() before thread B has computed anything. The box is still empty (ready == false). What should A do?
WHY. Two bad options, one good:
- Busy-spin — loop checking
readymillions of times. This burns 100% of a CPU core doing nothing. Rejected. - Sleep forever — but then who wakes A when the value lands? Unsolved without a tool.
- Sleep, and be woken on write — the correct answer. This is exactly the job of the tool in Step 6.
PICTURE. A reaches the box, sees ready = false, and a clock icon shows it parking (sleeping) rather than spinning.
Step 5 — Why a mutex: stop the two hands colliding
WHAT. Producer B writes value and flips ready; consumer A reads them. If both touch the same memory at the same instant, that is a data race — undefined behavior (the program may print garbage or crash). The fix is a mutex m: a lock that only one thread can hold at a time (see std - mutex and lock_guard).
WHY. A mutex serializes access: while B holds m to write, A cannot read half-written data, and vice-versa. The write of value + ready becomes atomic as seen by A.
PICTURE. A padlock on the box. B holds the key (writing); A waits outside the locked door.
Step 6 — Why a condition_variable: sleep now, wake on write
WHAT. A condition_variable cv (see std - condition_variable) lets a thread sleep while releasing the mutex, and be woken by another thread that calls notify. The consumer waits like this:
lk— the lock;waitreleases it while sleeping (so the producer can grab it) and re-locks on wake.- the predicate
[]{return ready;}— a tiny yes/no test re-checked every wake.
WHY the predicate and not a bare wait(lk)? Two reasons drawn in the figure:
- Spurious wakeup — a
cvmay wake with no notify. The predicate says "readystill false → go back to sleep." No false start. - Set-before-wait race — if B already filled the box before A reached
wait,readyis already true, sowaitreturns instantly without sleeping. No missed wakeup.
PICTURE. A timeline: A calls wait, releases the lock, sleeps. B writes under the lock and calls notify_all(). A wakes, re-checks predicate (ready == true), proceeds.
Step 7 — The write: set_value fills the box and rings the bell
WHAT. The producer finishes and does:
{
std::lock_guard<std::mutex> lk(state->m);
state->value = v; // (1) drop the result in
state->ready = true; // (2) flip the flag
} // (3) unlock as the block ends
state->cv.notify_all(); // (4) ring the bell -> wake the sleeperTerm by term: (1) stores the actual data, (2) tells any reader "data is present", (3) releases the mutex so the reader can lock it, (4) notify_all wakes A's cv.wait.
WHY this order. Value before flag: if we set ready first and got interrupted, A could wake and read an empty value. Flag under the lock guarantees A sees a fully-written box.
PICTURE. Box now filled (green value), ready flipped to true, a bell ringing an arrow toward the sleeping consumer.
Step 8 — The read: get() moves the value out, once
WHAT. The consumer, now awake with predicate true, runs:
if (state->ex) std::rethrow_exception(state->ex); // exception path (Step 9)
return std::move(state->value); // value pathstd::move(state->value) moves the result out (cheap for big objects) and leaves the box drained. After get(), the future is invalid: a second get() throws future_error(no_state). This is the one-shot guarantee.
WHY move, not copy? The box owns the value and is done with it; moving avoids copying a possibly-large object and marks the channel as consumed. Check the state with fut.valid() — true before get(), false after.
PICTURE. The value flowing out along an orange arrow into A's hand; the box now empty and valid() flipping from 1 → 0.
Step 9 — Edge cases: exception, and broken promise
WHAT. Two ways the happy path fails, both handled by the same box:
-
Exception transport. Instead of a value, the producer ships an error:
catch (...) { p.set_exception(std::current_exception()); }The
exslot fills instead ofvalue. On the reading side,get()seesexis set and re-throws it in the consumer's thread (see Exception handling in C++). An error born in thread B is now catchable in thread A. -
Broken promise (degenerate case). If the producer is destroyed without ever calling
set_value/set_exception, the shared state marks itself broken. Thenget()throwsfuture_error(broken_promise)— so a stuck consumer is woken with an error rather than hanging forever.
WHY these matter. Without (1) a thrown exception in B would call std::terminate and kill the whole program. Without (2) a forgotten set_value would freeze A forever. The box covers every exit path: value, error, or abandonment.
PICTURE. Three-way fork out of the box: green "value" arrow, orange "exception re-thrown" arrow, red "broken_promise" arrow.
The one-picture summary
Everything above compressed into a single frame: producer lane on the left holding the promise, consumer lane on the right holding the future, the shared-state box bridging the red gap, and the three possible outcomes leaving the box.
Recall Feynman retelling — say it back in plain words
Two workers run in separate lanes and can't hand things to each other directly. So we put a little box in the gap they can both reach. The promise is the write-handle, the future is the read-handle — we grab the read-handle first, then send the write-handle to the other worker. The reader shows up early, checks a ready flag, and — instead of spinning uselessly — goes to sleep using a condition variable that will wake it the moment the box is filled. A mutex makes sure nobody reads a half-written box. The producer, under the lock, drops the value in, flips ready to true, and rings the bell. The sleeper wakes, double-checks the flag (so a fake wakeup or an early write can't fool it), and moves the value out — exactly once. If something went wrong, the producer stuffs the exception into the box instead, and the reader's get() re-throws it right there. And if the producer vanishes without filling the box, the reader gets a broken_promise error instead of hanging forever. That's the whole machine.
Recall Quick self-test
Why sleep with a predicate, not a bare wait? ::: To survive spurious wakeups and the set-before-wait race — the predicate re-checks ready on every wake.
Why store the value before flipping ready? ::: So a reader that sees ready==true is guaranteed to see a fully-written value, never a half-written one.
What are the three ways the box can be emptied? ::: A value (set_value), an exception (set_exception, re-thrown by get), or abandonment (broken_promise).
Related tools: std - async and std - launch and std - packaged_task wrap this same box for you; std - shared_future lets many readers share one box.