3.3
Thread Pools and Deadlock
Reusing threads, sizing a pool, the four conditions for deadlock and how to break them.
55 min Reading + animations
Tip: tap or hover any dotted-underlined term to see what it means.
By the end of this lesson you will be able to
- Explain what a thread pool is, why servers use one, and what it costs, with the 10,000-tasks-on-20-workers arithmetic.
- Size a pool for CPU-bound and I/O-bound work, and spot when the real limit is a database rather than the CPU.
- Choose a bounded queue and a rejection policy, and predict the order in which a Java
ThreadPoolExecutorgrows. - Recognise pool exhaustion from its symptoms, and limit the damage with timeouts and bulkheads.
- Explain how a pool can deadlock on itself with no lock anywhere, and fix it.
- State the four Coffman conditions, draw a cycle of any length, and tell a deadlock from a long wait chain.
- Prevent deadlocks with lock ordering and the other techniques, and say which are prevention and which are recovery.
- Detect deadlocks with a wait-for graph, thread dumps and
pg_locks, and write correct retry logic for database deadlock victims. - Tell deadlock, starvation, livelock and priority inversion apart from what you see on a dashboard.
#Where this lesson fits
Lesson 1.2 introduced the thread-per-connection server and named thread-pool exhaustion. Lesson 1.13 showed the same pattern in PHP-FPM, Gunicorn and Tomcat, and the 504s you get when workers run out. Lesson 2.5 showed how long-lived connections can pin a thread each.
In this chapter, lesson 3.1 explained what a thread is and what it costs to create, and lesson 3.2 explained scheduling and why too many threads waste CPU. This lesson answers the question they leave open: how do you run lots of work on a limited number of threads, and what goes wrong when threads wait for each other?
The event-loop alternative is in lesson 3.6. Locks in real code (mutexes, semaphores, races, CAS) are Chapter 14; here we look at waiting and deadlock as system behaviour.
#What a thread pool is
A thread pool is a set of worker threads created in advance that take tasks from a queue and run them. Producers put tasks in; workers pull them out, run them and come back for the next one.
Incoming tasks
|
v
Task queue
/ | \
v v v
Worker Worker Worker
thread thread thread
Say an application receives 10,000 tasks. Instead of creating a new thread for each, you let 20 worker threads process them. If a task takes 50 ms:
- 20 workers finish 20 ÷ 0.05 s = 400 tasks per second.
- All 10,000 are done after 10,000 ÷ 400 = 25 seconds.
- There are never more than 20 threads, so creation cost and context switching stay under control.
The cost is that the last task waited about 25 seconds for 50 ms of work. A pool turns too much concurrency into queueing delay. That is usually the right trade, because a queue fails gradually and visibly, while 10,000 threads can bring the whole machine down.
A thread pool: one queue, a fixed number of workers
Each square is a thread or a queued task. Watch 10,000 tasks go through 20 workers, compare one thread per task, then see steady traffic and a dependency that suddenly slows down.
Scenario
Each task takes 50 ms, so 20 workers finish 400 tasks per second however many are waiting.
The pool creates its 20 threads once. With nothing to do they sleep on the queue and use no CPU, ready to start in microseconds.
#Benefits and problems
| Benefits | Why |
|---|---|
| Lower thread-creation cost | Threads are reused, so the system calls and stack setup happen once |
| Easier resource management | Stack memory and scheduler load have a known ceiling |
| Limits concurrency | At most N tasks run at once, which also protects whatever they call |
| Load control through the queue | Short bursts wait; long overloads can be refused |
| Problems | What it looks like |
|---|---|
| A large queue raises latency | Tasks spend longer waiting than running |
| All threads blocked | Thread starvation: the queue grows while CPU sits idle |
| Wrong pool size | Too small wastes hardware; too large wastes CPU and overloads what is behind it |
| One configuration for mixed work | CPU-bound and I/O-bound tasks want different sizes, and slow tasks hold up fast ones |
#Using one
Here is the simplest Java version:
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(() -> {
System.out.println("Task running");
});
executor.shutdown(); // finish queued tasks, accept no new ones
It is fine for a demo, but its queue has no limit, which is a real hazard (see below). In production, spell the settings out:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
20, 20, // core and maximum threads
0L, TimeUnit.MILLISECONDS, // keep-alive for extra threads
new ArrayBlockingQueue<>(200), // bounded queue
new ThreadPoolExecutor.AbortPolicy()); // reject when full
A thread pool is the producer-consumer pattern with tasks as the data: request handlers produce, workers consume, and the queue separates their speeds. The difference is emphasis. Producer-consumer describes a data pipeline; a thread pool is about running tasks on a managed set of threads.
#Where you already meet pools
| System | Pool | Typical setting |
|---|---|---|
| Tomcat | Request threads | maxThreads default 200 |
| PHP-FPM | Worker processes (a process pool) | pm.max_children, set by you |
| Gunicorn | Worker processes, optionally with threads | --workers, --threads |
Python ThreadPoolExecutor | Worker threads | Default min(32, cores + 4) |
| Node.js libuv | Workers for file and DNS jobs | 4 by default (lesson 3.6) |
Java ForkJoinPool common pool | Work-stealing workers for parallel streams | Cores − 1 |
| HikariCP, PgBouncer | Database connections, not threads | The same ideas apply exactly |
The last row matters. Everything in this lesson applies to any pool of scarce things: connections, processes, or memory buffers.
#How many threads?
There is no universal number, but there is a good way to reason. A thread that waits for I/O gives up its core, so the answer depends on how much of each task is waiting.
CPU-bound work (image resizing, compression, hashing) never waits, so one thread can fill one core:
threads ≈ cores
I/O-bound work (a web request that mostly waits on a database) leaves the core free while it waits, so more threads can share it:
threads ≈ cores × (1 + wait time ÷ compute time)
The version in Java Concurrency in Practice adds a target utilisation U between 0 and 1: cores × U × (1 + wait ÷ compute).
A worked example: 4 cores, and each request computes for 10 ms and waits 90 ms.
| Threads | Throughput | CPU | What is happening |
|---|---|---|---|
| 4 | 4 ÷ 0.1 s = 40/s | about 10% | Cores idle; you pay for hardware you are not using |
| 28 (U = 0.7) | 280/s | 70% | Good: room for bursts |
| 40 (the formula) | 400/s | 100% | The maximum, but no headroom, so queues form |
| 100 | still 400/s | 100% | Threads queue for a core: more latency, no more throughput |
How many threads? Watch the cores
Four cores in every scenario. A thread that waits for I/O frees its core, so the right count depends on how much of each task is waiting.
Scenario
Image resizing: all computing, no waiting. Threads ≈ cores.
Four threads on four cores. Each core is always working and nothing has to wait. A CPU-bound task can never use more than one core at a time, so this is already the maximum.
The formula is a starting point, and it looks only at the CPU. Check four more things:
- The narrowest downstream resource. With 40 threads and 10 database connections held for 90 ms, the ceiling is 10 ÷ 0.09 s ≈ 111 requests per second. The other threads queue for a connection, which adds memory use and hides the real limit.
- Little's law as a cross-check. Threads in use = arrival rate × time per request. 500 requests per second at 0.1 s needs 50 threads. If each request takes 2 s, it needs 1,000. Lesson 1.2 used this, and Chapter 17 treats it in full.
- What "cores" really means. In a container with a CPU limit, count the limit, not the host's cores (lesson 3.2 covers throttling). Hyper-threads are not full cores either. Several pools in one process share those same cores, so the pools' numbers must add up.
- The measured result. Run a load test and raise the size until throughput stops rising or latency jumps. That is your number, whatever the formula said.
For database connection pools, the PostgreSQL community's starting point, quoted in the HikariCP documentation, is connections ≈ (cores × 2) + effective disk count, counted on the database server. It is often surprisingly small. A database with too many active connections spends its time on lock and scheduling contention, and gets slower for everyone.
#Separate pools for different work
Put slow and fast tasks, or CPU-bound and I/O-bound tasks, in separate pools. In a shared pool, a few long tasks take all the workers and short tasks wait behind them. A CPU pool sized at the core count and an I/O pool sized by the formula can then be tuned and monitored separately.
#Queues: bounded, and what to do when full
The queue is where a pool decides how it behaves under overload. An unbounded queue never says no, so overload shows up as memory growth and huge latency, and then as a crash that loses everything queued. A bounded queue turns overload into an explicit decision.
How long should it be? Decide how long a task may usefully wait:
queue length ≈ throughput × acceptable wait
At 400 tasks per second, with clients that give up after about a second and a half second you can afford to spend waiting, that is 400 × 0.5 = 200. Anything queued beyond that would be finished after nobody cares. It is better to refuse it now.
Java's ThreadPoolExecutor grows in an order that surprises most people:
- Fewer than core threads running: start a new thread.
- Otherwise: put the task in the queue.
- Queue full: start extra threads, up to maximum.
- At maximum too: reject the task.
So extra threads start only after the queue is full. With a large or unbounded queue, the maximum never takes effect. Executors.newFixedThreadPool uses an unbounded queue, and Executors.newCachedThreadPool does the opposite: no queue capacity and no practical thread limit, so a burst can create thousands of threads. Tomcat's request pool is an exception: it adds threads before queueing, using its own executor.
| Rejection policy | What happens | Good for | Danger |
|---|---|---|---|
AbortPolicy (Java's default) | Throws RejectedExecutionException at the caller | Request handling: map it to 503 with Retry-After | The caller must actually handle it |
CallerRunsPolicy | The submitting thread runs the task | Batch jobs: natural backpressure, nothing lost | Disaster if the caller is an event loop or acceptor thread |
DiscardPolicy | Drops the new task silently | Sampled metrics you can afford to lose | Silent data loss |
DiscardOldestPolicy | Drops the oldest queued task | Latest-value updates, where old data is worthless | Silent data loss, and it can drop something important |
Bounded queues and what happens when they fill
A Java ThreadPoolExecutor with 2 core threads, a maximum of 4 and a queue of 4. The order in which it grows surprises most people, and the rejection policy decides who feels the overload.
Scenario
Nine slow tasks arrive one after another.
Tasks 1 and 2: fewer than the core count are running, so the pool starts a new thread for each.
Three more rules from production:
- Drop tasks that are already too old. A worker that picks up a request whose client timed out 10 seconds ago should skip it. Carry a deadline with the task and check it first.
- An in-memory queue dies with the process. If tasks must survive a crash or deploy (emails, payments), the queue belongs in a durable system. Chapters 30 to 32 cover that.
- Rejecting is a feature. Fast failure for a few protects the rest. Chapter 29 and Chapter 28 build rate limiting and load shedding on the same idea.
#Pool exhaustion: the classic incident
Thread-pool exhaustion is one of the most common production failures. It looks strange because nothing appears broken: there is no crash, CPU is low, and yet everything is slow.
The sequence is always much the same. One dependency slows down. Threads calling it stop returning. The number of threads held grows at arrival rate × hold time, so a 30 s timeout at 50 requests per second can hold up to 1,500 threads, far more than a pool of 200. The pool fills in seconds, and now every endpoint queues, including ones that never touch the slow dependency. Then retries add load and health checks fail, so the load balancer removes instances and pushes their traffic onto the rest.
One slow dependency takes down every endpoint
An API with one shared pool of 200 threads. Search gets 450 requests per second and takes 0.1 s. Checkout gets 50 per second and calls a payment provider. The provider starts hanging.
Scenario
The HTTP client’s timeout is 30 s. Payments has failed, but the search users are the ones who notice.
Normal day: search uses 450 × 0.1 s = 45 threads and checkout uses 50 × 0.2 s = 10. There is plenty of room.
How to recognise it, and what each symptom rules out:
| Signal | Reading |
|---|---|
| Latency high, CPU low | Threads are waiting, not working. This is not a CPU problem |
| Active threads = maximum, queue length rising | The pool is full |
| Thread dump: most threads in the same stack frame | That frame is the slow dependency |
| 504s at the proxy, or connections hang with no response | The app accepts connections but has no thread to serve them |
| Unrelated endpoints slow at the same time | A shared pool, with no isolation |
Monitor these for every pool: active threads, queue length, task wait time (separate from run time) and rejection count. Wait time is the early warning. It rises before users complain.
The fixes, in the order to apply them:
- A timeout on every outbound call. It sets the hold time. Without it, one dependency can hold a thread forever. Chapter 28 covers how to choose values.
- A bounded queue and fast rejection, as above.
- Bulkheads. Give risky dependencies their own small pool or a concurrency limit, so a failure stays in its compartment. Chapter 28 covers bulkheads and circuit breakers.
- Match pools along the path. 200 request threads over 20 database connections means 180 threads may queue on the connection pool. That can be fine for short bursts, but set a short connection-acquire timeout so it fails instead of hanging.
- Consider a different concurrency model for work that mostly waits: an event loop or virtual threads (lesson 3.6).
#A pool that waits for itself
A pool can deadlock with no lock in sight. A task running in a pool submits a child task to the same pool and blocks until the child finishes. With light load there is a free worker for the child. Under load, every worker holds a parent that is waiting for a child, and the children sit in the queue waiting for a worker that will never be free.
A pool that waits for itself
A task submits a child task to the same pool and blocks until the child is done. It passes every test and then freezes under load.
Scenario
One parent task on a pool of 2 workers.
One parent runs on worker 1, submits a child and blocks. Worker 2 is free.
This passes tests, because tests rarely fill the pool. In production it freezes the service with zero CPU. The fixes:
- Do not block inside a pool task on another task from that pool. Chain the next step instead (
thenComposeon aCompletableFuture). - Use a separate pool per level of dependency, so the dependency runs one way and cannot form a cycle.
- Use a work-stealing pool such as
ForkJoinPoolfor recursive work, where a waiting worker can run other queued tasks. - Always add a timeout to
get()and similar calls, so a freeze becomes an error you can see.
The same bug exists with connection pools: each request holds one connection and asks for a second, for instance through a nested transaction in a separate connection. The HikariCP documentation gives the minimum safe size: pool size = threads × (connections per task − 1) + 1. With 10 threads that need 2 connections each, that is 11. A pool of 10 can freeze until its timeout fires.
#Other everyday pool traps
- Exceptions disappear. With
submit, an exception is kept in the returned future. If nobody reads the future, nobody ever sees the error. Log inside the task or check the future. - Thread-local data leaks between tasks. Threads are reused, so a user id or security context left in a thread-local variable by one task is visible to the next, which can become a security bug. Always clear it in
finally. - Shutdown needs care. Call
shutdown(), wait withawaitTerminationfor a limited time, thenshutdownNow()if you must. Do this when you get SIGTERM (lesson 3.1), or the tasks in the queue are lost on every deploy. - Name your threads. A thread dump full of
pool-3-thread-17tells you little;payment-client-17tells you where to look. - Do not pool virtual threads. They are cheap by design, so limit concurrency with a semaphore instead (lesson 3.6).
#Deadlock
A deadlock is a state in which two or more threads or processes each wait for a resource that another of them holds, so none of them can ever continue. The note's short version mentions processes, but it applies to anything that can hold and wait: threads, processes, database transactions, or services calling each other.
Take two threads and two locks:
Thread A: Thread B:
acquire Lock 1 acquire Lock 2
wait for Lock 2 wait for Lock 1
Now:
Thread A holds Lock 1, waits for Lock 2
Thread B holds Lock 2, waits for Lock 1
Neither will release, so both wait forever. This is what makes deadlock so awkward: it depends on timing. The same code usually runs fine, and it freezes only when both threads get their first lock before either asks for its second.
Two threads, two locks, one fatal timing
A takes Lock 1 then Lock 2. B takes Lock 2 then Lock 1. Whether that deadlocks depends only on timing, which is why it can pass every test.
Scenario
A runs just far enough ahead of B. Nothing goes wrong, today.
Who holds what
| Thread | Holds | Waits for |
|---|---|---|
| A | Lock 1 | - |
| B | - | - |
Thread A takes Lock 1. It is free, so A gets it at once.
#The four Coffman conditions
In 1971 Coffman, Elphick and Shoshani described four conditions that must all hold at once for a deadlock to exist. They are necessary conditions: remove any one and deadlock is impossible.
| Condition | Meaning | Example | How to remove it |
|---|---|---|---|
| Mutual exclusion | A resource can be used by only one thread at a time | A mutex lock | Share it: read-only data, immutable objects, lock-free structures |
| Hold and wait | A thread holds a resource while waiting for another | Holding Lock 1, waiting for Lock 2 | Take all at once, or release before waiting |
| No preemption | A resource cannot be taken away from its holder; only the holder can release it | The OS will not take a mutex from a thread | Timeouts, aborting a victim |
| Circular wait | The waiting threads form a closed chain | A waits for B, B waits for A | A global lock order |
Mutual exclusion is rarely removable, because if a thing could be shared you would not have locked it. In practice you attack the other three, and most of all circular wait.
The note's three-thread sketch mixes up holding and waiting, so here it is redrawn clearly:
Thread A holds R1, waits for R2 (held by B)
Thread B holds R2, waits for R3 (held by C)
Thread C holds R3, waits for R1 (held by A)
A → B → C → A the cycle
Three threads in a ring, and how a detector sees it
A holds R1 and waits for R2, B holds R2 and waits for R3, C holds R3 and waits for R1. Then see a chain that only looks stuck, the wait-for graph, and what killing a victim does.
Scenario
Deadlock is not limited to two threads. A cycle can be any length, which makes it harder to spot in code.
Who holds what
| Thread | Holds | Waits for |
|---|---|---|
| A | R1 | - |
| B | R2 | - |
| C | R3 | - |
Each thread takes its first resource: A gets R1, B gets R2, C gets R3. Everything looks healthy.
Two details interviewers like:
- A long wait chain is not a deadlock. A → B → C, with C running, drains on its own when C finishes. Many threads blocked on one slow lock holder looks similar in a dashboard, but the fix is different.
- A cycle guarantees deadlock only when each resource has a single instance. With several interchangeable instances (a pool of 5 connections), a cycle in the resource graph may still resolve, because some other holder can release one. So with pools, the question becomes "can everyone be waiting at once?", which is what the formula in the previous section answers.
#Handling deadlock: four strategies
There are four broad approaches, and real systems combine them.
| Strategy | Idea | Where you see it |
|---|---|---|
| Prevention | Design so one condition can never hold | Lock ordering in application code |
| Avoidance | Check each request at runtime and refuse unsafe ones (the banker's algorithm) | Mostly textbooks, because it needs to know future demand |
| Detection and recovery | Allow it, find the cycle, abort a victim | Databases |
| Ignore it | Accept the rare freeze and restart | Many operating systems for user programs; watchdogs and health checks that restart a process |
#Prevention in practice
| Technique | Removes | Cost |
|---|---|---|
| Lock ordering | Circular wait | Everyone must obey it, in every path |
| Take all locks at once | Hold and wait | Must know all locks up front; less concurrency |
| Keep lock scope small | Reduces hold and wait | Multi-step updates may no longer be atomic |
| Avoid unnecessary nested locks | Hold and wait | Sometimes needs redesign |
| Timeout and back off | Hold and wait, no preemption (in effect) | This is recovery, not prevention: the cycle can still form, then dissolves |
| Lock hierarchy | Circular wait | Ordering by layers, for larger codebases |
Lock ordering is the most useful one:
Always:
Lock 1 → Lock 2
Nobody takes Lock 2 and then Lock 1. Every wait now points from a lower lock to a higher one, and arrows that only go up cannot form a loop.
When the order comes from data, sort it. transfer(from, to) that locks from and then to deadlocks when 7→9 and 9→7 run together, because the two calls take opposite orders. Lock the smaller account id first, whichever way the money moves. The same applies to database rows.
Ordering can be broken in places you do not read. If you hold a lock while calling a callback, listener or library, that code may take other locks in any order. Do not call code you do not control while holding a lock, and never hold a lock across a network call: it turns a slow dependency into a queue of blocked threads, and two services doing this to each other can form a cycle that crosses machines.
Four ways to avoid the deadlock, and one trap
Each fix removes one of the four conditions. Same two threads, same two locks.
Scenario
Everyone takes Lock 1 before Lock 2. This removes circular wait.
A takes Lock 1, the first lock in the agreed order.
Code-level details for all of this (synchronized, ReentrantLock.tryLock, Go and Python) are in Chapter 14.
#Detecting deadlocks
The wait-for graph is the core idea. Draw one node per thread and an edge from each waiter to the holder of what it wants. A cycle means a deadlock. Databases and the JVM build this graph from their lock tables.
In a running process, take a thread dump:
| Runtime | Command | What you get |
|---|---|---|
| Java (JDK tools) | jstack <pid>, jcmd <pid> Thread.print or kill -3 <pid> | All stacks, and a "Found one Java-level deadlock" section if there is a cycle |
| Go | kill -QUIT <pid> | All goroutine stacks. The runtime reports a deadlock only if all goroutines are asleep |
| Python | py-spy dump --pid <pid>, or faulthandler | Stacks of all threads |
| Native code | gdb -p <pid>, then thread apply all bt | Stacks of all threads; find the cycle by hand |
Take two or three dumps some seconds apart. Threads stuck at the same place in all of them are truly stuck. Many threads in one frame, and one that moves, is a slow holder, not a deadlock.
In a database, the engine usually finds it for you:
-- PostgreSQL: who is blocked, and by whom, right now
SELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event_type, state, query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- The locks themselves
SELECT locktype, relation::regclass, mode, granted, pid
FROM pg_locks
WHERE NOT granted;
In MySQL, SHOW ENGINE INNODB STATUS has a LATEST DETECTED DEADLOCK section that shows both transactions.
| PostgreSQL | MySQL InnoDB | |
|---|---|---|
| When it checks | After a wait exceeds deadlock_timeout (default 1 s) | Right when a transaction would wait |
| Which is aborted | Hard to predict; do not rely on it | The one it judges smallest (rows changed) |
| Error | 40P01 deadlock detected | 1213, SQLSTATE 40001 |
| Plain lock timeout | lock_timeout, off by default | innodb_lock_wait_timeout, 50 s by default, error 1205; rolls back only the statement by default |
A database finds the cycle and picks a victim
Two transactions update the same two rows in opposite order. Unlike threads, a transaction can be rolled back safely, so databases do recover automatically, and your code must retry.
Scenario
PostgreSQL waits first and looks for a cycle only if a lock wait lasts longer than deadlock_timeout.
An update takes a row lock and keeps it until commit or rollback. So every statement adds to what the transaction is holding.
A deadlock error is not a bug in the database. It is normal behaviour that your code must handle: catch the error, wait a small random time and retry the whole transaction. Then reduce how often it happens: consistent row order, shorter transactions, no user interaction or remote calls inside a transaction, and indexes so that updates lock only the rows they need. Log deadlocks (log_lock_waits in PostgreSQL) and treat a rising rate as a bug report. Chapter 22 covers transactions further.
Across machines, no one sees the whole graph. If transaction 1 holds a row on shard A and waits on shard B, while transaction 2 does the opposite, each database sees only an ordinary lock wait. The same goes for service A calling B while B calls A, each using up the other's pool. The practical answer is timeouts everywhere, plus a consistent order of access.
#Deadlock vs starvation vs livelock
| Deadlock | Starvation | Livelock | |
|---|---|---|---|
| What happens | Everyone involved is stuck | One thread gets no chance for a long time | All are active, but there is no progress |
| CPU activity | Usually none from those threads | Others run normally | High: threads keep running |
| Does the system progress? | Not those threads | Yes, except the victim | No |
| Ends on its own? | Never | Possibly, if load drops | Possibly, by luck in timing |
| Fix | Lock and resource design | Fair scheduling, aging | Backoff with randomness, a tie-breaker |
| What you see | Frozen, low CPU, same stacks in each dump | One kind of work has huge latency, the rest is fine | Busy CPU, zero throughput, stacks keep changing |
Livelock: threads keep reacting to each other to avoid a conflict, and the actual work never advances. It is often caused by a well-meant fix for deadlock, namely tryLock and retry with no delay. The cure is a random delay, so one thread gets ahead.
Livelock: everyone is polite, nothing happens
Two threads both back off when they see a conflict. If they keep doing it in step, the CPU is busy and no work gets done, until randomness breaks the tie.
Scenario
Each thread tries the second lock without waiting, and releases and retries straight away if it fails.
Round 1. A takes Lock 1 and B takes Lock 2.
Starvation: a thread keeps losing, usually a low-priority one that misses out on CPU or a resource. Priority is not the only cause. A stream of readers can starve a writer on a read-write lock, and an unfair lock can favour whichever thread is already running. Fair locks and queues fix it, at some cost in throughput. Aging raises a waiting task's priority with time.
#Priority inversion
Priority inversion means a high-priority task is held up by a lower-priority one because of a lock. The note's definition stops at "Low holds a lock that High wants". A short wait like that is normal. The dangerous version needs a third task: Medium, which does not need the lock, preempts Low. Now Low cannot finish, so High cannot run, for as long as Medium runs. Medium has effectively outranked High.
The famous case is Mars Pathfinder in 1997. Soon after landing, the lander's computer kept resetting. A low-priority weather task held a mutex on a shared bus, a high-priority bus task waited for it, and a medium-priority communications task kept Low from running. A watchdog saw that the high-priority task had missed its deadline, and reset the system. Engineers reproduced it on a ground copy, then uploaded a small change turning on priority inheritance for that mutex in VxWorks, and the resets stopped.
Priority inheritance means that while Low holds a lock that High wants, Low runs at High's priority. Linux offers this for real-time work with PTHREAD_PRIO_INHERIT.
Starvation and priority inversion
Three tasks with different priorities and one shared lock. In one case the low task never runs. In the other, the low task ends up holding back the most important one.
Scenario
The system as a whole makes progress, but one task never gets a turn.
High takes the lock, works and releases it.
You may never write real-time code, but the shape appears everywhere. A nightly batch job holds a row lock on a busy table at low CPU priority, and customer checkouts wait behind it. A low-priority task inside a shared lock gets throttled by its container's CPU limit, and everything needing that lock stalls. The rule is the same: never let low-priority work hold something that high-priority work needs for long.
#What this means for system design
- Every pool is a concurrency limit and a queue. Name them on your diagram: request threads, database connections, outbound clients. Overload shows up at the smallest one.
- Size from numbers. Arrival rate × time per request gives the threads you need; cores × (1 + wait ÷ compute) gives the threads the CPU can use. If the first is far larger than the second, you need more machines or a different model, not a bigger pool.
- Bound everything. Bounded queues, timeouts and rejection make overload a controlled, visible event.
- Isolate. One slow dependency must not be able to take all the threads.
- Assume database deadlocks will happen. Order your row access and always write the retry.
- Do not hold locks across slow work, and above all not across a network call.
- Distributed locks can deadlock too. A lease or expiry is their way of adding preemption. Chapter 38 covers them.
#Interview angles
| Question | A strong answer includes |
|---|---|
| "How would you size this thread pool?" | CPU vs I/O formula, then the downstream limit, Little's law, a load test |
| "All requests are slow but CPU is at 10%. What is going on?" | Threads waiting: exhausted pool, slow dependency, lock contention; check a thread dump and pool metrics |
| "What is a deadlock? How do you prevent it?" | Four conditions, then lock ordering; timeouts are recovery; data-driven order by sorting ids |
| "Two money transfers deadlock. Fix it." | Lock accounts in id order; in SQL, update rows in id order and retry on 40P01 or 1213 |
| "What happens when your queue is full?" | Choice of rejection policy; 503 with Retry-After; never an unbounded queue |
| "Deadlock or livelock?" | Deadlock: low CPU, frozen stacks. Livelock: high CPU, no throughput, changing stacks |
| "How do you stop one slow service affecting others?" | Timeouts, bulkheads, circuit breakers (Chapter 28) |
#Try it yourself
1. Watch the thread count change throughput. Save and run this. 200 tasks each wait 50 ms, as a database call would:
import time
from concurrent.futures import ThreadPoolExecutor
def io_task(_):
time.sleep(0.05) # 50 ms of waiting
for workers in (1, 4, 20, 100):
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(io_task, range(200)))
print(f"{workers:>3} workers: {time.perf_counter() - start:5.2f} s")
You should see about 10 s, 2.5 s, 0.5 s and 0.1 s. These tasks only wait, so more workers help. Change time.sleep to a busy CPU loop and the benefit disappears.
2. Make a real deadlock and read the stacks. The threads are daemons, so the program can still exit:
import faulthandler
import threading
import time
lock1 = threading.Lock()
lock2 = threading.Lock()
def worker_a():
with lock1:
time.sleep(0.1) # give B time to take lock2
with lock2:
print("A finished")
def worker_b():
with lock2: # swap these two lines to apply lock ordering
time.sleep(0.1)
with lock1:
print("B finished")
a = threading.Thread(target=worker_a, name="A", daemon=True)
b = threading.Thread(target=worker_b, name="B", daemon=True)
a.start()
b.start()
time.sleep(1)
print("alive after 1 s:", a.is_alive(), b.is_alive())
faulthandler.dump_traceback(all_threads=True)
The dump shows each thread stuck on the line of its second lock. Now make worker_b take lock1 first, run it again and watch both finish.
3. Deadlock a pool with no locks:
import os
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FutureTimeout
pool = ThreadPoolExecutor(max_workers=2)
def child():
return "child done"
def parent():
return pool.submit(child).result() # blocks a worker until child runs
parents = [pool.submit(parent) for _ in range(2)]
try:
print([p.result(timeout=2) for p in parents], flush=True)
except FutureTimeout:
print("stuck: both workers wait for children that are still queued", flush=True)
os._exit(0) # a stuck pool would block a normal exit
Change range(2) to range(1) and it works, which is exactly how this bug gets through testing.
4. Make PostgreSQL pick a victim. Open two psql sessions:
CREATE TABLE accounts (id int PRIMARY KEY, balance int);
INSERT INTO accounts VALUES (1, 100), (2, 100);
-- Session 1:
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- Session 2:
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 2;
-- Session 1 (this one waits):
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
-- Session 2 (after about a second, one session gets the error):
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
-- ERROR: deadlock detected
While session 1 waits, run the pg_blocking_pids query above in a third session.
5. Take a thread dump of any Java service you run: jcmd <pid> Thread.print. Count how many threads are in each state and where most of them are waiting.
#Summary
Key takeaways
- A thread pool is a queue plus a fixed set of reused workers. It limits concurrency and turns overload into queueing delay.
- CPU-bound: threads ≈ cores. I/O-bound: threads ≈ cores × (1 + wait ÷ compute). Then check the narrowest downstream resource and load test.
- More threads help only while something is idle. Beyond that they add latency.
- Use bounded queues and a deliberate rejection policy. Java's
ThreadPoolExecutorfills the queue before starting extra threads. - Pool exhaustion looks like high latency with low CPU. Timeouts cap the hold time, bulkheads contain the damage.
- A pool whose tasks block on tasks in the same pool can deadlock with no locks.
- Deadlock needs all four Coffman conditions: mutual exclusion, hold and wait, no preemption, circular wait. Break any one.
- Lock ordering is the main prevention; sort by id when the order comes from data. Timeouts are recovery, not prevention.
- A wait chain is not a deadlock; only a cycle is. Find cycles with a wait-for graph, thread dumps and
pg_locks. - Databases abort a victim, so your code must retry the whole transaction. Across machines nothing detects the cycle, so use timeouts.
- Deadlock: frozen, low CPU. Livelock: busy, no progress. Starvation: one loser while the rest progress. Priority inversion: Medium delays High through a lock held by Low.
Answer on your own first, then read the explanation, even when you got it right.
Self-checkQuestion
What is the main reason to use a thread pool rather than a new thread for each task?
Self-checkQuestion
A pool has 20 workers and each task takes 50 ms. A burst of 10,000 tasks arrives at once. Roughly how long until the last one finishes?
Self-checkQuestion
A service does image resizing, purely CPU work, on an 8-core machine. What is the sensible starting pool size?
Self-checkQuestion
A 4-core server handles requests that compute for 20 ms and wait 180 ms on I/O. What does the sizing formula give, at full CPU use?
Self-checkQuestion
A pool of 40 threads calls a database through 10 connections, and each query holds a connection for 100 ms. What is the ceiling on queries per second, whatever the thread count?
Self-checkQuestion
During an incident the dashboard shows CPU at 8%, p99 latency at 40 s, and the active thread count pinned at its maximum. What is the most likely cause?
Self-checkQuestion
A thread pool in Java is set to core 10, maximum 50, with a queue of capacity 10,000. Traffic is heavy but stays below 10,000 queued tasks. How many threads run?
Self-checkQuestion
What is the main hazard of Executors.newFixedThreadPool(20) in a server under sustained overload?
Self-checkQuestion
Which risk is specific to Executors.newCachedThreadPool under a burst of slow tasks?
Self-checkQuestion
An API's request pool is full. Which response is usually best for the user and the system?
Self-checkQuestion
Which two statements about CallerRunsPolicy are correct?
Choose 2
Self-checkQuestion
An app processes 200 tasks per second and users give up after a second. You decide up to 0.5 s of waiting is acceptable. What queue length fits?
Self-checkQuestion
A service has one shared pool of 200 threads. Checkout calls a payment provider that starts hanging, with a 30 s client timeout, at 50 checkouts per second. Why does /search also go down?
Self-checkQuestion
In the same service, you want search to stay healthy during the payment outage. Which two changes help most directly?
Choose 2
Self-checkQuestion
A service freezes under load with CPU at 0. A dump shows all workers in a pool blocked in future.get(), and the queue holds tasks submitted by those same workers. What is this?
Self-checkQuestion
Each task needs 2 database connections at once, and 10 threads run tasks concurrently. By the HikariCP formula, what is the smallest pool that cannot freeze?
Self-checkQuestion
Why can thread-local data be a security problem in a thread pool?
Self-checkQuestion
Which is NOT one of the four Coffman conditions?
Self-checkQuestion
Thread A holds R1 and waits for R2. Thread B holds R2 and waits for R3. Thread C holds R3 and is running normally. Is this a deadlock?
Self-checkQuestion
Function transfer(from, to) locks from, then to. Deadlocks appear when transfer(7, 9) and transfer(9, 7) run together. What is the best fix?
Self-checkQuestion
Which Coffman condition does a global lock order remove?
Self-checkQuestion
A team says their tryLock with a 100 ms timeout prevents deadlocks. What is the most accurate response?
Self-checkQuestion
A team follows a strict lock order, yet a deadlock appears in a thread dump. Which two are the likely causes?
Choose 2
Self-checkQuestion
PostgreSQL returns error 40P01 to your transaction. What should the application do?
Self-checkQuestion
Why does PostgreSQL wait for deadlock_timeout, by default 1 s, before looking for a cycle?
Self-checkQuestion
Two transactions deadlock across two different database shards. Why does neither database report a deadlock?
Self-checkQuestion
CPU is at 100%, throughput is zero, and thread dumps taken seconds apart show threads in different places, repeatedly locking and unlocking. What is this?
Self-checkQuestion
Which two signs distinguish a deadlock from a long slow wait on one lock holder?
Choose 2
Self-checkQuestion
A high-priority task waits for a lock held by a low-priority task, while a medium-priority task that does not need the lock keeps running. What is the standard fix?
Self-checkQuestion
A writer on a read-write lock has waited for minutes because a constant stream of readers keeps the lock held. Readers are progressing normally. What is this, and what helps?