Skip to main content
Pixel art: a character reads a book at a cafe. Their phone buzzes on the table with a notification showing a light bulb and green checkmark. Through the window, smart home devices work on their own. Tagline: Dont Wait. Get Called Back.

CS 3100: Program Design and Implementation II

Lecture 32: Concurrency II — Asynchronous Programming

©2026 Jonathan Bell, CC-BY-SA

Learning Objectives

After this lecture, you will be able to:

  1. Compare threads and asynchronous programming for I/O-bound work
  2. Understand blocking vs non-blocking operations
  3. Use CompletableFuture to compose asynchronous workflows
  4. Evaluate the safety of asynchronous code (ordering, errors, shared state)
  5. Use Platform.runLater() to safely update GUIs from async callbacks

Threads Gave Us Concurrency — at a Price

On Monday we learned to orchestrate threads. It was painful:

  • Shared state → race conditions (two scenes interleave, room ends up in a mix of both)
  • Locks → deadlocks (two threads each hold what the other needs — frozen forever)
  • Every thread → costs memory, OS resources, and context-switch overhead
  • Testing → timing-dependent bugs that pass 99 times, fail once, can't reproduce

But here's the thing: most of what our software needs concurrency for isn't computing — it's waiting. Waiting for a device to respond. Waiting for a file to load. Waiting for a network call to return. We're paying the full cost of threads just to sit idle.

What if we could have concurrency without threads fighting over shared state? What if one thread could manage many concurrent operations by dispatching work and getting notified when results arrive?

Activating "Evening" Means Talking to 15 Devices — and Waiting 14 Times

"Evening" scene: dim 5 lights, close 4 shades, turn off 3 fans, set 3 color temperatures. Each command is a network call to a device over Zigbee — ~200ms round trip.

ApproachThreadsTimeProblem
Sequential115 × 200ms = 3 secondsUI frozen the entire time
Thread per device15~200ms14 threads doing nothing but waiting

Sequential is too slow. Thread-per-device works, but 14 of those threads are just... sitting there. Doing nothing. Waiting for a response.

