The Concurrency Pitfall
Consider a scenario where a fixed-size thread pool is used to process multiple primary operations. Each primary operation retrieves a collection of records and processes them sequentially. To optimize throughput, the processing of individual records is offloaded to the same thread pool as asynchronous sub-tasks, while the primary task waits for their completion using a synchronization barrier.
ExecutorService sharedPool = Executors.newFixedThreadPool(2);
int taskCount = 4;
CountDownLatch mainTracker = new CountDownLatch(taskCount);
for (int i = 1; i <= taskCount; i++) {
sharedPool.submit(() -> {
int subTaskCount = 2;
CountDownLatch subTracker = new CountDownLatch(subTaskCount);
System.out.println("Primary Operation " + i + " initiated on " + Thread.currentThread().getName());
for (int j = 1; j <= subTaskCount; j++) {
sharedPool.submit(() -> {
System.out.println("Sub-task " + j + " for Operation " + i + " processing on " + Thread.currentThread().getName());
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) {}
subTracker.countDown();
});
}
try {
subTracker.await();
} catch (InterruptedException e) {}
System.out.println("Primary Operation " + i + " finished on " + Thread.currentThread().getName());
mainTracker.countDown();
});
}
mainTracker.await();
System.out.println("All operations finalized.");Executing this logic results in an application hang. The program becomes unresponsive because the thread pool's limited capacity is entirely consumed by the primary operations, which are blocked waiting for the sub-tasks to finish. The sub-tasks are queued within the thread pool, waiting for an available thread. Since no threads will be freed until the sub-tasks complete—and no threads are available to run the sub-tasks—a cyclic dependency is formed. This creates a thread starvation deadlock.
Distributed System Implications
In microservice architectures, this pattern manifests less obviously. If Service A receives a request, offloads it to a global thread pool, and subsequently calls Service B, which then calls back into Service A using the same global thread pool, a similar deadlock occurs. Service A's threads are occupied waiting for Service B, while Service B's callback to Service A requires a thread from the same exhausted pool.
Resolving the Deadlock through Isolation
The solution is to enforce thread pool isolation. Parent and child tasks must not share the same constrained resource pool. By provisioning a dedicated pool for sub-tasks, primary operations can release their threads back to the pool once their immediate work is done, allowing the sub-tasks to execute concurrently without blocking the primary execution flow.
ExecutorService parentPool = Executors.newFixedThreadPool(2);
ExecutorService childPool = Executors.newFixedThreadPool(4);
int taskCount = 4;
CountDownLatch mainTracker = new CountDownLatch(taskCount);
for (int i = 1; i <= taskCount; i++) {
parentPool.submit(() -> {
int subTaskCount = 2;
CountDownLatch subTracker = new CountDownLatch(subTaskCount);
System.out.println("Primary Operation " + i + " initiated on " + Thread.currentThread().getName());
for (int j = 1; j <= subTaskCount; j++) {
childPool.submit(() -> {
System.out.println("Sub-task " + j + " for Operation " + i + " processing on " + Thread.currentThread().getName());
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) {}
subTracker.countDown();
});
}
try {
subTracker.await();
} catch (InterruptedException e) {}
System.out.println("Primary Operation " + i + " finished on " + Thread.currentThread().getName());
mainTracker.countDown();
});
}
mainTracker.await();
System.out.println("All operations finalized.");