Thread Pool — Practice Tasks¶
Hands-on exercises for the Thread Pool pattern, easy → hard. Each task has a goal, requirements, hints, and a solution sketch. Work in junior.md/middle.md first; tasks 7–10 lean on senior.md/professional.md.
Table of Contents¶
- Task 1 — Build a fixed pool and prove reuse
- Task 2 — Submit and collect results with Future
- Task 3 — Refactor thread-per-request to a bounded pool
- Task 4 — Demonstrate the unbounded-queue trap
- Task 5 — Implement all four rejection policies
- Task 6 — Build a Go worker pool with results
- Task 7 — Size an IO-bound pool with the Goetz formula
- Task 8 — Reproduce and fix pool-induced deadlock
- Task 9 — Bulkhead two dependencies
- Task 10 — Migrate an IO pool to virtual threads
- How to Practice
Task 1 — Build a fixed pool and prove reuse¶
Goal: Show that a thread pool reuses threads rather than creating one per task.
Requirements: - Submit 1,000 tasks to a 4-thread pool. - Each task records Thread.currentThread().getName(). - Assert that only 4 distinct thread names appear.
Hints: Collect names in a ConcurrentHashMap.newKeySet(). Shut down and awaitTermination before asserting.
Solution sketch
ExecutorService pool = Executors.newFixedThreadPool(4);
Set<String> names = ConcurrentHashMap.newKeySet();
for (int i = 0; i < 1000; i++) pool.submit(() -> names.add(Thread.currentThread().getName()));
pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS);
assert names.size() == 4; // exactly the 4 reused workers
Task 2 — Submit and collect results with Future¶
Goal: Fan out N computations, fan in the results.
Requirements: - Submit 50 Callable<Integer> tasks (return i * i). - Collect results in submission order; handle ExecutionException.
Hints: Keep Futures in a List; loop and get(). Unwrap e.getCause() on failure.
Solution sketch
Order is preserved because you iterate the `Future` list in submission order, not completion order.Task 3 — Refactor thread-per-request to a bounded pool¶
Goal: Cap concurrency on a server that currently spawns a thread per connection.
Starting code:
while (true) {
Socket conn = server.accept();
new Thread(() -> handle(conn)).start(); // unbounded
}
Requirements: - Replace with a bounded ThreadPoolExecutor (bounded queue, named threads, a rejection policy). - Under a flood of 10,000 connections, prove the live thread count never exceeds maximumPoolSize.
Hints: Track pool.getPoolSize() / getLargestPoolSize(). Use CallerRunsPolicy to avoid dropping connections while throttling.
Solution sketch
ThreadPoolExecutor pool = new ThreadPoolExecutor(
16, 64, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(256),
named("conn"), new ThreadPoolExecutor.CallerRunsPolicy());
while (true) {
Socket conn = server.accept();
pool.execute(() -> { try (conn) { handle(conn); } catch (IOException e) { log.warn("", e); } });
}
// After the flood: assert pool.getLargestPoolSize() <= 64
Task 4 — Demonstrate the unbounded-queue trap¶
Goal: Prove that an unbounded queue makes maximumPoolSize meaningless.
Requirements: - Create a ThreadPoolExecutor with core=2, max=10, LinkedBlockingQueue (unbounded). - Submit slow tasks faster than they complete; record getLargestPoolSize(). - Show it stays at 2 even though max is 10, and the queue grows unboundedly.
Hints: Each task sleeps; submit thousands quickly. Watch getQueue().size() climb.
Solution sketch
The pool never creates worker #3 because the unbounded queue is never full, so growth step 3 (queue full → grow to max) never fires. `getLargestPoolSize()` == 2; `getQueue().size()` rises without limit. Swap to `new ArrayBlockingQueue<>(100)` and `getLargestPoolSize()` climbs toward 10 once the queue fills — proving the growth rule and the trap in one experiment.Task 5 — Implement all four rejection policies¶
Goal: Feel each saturation policy's behavior.
Requirements: - Tiny pool (core=max=1, ArrayBlockingQueue(1)). - Submit 5 tasks rapidly under each of AbortPolicy, CallerRunsPolicy, DiscardPolicy, DiscardOldestPolicy. - Record which tasks run, which are dropped, and which throw.
Hints: Wrap submission in try/catch to observe RejectedExecutionException under AbortPolicy.
Solution sketch
- **Abort:** task 1 runs, task 2 queues, tasks 3–5 throw `RejectedExecutionException`. - **CallerRuns:** rejected tasks run on the *submitting* thread (submission blocks while they run) — no loss, but the producer is throttled. - **Discard:** rejected tasks vanish silently (no exception, no run). - **DiscardOldest:** the queued task is evicted and replaced by the newcomer — newest-wins. The lesson: the policy *is* your overload contract. Choose it consciously.Task 6 — Build a Go worker pool with results¶
Goal: Implement the pattern from primitives in Go.
Requirements: - N worker goroutines drain a jobs channel. - Results flow back on a results channel; collect all of them. - Graceful shutdown: close jobs, wait for workers via sync.WaitGroup, then close results.
Hints: Use a buffered jobs channel as the bounded queue. Close results only after wg.Wait().
Solution sketch
See the full implementation in [middle.md](middle.md) §5 (Go example). Key correctness points: (1) close `jobs` to signal "no more work" so `for range` exits; (2) a separate goroutine does `wg.Wait(); close(results)` so the result range terminates; (3) the buffer size on `jobs` is your bounded queue.Task 7 — Size an IO-bound pool with the Goetz formula¶
Goal: Compute a defensible pool size, then validate it.
Requirements: - Given: 16 cores, target CPU utilization 0.9, tasks that wait 180 ms (network) and compute 20 ms. - Compute N = N_cores × U × (1 + W/C). - Load-test at that size; watch queue depth and p99 latency; adjust.
Hints: W/C = 180/20 = 9.
Solution sketch
`N = 16 × 0.9 × (1 + 9) = 16 × 0.9 × 10 = 144` threads. Validate with a load test: if queue depth stays near zero and CPU sits near 90%, the size is right. If queue grows, either the downstream is the bottleneck (more threads won't help — they'll just queue inside the DB pool) or arrival rate exceeds capacity (Little's Law: `λ` too high for this `W`). 144 is also a hint that virtual threads would remove the sizing problem entirely.Task 8 — Reproduce and fix pool-induced deadlock¶
Goal: Make a pool deadlock itself, then fix it three ways.
Requirements: - Pool of size 2. Submit 2 tasks; each submits an inner task to the same pool and calls get(). - Observe the deadlock (no progress, both workers blocked). - Fix with: (a) a separate inner pool, (b) CompletableFuture.thenApplyAsync, (c) restructuring to not block a worker.
Hints: A thread dump shows both workers parked in FutureTask.get.
Solution sketch
The two outer tasks occupy both workers and block on `get()`; the two inner tasks sit in the queue with no free worker → deadlock. Fixes: **(a)** route inner tasks to `innerPool` so an idle worker runs them; **(b)** `supplyAsync(step1, pool).thenApplyAsync(step2, pool)` never parks a worker on `get()`; **(c)** compute the inner work inline instead of submitting-and-waiting. See [senior.md](senior.md) §4 and §7.Task 9 — Bulkhead two dependencies¶
Goal: Prove that isolated pools contain a dependency failure.
Requirements: - One service calls a fast cache and a slow payment dependency. - Version A: shared pool of 8. Version B: separate cachePool(8) and paymentPool(4). - Inject a 5-second latency into payment; measure cache request latency under both versions.
Hints: Drive concurrent load at both dependencies; record cache p99.
Solution sketch
In Version A, payment's latency parks all 8 shared threads; cache p99 explodes (or times out) despite cache itself being instant — cascading failure. In Version B, payment saturates only its 4-thread compartment; cache requests keep their own 8 threads and stay fast. The bulkhead turned a service-wide outage into a payment-only degradation. Add a circuit breaker on `payment` to fail fast once its pool is clearly overwhelmed.Task 10 — Migrate an IO pool to virtual threads¶
Goal: Replace a large platform-thread IO pool with virtual threads, keeping the downstream cap.
Requirements: - Start from a ThreadPoolExecutor(144, 144, ...) serving blocking HTTP calls (from Task 7). - Replace with Executors.newVirtualThreadPerTaskExecutor(). - Re-introduce the downstream concurrency cap with a Semaphore (the DB allows 20 connections). - Verify at most 20 concurrent DB calls despite thousands of virtual threads.
Hints: Semaphore(20); acquire/release around the DB call. Check for synchronized pinning with -Djdk.tracePinnedThreads=full.
Solution sketch
The virtual-thread executor removes the *thread* limit; the semaphore re-imposes the *resource* limit. If `tracePinnedThreads` reports pinning, replace `synchronized` blocks on the blocking path with `ReentrantLock`. See [senior.md](senior.md) §11 and [professional.md](professional.md) §4.How to Practice¶
- Always measure, don't assume. Print
getPoolSize,getLargestPoolSize,getQueue().size(),getCompletedTaskCount, and rejection counts. The numbers teach the growth rule and the trap faster than reading does. - Reproduce failures on purpose. Deliberately cause OOM (unbounded queue), deadlock (same-pool
get()), and starvation (shared pool + slow dependency) in small programs. Engineers who've seen these recognize them instantly in production. - Tune against a load generator, not a single run. The queue-lock cliff and tail-latency blowups only appear under concurrency.
- Diff platform vs virtual threads on the same IO workload at 10k concurrency to feel why Loom changes the calculus.
- Write the assertions first (max thread count, max concurrent downstream calls) so each task proves a property, not just "it ran."
In this topic