I/O Takes an Eternity (from the CPU's Perspective)

Bar chart showing operation times scaled so 1 CPU cycle = 1 second. RAM access = 5 minutes. Zigbee device round trip = 4 months. Cross-country API call = 5 years.

Blocking Wastes Threads; Non-Blocking Frees Them

A blocking operation halts the thread until it completes. A non-blocking operation returns immediately.

The Restaurant Analogy: Don't Wait at the Kitchen Door

Left: 15 people blocking at a kitchen door waiting for orders. Right: one person sits reading with a receipt number, gets a notification when the order is ready. Labels: Blocking vs Async.

One Line of Code, Two Completely Different Behaviors

Blocking

// Thread blocks here for ~200ms
DeviceAck ack = zigbee.sendCommand(
light, new BrightnessCommand(30)
);
// Can't do anything else until ACK
updateStatus(light, ack);

Standing at the kitchen door.

Non-blocking (async)

// Returns immediately — thread is free
zigbee.sendCommandAsync(
light, new BrightnessCommand(30)
).thenAccept(ack -> {
// Runs later, when ACK arrives
updateStatus(light, ack);
});
// Thread continues immediately

Sitting down with your receipt.

Async Scales: Dispatch Many, Wait for All

With blocking, 15 devices = 15 idle threads. With async, one thread dispatches all 15:

// Dispatch 15 commands — thread never blocks
List<Future<DeviceAck>> receipts = new ArrayList<>();
for (DeviceCommand cmd : scene.getCommands()) {
receipts.add(zigbee.sendCommandAsync(cmd)); // returns immediately
}
// All 15 are in flight simultaneously
// Thread is free to update the UI, process other events, etc.

15 commands dispatched in microseconds. All 15 devices processing in parallel. One thread. Zero idle waiting. We'll see how to collect all the results with CompletableFuture.allOf() later.

Use Threads When the CPU Is Busy; Async When It's Idle

ThreadsAsync
Best forCPU-bound work (computing)I/O-bound work (waiting)
Resource usageHigher (thread per task)Lower (callbacks)
Programming modelFamiliar (sequential code)Less familiar (callbacks/futures)
SceneItAllComputing optimal scene settingsSending Zigbee device commands
In GA1Running business logicLoading data, BackgroundTaskRunner

Use threads when the CPU is busy. Use async when the CPU is idle and waiting. And the async model isn't even new to you...

You Already Know This Model — From L29's Event Loop

Remember the event loop from L29?

// The GUI event loop (from L29)
while (applicationIsRunning) {
Event event = waitForNextEvent(); // button click, key press...
EventHandler handler = findHandler(event);
handler.handle(event);
}

Async I/O uses the same model — but the events are I/O completions instead of button clicks:

// An I/O event loop (conceptually)
while (running) {
IOEvent event = waitForIOCompletion(); // device ACK, network response...
Callback callback = findCallback(event);
callback.run();
}

You already understand this model. Now we're applying it to I/O instead of UI.

Future: The Basic Receipt

A Future<T> is a placeholder for a result that will be available later — the receipt from our restaurant:

ExecutorService executor = Executors.newFixedThreadPool(10);

// Place the order — returns a receipt immediately
Future<SceneSettings> receipt = executor.submit(
() -> settingsEngine.computeOptimal(scene, sensors)
);

// Do other work while the kitchen cooks...

// Go to the counter and wait for your number
SceneSettings settings = receipt.get(); // BLOCKS until done

But get() blocks. You're back to standing at the kitchen door. What if the kitchen could call your number instead?

Future Blocks; CompletableFuture Calls You Back

Future<T> (2004, Java 5)

The basic receipt.

  • T get() — wait for result (blocks!)
  • boolean isDone() — is it ready yet?
  • boolean cancel(boolean) — cancel the order

You have to check or wait.

CompletableFuture<T> (2014, Java 8)

The receipt that calls your number.

  • CF<U> thenApply(fn) — transform result
  • CF<Void> thenAccept(fn) — use result, return nothing
  • CF<U> thenCompose(fn) — chain another async op
  • CF<V> thenCombine(other, fn) — join two results
  • CF<Void> allOf(...) — wait for all
  • CF<T> exceptionally(fn) — handle errors

The kitchen calls you.

Future is the receipt. CompletableFuture is the receipt plus a way to say "when it's ready, do this next thing automatically."

CompletableFuture: The Kitchen Calls Your Number

The simplest CompletableFuture chain — one async operation, one callback:

CompletableFuture
.supplyAsync(() -> settingsEngine.computeOptimal(scene, sensors))
.thenAccept(settings -> applyToDevices(settings));

// Thread continues immediately — no blocking
// applyToDevices runs later, when computation finishes
RestaurantJava
Place the ordersupplyAsync(() -> ...)
Kitchen calls your numberthenAccept(result -> ...)
You never stand at the doorThread is free immediately

thenCompose: Sequential Dependencies Without Blocking

thenCompose chains async operations that depend on each other:

sendCommandAsync(light, new BrightnessCommand(30))
.thenCompose(ack -> updateHubStateAsync(light, ack))
.thenAccept(state -> log("Hub updated: " + state));

Each step depends on the previous result, but no thread ever blocks. thenCompose = "when the first thing finishes, start the second thing."

CompletableFuture Flattens the Pyramid of Doom

Before CompletableFuture existed, async code looked like this:

Nested callbacks (the old way)

sendCommand(light, brightness, lightAck -> {
if (lightAck.isError()) {
handleError(lightAck);
} else {
sendCommand(shade, close, shadeAck -> {
if (shadeAck.isError()) {
handleError(shadeAck);
} else {
updateHub(state, hubResult -> {
if (hubResult.isError()) {
handleError(hubResult);
} else {
log("All done");
}
});
}
});
}
});

CompletableFuture (flat chain)

sendCommandAsync(light, brightness)
.thenCompose(ack ->
sendCommandAsync(shade, close))
.thenCompose(ack ->
updateHubAsync(state))
.thenAccept(result ->
log("All done"))
.exceptionally(error -> {
handleError(error);
return null;
});

Same logic. Flat. Readable. One error handler for the whole chain.

Three Ways to Activate a Scene

Raw Threads (L31)

for (DeviceCommand cmd : commands) {
new Thread(() ->
sendCommand(cmd) // blocks
).start();
}
// How do we know when
// they're all done? join()
// each one — blocks again.

15 threads. 14 idle. 15MB stacks.

Thread Pool (L31)

for (DeviceCommand cmd : commands) {
Future<Ack> f = executor
.submit(() ->
sendCommand(cmd));
futures.add(f);
}
// f.get() to collect — but
// get() blocks the caller!

10 threads (reused). Still blocks to collect.

Async (today)

List<CF<Ack>> futures = commands
.stream()
.map(cmd ->
sendCommandAsync(cmd))
.toList();
CompletableFuture.allOf(
futures.toArray(new CF[0]));
// No blocking. No idle threads.

1 thread. 0 idle. Scales to 1000.

The punchline: async lets you fire all 15 requests without paying for a thread to sit and wait for each response. This is also a coupling reduction (L7): your code no longer depends on thread lifecycles, shared locks, or execution timing — it just dispatches work and reacts to results.

The Full Pipeline: Four Phases, Zero Blocking

Pipeline diagram: Phase 1 (compute settings) feeds into Phase 2 (fan out to 4 devices via allOf), which feeds into Phase 3 (update hub), then Phase 4 (push to apps). Phases connected by thenCompose.

Four phases. Phases run sequentially (each depends on the previous). Within Phase 2, device commands run in parallel.

Phase 1: supplyAsync Starts the Computation

// Phase 1: CPU-bound — compute optimal settings from sensors
CompletableFuture<SceneSettings> settingsFuture =
CompletableFuture.supplyAsync(
() -> settingsEngine.computeOptimal(scene, sensors)
);

supplyAsync runs the computation on a background thread from the common pool. Returns a CompletableFuture immediately. The calling thread is free.

Pipeline diagram

Phase 2: allOf Dispatches 15 Commands in Parallel

Phase 2: fan out to all devices, wait for all to complete.

// Phase 2: Fan out — send commands to all devices in parallel
CompletableFuture<List<DeviceAck>> devicesFuture = settingsFuture
.thenCompose(settings -> {
List<CompletableFuture<DeviceAck>> commands = settings.getCommands()
.stream()
.map(cmd -> sendCommandAsync(cmd)) // each returns a Future
.toList();

return CompletableFuture.allOf(commands.toArray(new CompletableFuture[0]))
.thenApply(v -> commands.stream()
.map(CompletableFuture::join).toList());
});

Phases 3-4: Fan In, Then Push to All Apps

// Phase 3: Update hub state with all device results
CompletableFuture<HubState> hubFuture = devicesFuture
.thenCompose(acks -> hub.updateStateAsync(scene, acks));

// Phase 4: Push updated state to all connected mobile apps
CompletableFuture<Void> pushFuture = hubFuture
.thenCompose(state -> {
CompletableFuture<Void> pushToApps = pushService.notifyAllAsync(state);
CompletableFuture<Void> logActivation = logger.logAsync(scene, state);
return CompletableFuture.allOf(pushToApps, logActivation);
});
Pipeline diagram

The entire pipeline — compute, fan-out to 15 devices, update hub, push to apps — runs without a single blocking call. One thread dispatches everything.

Async Errors Are Silent by Default

Bad: silent failure

sendCommandAsync(light, brightness)
.thenAccept(ack -> updateUI(ack));
// If sendCommand fails...
// nothing happens. No error. Silence.
// Light stays at old brightness.
// User has no idea.

Good: explicit error handling

sendCommandAsync(light, brightness)
.thenAccept(ack -> updateUI(ack))
.exceptionally(error -> {
showError("Light unreachable");
return null;
})
.orTimeout(5, TimeUnit.SECONDS);

Async errors are silent by default. If you don't add .exceptionally(), errors vanish. The user sees nothing — the device just doesn't change.

Your CompletableFuture Reference Card

MethodWhat it doesWhen to use
allOf(f1, f2, f3...)Wait for all to completeFan-out / fan-in
supplyAsync(() -> ...)Start async work, return Future"Place the order"
thenApply(result -> ...)Transform result (sync)Change the type: Stringint
orTimeout(n, SECONDS)Fail if not done in timeDevice not responding
thenAccept(result -> ...)Use result, return nothingFinal step: log, update UI
thenCompose(result -> ...)Chain another async opSequential dependency
thenCombine(other, (a,b) -> ...)Join two FuturesCombine independent results
thenAcceptAsync(fn, executor)Run callback on specific threadPlatform.runLater() for GUI
exceptionally(error -> ...)Handle errors in the chainError recovery / fallback

Async Doesn't Eliminate Concurrency Bugs — It Changes Their Shape

Even when every operation succeeds, async can produce wrong results:

Ordering bug

You ordered steak first, then salad. Salad arrives first — because it's simpler.

setBrightnessAsync(light, 30); // sent first
setBrightnessAsync(light, 10); // sent second
// 10 might arrive before 30!
// Light ends up at 30% — wrong

Fix: thenCompose for sequential dependency

Shared state race

Same deviceCount++ bug from L31 — now in callbacks:

sendCommandAsync(light, brightness)
.thenAccept(ack -> {
totalCommands++; // RACE!
});
sendCommandAsync(shade, close)
.thenAccept(ack -> {
totalCommands++; // RACE!
});

Fix: AtomicInteger.incrementAndGet()

Async doesn't eliminate concurrency bugs — it changes their shape. Instead of threads interleaving, callbacks execute in unpredictable order.

Callbacks Run on the Wrong Thread — And Your GUI Crashes

Async callbacks run on background threads. But GUI updates must happen on the JavaFX Application Thread.

Platform.runLater() Bridges Background Threads to the GUI

// WRONG: callback runs on background thread
sendCommandAsync(light, brightness)
.thenAccept(ack -> slider.setValue(ack.getBrightness())); // CRASH!

// CORRECT: push the UI update to the FX thread
sendCommandAsync(light, brightness)
.thenAcceptAsync(
ack -> slider.setValue(ack.getBrightness()),
Platform::runLater // runs callback on FX thread
);

In GA1, use BackgroundTaskRunner — it wraps this pattern:

BackgroundTaskRunner.run(
() -> librarianService.listCollections(), // runs on background thread
collections -> { // runs on FX thread (success)
this.collections.setAll(/* ... */);
},
error -> showError(error.getMessage()) // runs on FX thread (failure)
);

Every GA1 feature needs this. Your TAs will ask you to explain what BackgroundTaskRunner does internally.

Comprehension Check

Open Poll Everywhere and answer the next 4 questions.

Five Best Practices for Async Safety

  1. Prefer immutability. Pass records between async stages — don't share mutable objects between callbacks.
  2. Chain properly. Use thenCompose for sequential dependencies. Don't fire-and-forget operations that depend on each other.
  3. Handle errors at the end. Every chain needs .exceptionally() or .handle() — async errors are silent by default.
  4. Use timeouts. .orTimeout(5, SECONDS) — devices go offline, networks fail. Don't wait forever.
  5. Confine UI updates. Use Platform.runLater() or BackgroundTaskRunner — never touch JavaFX widgets from a background thread.

Key Takeaways

  1. Threads wait expensively. A blocking thread uses memory and OS resources to do nothing. Async lets one thread manage many concurrent I/O operations.
  2. Future = receipt. CompletableFuture = receipt that calls your number. supplyAsync, thenCompose, allOf compose workflows without blocking.
  3. Async doesn't eliminate concurrency bugs — it changes their shape. Ordering bugs, silent errors, and shared mutable state are still dangerous.
  4. GUI updates must run on the FX thread. Use Platform.runLater() or BackgroundTaskRunner — never touch widgets from a callback.
  5. Always handle errors. .exceptionally() at the end of every chain. Async errors are silent by default.

Looking Ahead

Tomorrow: Event-Driven Architecture (L33)

  • Today's async model works inside one process. But SceneItAll has a hub, a mobile app, a cloud service, and device firmware — on different machines, different networks. How do they coordinate?
  • The answer: events. Instead of services calling each other directly (fragile), they publish facts to a broker and react independently. Same decoupling as Observer/MVVM, but across the network.

Async beyond Java:

  • Modern languages make async even cleaner with async/await syntax — C# (2011), JavaScript (2017), Python, Kotlin, Rust. Same concepts as today's CompletableFuture, but the compiler rewrites sequential-looking code into callbacks for you.

Your group project:

  • GA1 (due Apr 9): BackgroundTaskRunner wraps today's concepts — every feature needs it
  • TAs will ask you to explain what it does internally during code walks

Today you learned to stop waiting and start getting called back. Tomorrow, we scale that idea across an entire system.