Build a Future / Promise
Build a Future / Promise
Every async API you've ever called — an HTTP client, a database driver, a thread pool's submit() — hands you back a value that doesn't exist yet. Thread Pools, Executors & Futures told you a Future is "a handle to a result that isn't ready yet"; this lab makes you build that handle from an empty file, in Java and Go, and break it the way real codebases break it — not with a lock you forgot, but with a callback you registered one instruction too late. If you've done Build a Thread Pool, you already used a bare-bones Future in its M4; this lab is the deep dive on the Future/Promise contract itself: the state machine, the completion race, and the callback chaining (thenApply) that turns a Future into the foundation of every reactive/async framework you'll touch.
1. The trap
You need to kick off work on another thread and get its result back later. Two "obvious" first drafts, both wrong in ways that don't show up in a quick manual test:
public final class Drafts {
static Object computeExpensiveThing() { return new Object(); }
// Draft A -- just block on a shared variable
static void draftA() throws InterruptedException {
Object[] resultBox = new Object[1];
new Thread(() -> { resultBox[0] = computeExpensiveThing(); }).start();
while (resultBox[0] == null) { /* wait... */ } // busy-wait: burns a full core
Object result = resultBox[0]; // and it's a data race: no happens-before edge at all
}
// Draft B -- a shared "done" flag, checked in a loop
static volatile boolean done = false;
static Object result;
static void draftB() throws InterruptedException {
new Thread(() -> { result = computeExpensiveThing(); done = true; }).start();
while (!done) { Thread.sleep(1); } // better than spinning, still polling
Object r = done ? result : null; // "done" is volatile, but is `result` guaranteed visible?
}
public static void main(String[] args) throws InterruptedException {
draftA();
done = false;
draftB();
}
}
Draft A is two bugs at once: a busy-wait that pins a core at 100% doing nothing useful, and a genuine data race — nothing establishes a happens-before edge between the writer thread setting resultBox[0] and the reader thread's repeated reads, so the compiler/JIT/CPU are free to cache the read forever and the loop can spin indefinitely even after the value is set. Draft B is more polite (it sleeps instead of spinning) but it's still polling: latency is bounded by your sleep interval, throughput is wasted on wake-ups that find nothing changed, and if you have N callers all waiting on the same computation, that's N separate poll loops burning cycles for one answer. Worse: done being volatile guarantees ordering for done itself, but the moment you add a second field (result) you're relying on the JMM's happens-before-via-volatile rule holding across two fields written by one thread and read by another — easy to get subtly wrong, easy to "fix" by sprinkling more volatile around code that should have used one synchronization primitive from the start.
What you actually want: block without spinning (sleep the calling thread, get woken exactly when the value exists), one write, many readers (multiple callers can all wait on the same pending result), and a way to say "and when it's ready, do X" without the caller having to ask at all. That object is a Future (the reader's handle) paired with a Promise (the writer's handle to complete it) — in most JVM/Go designs the two are fused into one type, which is what you'll build below.
2. Scope it like a senior
Before writing a line, pin down the contract:
- Single write, multiple reads? A Future is completed exactly once and can be read (via
get()or a callback) by any number of callers, including callers who show up after it's already done. That "already done" case is the whole lab — see Movement 3. - What does "get the result" mean before it exists? Block (the default), or fail fast, or wait with a deadline? We build blocking first (M1), add a deadline (M4) because "what if I don't want to wait forever" is the guaranteed follow-up.
- Push or pull?
get()is pull — the caller asks and blocks. A callback (whenComplete/thenApply) is push — the caller registers intent and walks away, and the Future calls them back. Real systems need both; a callback-only design can't express "block the main thread until this is done," and a get()-only design can't build a non-blocking pipeline. - Can it fail? A result is one of three outcomes, not two: pending, completed with a value, or failed with an exception. Skipping the third is the single most common corner-cutting bug in hand-rolled async code — the exception gets swallowed on the worker thread and the caller hangs or gets a
null. - What happens if two threads both try to set the result? Exactly one write must win, every other write must be a well-defined no-op — not a corrupted value, not a thrown exception from the loser, not a second silent overwrite.
Answering these up front is why java.util.concurrent.CompletableFuture and Go's errgroup/channel idioms look the way they do — they're not arbitrary APIs, they're the direct consequence of these five questions.
3. Reason to the design
Attempt 0 — a shared flag, polled. Movement 1's Draft B. Wrong for the reasons above: wasted cycles, bounded-by-sleep-interval latency, and a fragile multi-field happens-before story. Reject it and go straight to the primitive built for exactly this: a lock plus a condition variable (you built this in Condition Variables and in the Bounded Producer–Consumer lab — same mechanism, one waiter-side event instead of a queue).
Attempt 1 — a one-shot slot with a state machine. Model the Future as three states: PENDING → COMPLETED or PENDING → FAILED. Never any other transition — once you leave PENDING you're done forever. get() acquires the lock, loops while (state == PENDING) condition.await() (the exact same "loop, don't trust a one-shot wake" rule from the producer–consumer lab — spurious wakeups are still possible here), then reads the value or throws the wrapped exception. complete(value) acquires the lock, and here is the first design decision that isn't just "condition variable, again": it must check the current state before writing. If state is already COMPLETED/FAILED, complete() must be a no-op (return false), not a second write. Two threads racing to complete the same Future is normal — e.g. two redundant requests racing to populate one cache-fill Future — and exactly one of them should "win," deterministically, with the loser finding out cheaply.
Attempt 2 — add callbacks, and hit the actual race this lab is named for. get() alone can't build a non-blocking pipeline; you need whenComplete(callback) so a caller can say "run this when you're done" and walk away. The naive implementation:
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
final class NaiveFuture<T> {
static final int PENDING = 0, COMPLETED = 1, FAILED = 2;
int state = PENDING;
T value;
Throwable error;
List<BiConsumer<T, Throwable>> callbacks = new ArrayList<>();
void whenComplete(BiConsumer<T, Throwable> cb) {
if (state == PENDING) {
callbacks.add(cb); // register for later
return;
}
cb.accept(value, error); // already done -- fire now
}
}
looks right, and the logic is right — the bug is where the lock goes. If the state == PENDING check and the callbacks.add(cb) happen in separate critical sections (or no critical section at all), you've created a window: thread A checks state, sees PENDING, and is about to enqueue — but before it does, thread B calls complete(), which runs to completion and fires every callback currently in the list. Thread A's callback isn't in the list yet. It enqueues a moment later, into a list nobody will ever drain again. The callback is silently lost — not delayed, not out of order, gone. This is the register-after-complete race, and it's the single most common bug in hand-rolled Future/Promise implementations, because "check state, then act" reads like it should be atomic and isn't unless you make it so.
The fix, and the whole lesson of this lab: the state check and the enqueue-or-fire decision must be one atomic step, guarded by the same lock that guards complete(). Either complete() runs first (fully) and whenComplete() sees state != PENDING and fires immediately, or whenComplete() runs first (fully, enqueuing under the lock) and complete() sees the callback already in the list and fires it. There is no third interleaving, because the lock forbids it. Fire the callback itself outside the lock (never run arbitrary user code while holding your internal lock — that's how you get reentrancy bugs and lock-ordering deadlocks), but make the decision of whether to fire-now-or-enqueue happen fully inside it.
4. Build it — milestones
Build the Future with this contract, growing it milestone by milestone. Attempt each yourself before reading the reference implementation below — the reveal is positioned after the milestones on purpose.
- Core contract:
get()blocks until a value or failure exists;complete(value)/completeExceptionally(err)set the outcome exactly once and wake every waiter. - M1 — the one-shot slot. State machine
PENDING → COMPLETED/FAILED, one lock, one condition variable (Java) / onedonechannel closed on completion (Go).get()blocks correctly; multiple threads can all callget()on the same Future and all unblock when it completes. - M2 — the double-complete guard. Two threads race to call
complete()on the same Future. Exactly one write wins (returnstrue); the other is a clean no-op (returnsfalse), never a corrupted value or a thrown exception. Prove it with a test that callscomplete()twice and asserts the first value sticks. - M3 — callbacks, race-free.
whenComplete(cb)registers a callback that fires when the Future completes — or immediately, if it's already done by the time you register. This is Movement 3's race, and Movement 5 proves your implementation actually closes it. BuildthenApply(fn)on top: it creates a new Future and completes it by registering awhenCompletecallback on the first one. - M4 — bounded wait.
get(timeout)so a caller can give up instead of blocking forever (Java:Condition.awaitNanos; Go:selecton thedonechannel +context.Context).
Reference implementation — Java (ReentrantLock + Condition, hand-rolled)
Not a wrapper around CompletableFuture — every line below is the actual mechanism such a class would need internally. The comment on whenComplete is Movement 3's fix, spelled out in code: the state read and the enqueue-or-fire decision share one lock acquisition.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Function;
/** A hand-rolled one-shot Future/Promise: PENDING -> COMPLETED/FAILED.
* get() blocks until set; complete()/completeExceptionally() set the result
* exactly once and wake every waiter; whenComplete() registers a callback
* that fires now if already done -- that's the race this file is about. */
public final class MyFuture<T> {
private enum State { PENDING, COMPLETED, FAILED }
private final ReentrantLock lock = new ReentrantLock();
private final Condition doneCond = lock.newCondition();
private State state = State.PENDING;
private T value;
private Throwable error;
private final List<BiConsumer<T, Throwable>> callbacks = new ArrayList<BiConsumer<T, Throwable>>();
/** Sets the result exactly once. Returns false if already completed/failed (M2: double-complete guard). */
public boolean complete(T v) {
List<BiConsumer<T, Throwable>> toRun;
lock.lock();
try {
if (state != State.PENDING) return false; // idempotent: second caller loses, cleanly
value = v;
state = State.COMPLETED;
toRun = new ArrayList<BiConsumer<T, Throwable>>(callbacks);
callbacks.clear();
doneCond.signalAll();
} finally {
lock.unlock();
}
for (BiConsumer<T, Throwable> cb : toRun) cb.accept(v, null); // fire OUTSIDE the lock
return true;
}
public boolean completeExceptionally(Throwable t) {
List<BiConsumer<T, Throwable>> toRun;
lock.lock();
try {
if (state != State.PENDING) return false;
error = t;
state = State.FAILED;
toRun = new ArrayList<BiConsumer<T, Throwable>>(callbacks);
callbacks.clear();
doneCond.signalAll();
} finally {
lock.unlock();
}
for (BiConsumer<T, Throwable> cb : toRun) cb.accept(null, t);
return true;
}
/** Blocks until the result (or failure) is available. */
public T get() throws InterruptedException, ExecutionException {
lock.lock();
try {
while (state == State.PENDING) doneCond.await();
if (state == State.FAILED) throw new ExecutionException(error);
return value;
} finally {
lock.unlock();
}
}
/** M4: bounded wait instead of blocking forever. */
public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
long nanos = unit.toNanos(timeout);
lock.lock();
try {
while (state == State.PENDING) {
if (nanos <= 0L) throw new TimeoutException();
nanos = doneCond.awaitNanos(nanos);
}
if (state == State.FAILED) throw new ExecutionException(error);
return value;
} finally {
lock.unlock();
}
}
/** Registers a callback. THE RACE this whole lab is about: the check
* ("are we done?") and the enqueue/fire decision happen in ONE lock
* acquisition, so a concurrent complete() can never land in between. */
public void whenComplete(BiConsumer<T, Throwable> cb) {
boolean alreadyDone;
T v = null;
Throwable e = null;
lock.lock();
try {
alreadyDone = state != State.PENDING;
if (alreadyDone) { v = value; e = error; }
else callbacks.add(cb);
} finally {
lock.unlock();
}
if (alreadyDone) cb.accept(v, e); // fire immediately -- must be outside the lock
}
/** Chains a transformation without blocking the caller. */
public <U> MyFuture<U> thenApply(Function<T, U> fn) {
final MyFuture<U> next = new MyFuture<U>();
whenComplete((v, e) -> {
if (e != null) { next.completeExceptionally(e); return; }
try { next.complete(fn.apply(v)); }
catch (Throwable ex) { next.completeExceptionally(ex); }
});
return next;
}
}
Reference implementation — Go (done channel + mutex-guarded callback list)
Go's version leans on a fact worth stating precisely: closing a channel happens-before every receive from it returns (part of the Go memory model). That means every field written before close(f.done) — guarded by the same mutex — is safely visible to every goroutine that unblocks from <-f.done, with no separate memory fence needed on the read side. Get() itself needs no lock at all.
// Package futurelab is a hand-rolled Future/Promise: a one-shot result slot,
// PENDING -> COMPLETED/FAILED, that blocks Get() and fires callbacks -- with
// the register-after-complete race handled by design (see future_test.go).
package futurelab
import (
"context"
"sync"
)
type state int32
const (
pending state = iota
completed
failed
)
// Future is a one-shot result slot. done is closed exactly once, on
// completion; Get() blocks by receiving from it.
type Future[T any] struct {
mu sync.Mutex
st state
val T
err error
done chan struct{}
callbacks []func(T, error)
}
func NewFuture[T any]() *Future[T] {
return &Future[T]{done: make(chan struct{})}
}
// Complete sets the result exactly once. Returns false if already
// completed/failed -- the double-complete guard (M2).
func (f *Future[T]) Complete(v T) bool {
f.mu.Lock()
if f.st != pending {
f.mu.Unlock()
return false
}
f.val = v
f.st = completed
cbs := f.callbacks
f.callbacks = nil
close(f.done) // publishes val/err to every Get(): close happens-before receive
f.mu.Unlock()
for _, cb := range cbs {
cb(v, nil) // fire OUTSIDE the lock
}
return true
}
// CompleteError fails the Future exactly once.
func (f *Future[T]) CompleteError(err error) bool {
f.mu.Lock()
if f.st != pending {
f.mu.Unlock()
return false
}
f.err = err
f.st = failed
cbs := f.callbacks
f.callbacks = nil
close(f.done)
f.mu.Unlock()
var zero T
for _, cb := range cbs {
cb(zero, err)
}
return true
}
// Get blocks until the Future is done.
func (f *Future[T]) Get() (T, error) {
<-f.done
return f.val, f.err
}
// GetContext is Get with a deadline/cancellation (M4).
func (f *Future[T]) GetContext(ctx context.Context) (T, error) {
select {
case <-f.done:
return f.val, f.err
case <-ctx.Done():
var zero T
return zero, ctx.Err()
}
}
// WhenComplete registers a callback. THE RACE this lab is about: the
// pending-check and the enqueue-or-fire decision happen under ONE lock, so
// a concurrent Complete can never land in the gap between them.
func (f *Future[T]) WhenComplete(cb func(T, error)) {
f.mu.Lock()
if f.st == pending {
f.callbacks = append(f.callbacks, cb)
f.mu.Unlock()
return
}
v, e := f.val, f.err
f.mu.Unlock()
cb(v, e) // already done -- fire immediately, outside the lock
}
// ThenApply chains a transformation without blocking the caller.
func ThenApply[T, U any](f *Future[T], fn func(T) U) *Future[U] {
next := NewFuture[U]()
f.WhenComplete(func(v T, err error) {
if err != nil {
next.CompleteError(err)
return
}
next.Complete(fn(v))
})
return next
}
Both compile clean under go build ./... and go vet ./..., and both were exercised end to end (including go test -race) before writing this page.
5. Break it — the test that fails
Change exactly one thing: move the state != PENDING check in whenComplete outside the lock, so it's a separate step from the enqueue. Everything else — complete(), the state machine, the callback list — is identical to the correct version above.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
/** Same shape as MyFuture, except whenComplete checks "are we done?" OUTSIDE
* the lock. That reopens the register-after-complete race: complete() can
* run to completion in the gap between the stale read and the enqueue. */
public final class BuggyFuture<T> {
private volatile boolean done = false;
private T value;
private final ReentrantLock lock = new ReentrantLock();
private final List<BiConsumer<T, Throwable>> callbacks = new ArrayList<BiConsumer<T, Throwable>>();
/** Test seam: forces the exact interleaving deterministically instead of
* hoping the OS scheduler cooperates. Set by the test, not used in prod. */
public Runnable raceWindow;
public boolean complete(T v) {
List<BiConsumer<T, Throwable>> toRun;
lock.lock();
try {
if (done) return false;
value = v;
done = true;
toRun = new ArrayList<BiConsumer<T, Throwable>>(callbacks);
callbacks.clear();
} finally {
lock.unlock();
}
for (BiConsumer<T, Throwable> cb : toRun) cb.accept(v, null);
return true;
}
// Java -- BuggyFuture.whenComplete(), the ONLY structural change from MyFuture:
void whenComplete(BiConsumer<T, Throwable> cb) {
if (done) { // BUG: read outside the lock
cb.accept(value, null);
return;
}
if (raceWindow != null) raceWindow.run(); // test seam: forces the gap open on demand
// <-- complete() can run to completion in exactly this gap
lock.lock();
try {
callbacks.add(cb); // too late: complete() already fired the (empty) list
} finally { lock.unlock(); }
}
}
The gap is one machine instruction wide under normal scheduling — the kind of race you could run a thousand times and never see. So the test doesn't hope for bad luck: it forces the exact interleaving with a synchronization checkpoint — a test seam (raceWindow/RaceWindow) that pauses the registering thread exactly between the stale read and the enqueue, hands control to the main thread to call complete(), then resumes. This is the standard way to make a Heisenbug reproduce deterministically, on every run, instead of chasing a flaky test:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
public class FutureRaceTest {
public static void main(String[] args) throws Exception {
deterministicRaceRepro();
stressTestFixedFutureNeverLoses();
doubleCompleteIsIdempotent();
thenApplyChains();
System.out.println("ALL CHECKS PASSED");
}
/** Force the exact interleaving with a synchronization checkpoint --
* the standard way to make a Heisenbug deterministic in a test. */
static void deterministicRaceRepro() throws Exception {
final BuggyFuture<Integer> bf = new BuggyFuture<Integer>();
final CountDownLatch checkPassed = new CountDownLatch(1);
final CountDownLatch proceed = new CountDownLatch(1);
bf.raceWindow = () -> {
checkPassed.countDown();
try { proceed.await(); } catch (InterruptedException ignored) {}
};
final boolean[] fired = { false };
Thread registrar = new Thread(() -> bf.whenComplete((v, e) -> fired[0] = true));
registrar.start();
checkPassed.await(); // registrar has passed the stale "not done" check
bf.complete(42); // completes; runs the (currently empty) callback list
proceed.countDown(); // release the registrar to append its now-useless callback
registrar.join();
System.out.println("BUG REPRODUCED: callback registered during complete() fired = " + fired[0]
+ " (expected false -- the callback is silently lost)");
if (fired[0]) throw new AssertionError("expected the race to LOSE the callback, but it fired");
}
/** The fixed Future under the same concurrent pressure, many times over --
* proves the fix isn't a fluke of one interleaving. */
static void stressTestFixedFutureNeverLoses() throws Exception {
final int trials = 20000;
int lost = 0;
for (int i = 0; i < trials; i++) {
final MyFuture<Integer> f = new MyFuture<Integer>();
final boolean[] fired = { false };
final CyclicBarrier start = new CyclicBarrier(2);
final int val = i;
Thread registrar = new Thread(() -> {
await(start);
f.whenComplete((v, e) -> fired[0] = true);
});
Thread completer = new Thread(() -> {
await(start);
f.complete(val);
});
registrar.start();
completer.start();
registrar.join();
completer.join();
if (!fired[0]) lost++;
}
System.out.println("stress test: lost " + lost + "/" + trials + " callbacks on the FIXED MyFuture (expected 0)");
if (lost != 0) throw new AssertionError("fixed Future lost " + lost + " callbacks under concurrent stress");
}
static void await(CyclicBarrier b) {
try { b.await(); } catch (Exception e) { throw new RuntimeException(e); }
}
/** Secondary break-it: without the `if (state != PENDING) return false` guard,
* a second complete() would silently overwrite the value. With the guard: */
static void doubleCompleteIsIdempotent() throws Exception {
MyFuture<Integer> f = new MyFuture<Integer>();
boolean first = f.complete(1);
boolean second = f.complete(999);
int got = f.get();
System.out.println("double-complete: first=" + first + " second=" + second + " value=" + got
+ " (expected true, false, 1)");
if (!first || second || got != 1) throw new AssertionError("double-complete guard broken");
}
static void thenApplyChains() throws Exception {
MyFuture<Integer> f = new MyFuture<Integer>();
MyFuture<String> g = f.thenApply(x -> "answer=" + (x * 2));
f.complete(21);
String result = g.get();
System.out.println("thenApply: " + result + " (expected answer=42)");
if (!result.equals("answer=42")) throw new AssertionError("thenApply chain wrong: " + result);
}
}
Run it:
BUG REPRODUCED: callback registered during complete() fired = false (expected false -- the callback is silently lost)
stress test: lost 0/20000 callbacks on the FIXED MyFuture (expected 0)
double-complete: first=true second=false value=1 (expected true, false, 1)
thenApply: answer=42 (expected answer=42)
ALL CHECKS PASSED
The Go version reproduces identically, deterministically:
// Go -- BuggyFuture has the same shape as Future, except WhenComplete checks
// "are we done?" OUTSIDE the lock. That reopens the register-after-complete
// race: Complete can run to completion in the gap between the stale read
// and the enqueue.
package futurelab
import (
"sync"
"sync/atomic"
"testing"
)
type BuggyFuture[T any] struct {
mu sync.Mutex
done int32 // 0 = pending, 1 = completed -- read WITHOUT the lock, the bug
val T
callbacks []func(T, error)
// RaceWindow is a test seam: it forces the exact interleaving
// deterministically instead of hoping the Go scheduler cooperates.
RaceWindow func()
}
func (f *BuggyFuture[T]) Complete(v T) {
f.mu.Lock()
f.val = v
atomic.StoreInt32(&f.done, 1)
cbs := f.callbacks
f.callbacks = nil
f.mu.Unlock()
for _, cb := range cbs {
cb(v, nil)
}
}
func (f *BuggyFuture[T]) WhenComplete(cb func(T, error)) {
if atomic.LoadInt32(&f.done) == 1 { // BUG: read outside the lock
cb(f.val, nil)
return
}
if f.RaceWindow != nil {
f.RaceWindow() // Complete() runs to completion HERE, in the test
}
f.mu.Lock()
f.callbacks = append(f.callbacks, cb) // too late: Complete already fired the (empty) list
f.mu.Unlock()
}
func TestRegisterAfterCompleteRaceIsLost(t *testing.T) {
bf := &BuggyFuture[int]{}
checkPassed := make(chan struct{})
proceed := make(chan struct{})
bf.RaceWindow = func() {
close(checkPassed)
<-proceed
}
fired := false
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
bf.WhenComplete(func(v int, err error) { fired = true })
}()
<-checkPassed // registrar has passed the stale "not done" check
bf.Complete(42) // completes; runs the (currently empty) callback list
close(proceed) // release the registrar to append its now-useless callback
wg.Wait()
if fired {
t.Fatalf("expected the callback to be LOST (race reproduced), but it fired")
}
}
Run it under go test -race -v:
=== RUN TestRegisterAfterCompleteRaceIsLost
future_test.go:37: BUG REPRODUCED: callback registered during Complete() never fired (fired=false)
--- PASS: TestRegisterAfterCompleteRaceIsLost (0.00s)
=== RUN TestWhenCompleteNeverRaces
future_test.go:71: stress test: lost 0/20000 callbacks on the fixed Future
--- PASS: TestWhenCompleteNeverRaces (0.17s)
=== RUN TestDoubleCompleteIsIdempotent
--- PASS: TestDoubleCompleteIsIdempotent (0.00s)
=== RUN TestThenApplyChains
--- PASS: TestThenApplyChains (0.00s)
PASS
ok futurelab 2.170s
Why -race doesn't catch this on its own: this is not a data race in the memory-model sense — every field access in the buggy version is still protected by some synchronization (the volatile/atomic read, the mutex around the list). Go's race detector and the JVM would both stay silent on this bug in a normal, unforced run. It's a logic race — a lost update at the level of "which of two orderings did the scheduler pick" — and the only way to catch it is either the forced-interleaving test above, or the stress test in the log: run the fixed MyFuture/Future through 20,000 concurrent register-vs-complete pairs with no forcing at all, and confirm zero lost callbacks, every time. The forced test proves the mechanism; the stress test proves the fix holds under real scheduling, not just the one interleaving you thought to force.
The fix is exactly Movement 3's rule restated: read the callbacks.add(cb) call back into the same lock acquisition as the state check (MyFuture.whenComplete / Future.WhenComplete above). With that fix, the forced-interleaving test can't even express the bug — the registering thread either holds the lock and finishes enqueuing before complete() can acquire it, or complete() finishes first and the registering thread's own state check (now inside the lock) correctly reports "already done."
6. Optimise — with trade-offs
| Approach | Multi-consumer / callback semantics | Complexity you own | Cancellation / timeout | Use when |
|---|---|---|---|---|
| Hand-rolled Future/Promise (this lab) | Native — any number of get() callers and any number of whenComplete callbacks, registered before OR after completion | You own the state machine and the completion race — real surface area, as this lab just proved | You implement it (M4) — doable but manual | Learning the mechanism, or a runtime with no async primitives at all |
CompletableFuture (Java) | Native, plus a huge combinator library (thenCompose, allOf, anyOf, orTimeout) — production-hardened against exactly the race in Movement 5 | None — the JDK maintainers own the state machine | Built-in (orTimeout, completeOnTimeout) | Default choice on the JVM for anything beyond a single submit().get() — don't hand-roll this in production, hand-roll it once here to understand it |
Plain Future (Java, from ExecutorService.submit) | Single-consumer in spirit — get() only, no callbacks, no chaining | None to own, but you're limited to blocking-pull | get(timeout, unit) built in | Fire-and-collect: submit N tasks, later get() each result; you don't need reactive chaining |
| Unbuffered/buffered Go channel | Channels are fundamentally either single-value-once (buffered cap 1, closed after) or a stream — not natively "N late-arriving callbacks fire immediately"; you'd hand-roll that on top, which is exactly what Future[T] above does | Low if the shape is genuinely "one value, one or few receivers" — the channel is the synchronization | select + context.Context, idiomatic and cheap | The result has exactly one or a small, known set of consumers reading once; you don't need late-registration callback semantics |
errgroup.Group (Go, golang.org/x/sync/errgroup) | N/A — it's a fan-out/fan-in coordinator, not a value handle; use it to run M goroutines and collect the first error, not to hand a result to a late arriver | Very low — a few lines around a WaitGroup and an error | errgroup.WithContext cancels siblings on first error | "Run these N things concurrently, stop everything if one fails, wait for the rest" — the classic parallel-fan-out shape, not a Future's late-callback shape |
The real judgment call: reach for a bare channel when the consumer is known and reads once — it's the cheapest, hardest-to-misuse option because there's no separate "is it done yet" check to race. Reach for a Future/Promise (hand-rolled or CompletableFuture) the moment you need either multiple independent consumers, or a consumer that might register its interest after the work already finished, or a chain of transformations that shouldn't block the thread that sets them up. In Java, that almost always means "use CompletableFuture, don't hand-roll it" — you now know exactly what bug you'd be signing up to re-discover if you did. In Go, the idiomatic move is to reach for a channel first and only build a Future-shaped type when you catch yourself writing a second done bool guarded by a mutex next to it — that's the tell that you've actually got Future semantics and should name the type accordingly.
7. Defend under drilling
An interviewer will push on the design. These are the follow-ups that come up almost every time, with the answer a staff engineer gives — short, concrete, no hedging.
- "Why not just use a
volatile boolean doneand avolatile T value?" Two separate volatile fields give you no atomicity across the pair — a reader can observedone == truebefore it's safe to assumevaluereflects the final write on every JVM/hardware combination, and you still have zero support for blocking (a spinning reader) or for multiple late-registering callbacks. A single lock (or a channel close, in Go) gives you one atomic "publish" operation and a real park/unpark instead of a spin. - "Why does
complete()return abooleaninstead of throwing on double-complete?" Because racing to complete a Future is a normal, expected situation (two redundant lookups racing to fill one cache slot), not a programming error — the loser needs a cheap, non-exceptional way to find out it lost. Contrast with re-throwing on double-complete, which forces every caller into a try/catch for a case that isn't exceptional.CompletableFuture.complete()makes the same call — it returnsboolean, not void-or-throw. - "Where exactly does the register-after-complete race live, and why doesn't locking the whole class everywhere fix it 'automatically'?" It's not about locking enough places — both the buggy and fixed versions lock around every individual field access. The bug is a lock scoping mistake: the buggy version takes two separate critical sections (check, then maybe-enqueue) where the correct version takes one. Locking more things without locking the right span of code together doesn't help — you have to identify the check-then-act pair that must be atomic and put exactly one lock acquisition around both halves.
- "How does this generalize to
allOf/anyOf— waiting on N futures?" Same primitive, composed:allOfregisters awhenCompleteon every input Future that decrements a shared counter and completes the output Future when the counter hits zero (or fails fast on the first exception);anyOfcompletes the output Future on the first input to complete and is a no-op (thanks to the double-complete guard!) for every subsequent one. Movement 6's judgment call about "N consumers, or late registration" is exactly why you'd reach for this shape over N separateget()calls. - "What breaks at 100× the callback count, or 100× the Future count?" Two different bottlenecks: (a) one Future with thousands of callbacks means
complete()holds the lock while copying the whole callback list — fine, because you copy-then-release-then-fire outside the lock, exactly as both reference implementations do; (b) thousands of Futures each with their own lock is embarrassingly parallel and doesn't contend at all, since each Future's lock is independent. The thing that would break is a design that fires callbacks while holding the lock — a slow or reentrant callback would then serialize every other operation on that Future behind it, or deadlock outright if the callback itself calls back into the Future.
8. You can now defend
- You can implement a one-shot Future/Promise from scratch — state machine, blocking
get(), a double-complete guard, and callback chaining — in both Java and Go, without wrapping an existing library type. - You've named and reproduced the register-after-complete race deterministically (via a forced interleaving) and statistically (via a 20,000-trial concurrent stress test with zero losses on the fix), and you can explain precisely why
go test -raceand normal JVM execution both stay silent on it — it's a logic race, not a data race. - You can place a hand-rolled Future on a spectrum against
CompletableFuture, a plainFuture, a bare Go channel, anderrgroup, and argue the multi-consumer/callback trade-off for each with a concrete "use when." - You can defend the design choices —
boolean-returningcomplete(), firing callbacks outside the lock, one lock spanning the check-then-act pair — as deliberate answers to specific races, not arbitrary API shape.
Re-authored/Deepened for this guide. Reference code compiled and executed before publishing: Java via javac -Xlint:all -Werror (clean) and java (all assertions pass, race reproduced on every run); Go via go build ./..., go vet ./..., and go test -race -v ./... (clean, 4/4 tests pass, race reproduced deterministically, 20,000-trial stress test on the fix shows zero lost callbacks). See also: Thread Pools, Executors & Futures.
🤖 Don't fully get this? Learn it with Claude
Stuck on Build a Future / Promise? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Build a Future / Promise** (Hands-On Builds) and want to truly understand it. Explain Build a Future / Promise from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Build a Future / Promise** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Build a Future / Promise** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Build a Future / Promise** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.