Module 06 — Threads, Races & Deadlock

Updated 22 August 2026

Module 06 · Threads, races and deadlock

Module 02 showed how one process becomes two. This module is the other option: one process doing several things at once, sharing everything — which is faster, and which introduces a class of bug that only shows up under load.

🧠 concept → 🧩 real-world analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start, you should already know:

From Module 01 — system calls, strace, and why entering the kernel costs something.

From Module 02 — processes, fork, clone, PIDs, and states R, S, D.

From Module 03 — file descriptors.

From Module 04 — signals.

This module comes before scheduling on purpose: Linux schedules threads, not processes, so you need to know what a thread is first.


🧵 Part A · What a thread is

A1 · Threads and processes — what is shared

Official docs: pthreads(7) · clone(2) · proc(5)

A thread is a separate line of execution inside a process. One process, several things happening at once.

The whole subject comes down to one question: what is shared, and what is not.

Shared by all threads

The memory — heap, globals, everything

Open file descriptors

The current directory

Signal handlers

The user and group IDs

The PID

Private to each thread

Its own stack

Its own CPU registers

Its own thread ID

Its own errno

Its own signal mask

Its own scheduling priority

That left-hand column is the whole point of threads, and the whole danger of them. Two threads can read and write the same variable with no system call, no copying and no coordination. That is why threads are fast. It is also why Part B exists.

Compare with fork from Module 02, where copy-on-write gave each process its own memory. Two processes cannot corrupt each other's variables. Two threads can, easily.

Real-world analogy — flatmates and neighbours

Processes are neighbours. Separate houses, separate front doors, separate fridges. To give your neighbour something you have to actually carry it round — that is inter-process communication, and it is deliberate work. The upside is that whatever happens in their kitchen cannot spoil your dinner.

Threads are flatmates. One flat, one kitchen, one fridge, one front door. Everything in the shared space is available to everyone instantly, with no arranging.

Each flatmate still has their own bedroom — that is the stack. Your own things, your own place in what you are doing, and nobody else goes in there.

The trade is obvious once you put it that way. Sharing a fridge is far more convenient than carrying food between houses. It also means somebody can drink your milk, and neither of you did anything wrong — you just both assumed it would be there.

Where the analogy stops working, and it matters. A flatmate who sets the kitchen on fire affects the flat; the neighbours are fine.

With threads there is no such boundary. One thread crashing takes the whole process down, every thread with it, because they share one address space. That is the real safety difference between threads and processes, and it is why browsers put tabs in separate processes rather than threads.

🧪 Exercise A1.1 — Count threads, not processes
bash
# How many PROCESSES are on this machine?
ps -e --no-headers | wc -l

# How many THREADS? (-L shows one line per thread)
ps -eL --no-headers | wc -l

# Which programs have the most threads?
ps -eLo pid,comm --no-headers | awk '{c[$1" "$2]++} END {for (k in c) print c[k], k}' \
  | sort -rn | head -6

# A process's threads are listed in /proc/PID/task
ls /proc/1/task
grep -E '^(Name|Pid|Threads)' /proc/1/status
Expected result — click to reveal
plain text
$ ps -e --no-headers | wc -l
243

$ ps -eL --no-headers | wc -l
412

$ ps -eLo pid,comm --no-headers | awk '{c[$1" "$2]++} END {for (k in c) print c[k], k}' | sort -rn | head -6
14 812 sshd
 9 1 systemd
 4 756 rsyslogd
 2 640 systemd-udevd
 1 3901 bash

$ ls /proc/1/task
1  128  129  130  131  147  148  149  150

$ grep -E '^(Name|Pid|Threads)' /proc/1/status
Name:	systemd
Pid:	1
Threads:	9

What to read out of it.

243 processes, 412 threads. Nearly 170 more lines of execution than there are processes. Every ps command you ran in Modules 02 to 05 was hiding most of them.

/proc/1/task is the key directory. It lists one entry per thread, and those numbers are thread IDs. Notice the first one is 1 — the same as the PID. Section A2 explains why.

Those nine systemd threads all share PID 1, one address space, and one set of open files. ps without -L shows them as a single line, which is usually what you want and occasionally exactly what is hiding your problem.

Now imagine this at 500 hosts. A Java or Go service showing 100% CPU in ps on a 32-core host is using about 3% of the machine — host dashboards will call that idle. And ps at the process level cannot tell you whether that 100% is one thread pinned to a core or thirty threads at 3% each, which are completely different problems.

ps -eLo pid,tid,pcpu,comm --sort=-pcpu | head shows per-thread CPU and finds the hot one immediately. top -H does the same interactively. If a service is slow and the process-level numbers look fine, this is the first thing to try.

A2 · How Linux really implements threads

Here is the part that makes Linux threads click, and it follows directly from Module 02.

That module said Linux has one process-creation call, clone, and that fork is clone with "share nothing". A thread is the same call with "share almost everything".

FlagWhat it shares with the caller
CLONE_VMThe memory. Writes by one are visible to the other immediately.
CLONE_FILESThe file descriptor table from Module 03.
CLONE_SIGHANDThe signal handlers from Module 04.
CLONE_THREADThe thread group — which is what makes them share a PID.

So to the kernel there is no separate "thread" object. There are tasks, each with its own thread ID (TID), and a task is a thread when it shares a thread group with others.

Two identifiers, and the naming is genuinely confusing:

  • TID — unique to each thread. What the kernel schedules.
  • PID — shared by every thread in the process. It is really the thread group ID, and it equals the TID of the first thread.

Which is why in /proc/1/task you saw an entry numbered 1: that is systemd's first thread, whose TID happens to equal the PID.

Real-world analogy — one address, several people

The flat has one address. Post arrives addressed to the flat, the electricity bill is for the flat, and the landlord deals with the flat. That is the PID.

Inside, each flatmate has their own name. The others need names to talk to each other and to divide up chores. That is the TID.

The flat's "official" name on the tenancy is usually the first tenant's — which is exactly why the process ID equals the first thread's ID.

Now the consequence people get wrong. Send something to the address and it reaches the household, and whoever gets to the door first deals with it. Send it to a person by name and only they get it.

That is precisely how signals work with threads, and it is why kill on a multithreaded process behaves in a way that surprises people.

Where the analogy stops working. Flatmates can move out and the flat survives.

Threads cannot. The whole process ends when any thread crashes, and if the first thread finishes while others are still running, the PID stays in use — the household keeps the address even though the person it was named after has gone.

🧪 Exercise A2.1 — Watch a thread being created
bash
# Trace the clone flags used to make a THREAD, not a process.
# 'timeout' is single-threaded; use something that threads. getent does not,
# so we use a small python if available, otherwise inspect an existing process.
if command -v python3 >/dev/null; then
  strace -f -e trace=clone,clone3 -o /tmp/thr.txt \
    python3 -c "import threading,time
t=threading.Thread(target=time.sleep,args=(0.2,)); t.start(); t.join()" 2>/dev/null
  grep -oE 'CLONE_[A-Z_]+' /tmp/thr.txt | sort -u | head -10
fi

# Compare PID and TID for every thread of a real process
TARGET=$(pgrep -n sshd || pgrep -n dbus-daemon || echo 1)
echo "inspecting PID $TARGET ($(cat /proc/$TARGET/comm))"
ps -Lo pid,tid,comm -p "$TARGET" --no-headers | head -5
echo "--- /proc entries ---"
ls /proc/$TARGET/task | head -5
Expected result — click to reveal
plain text
$ grep -oE 'CLONE_[A-Z_]+' /tmp/thr.txt | sort -u | head -10
CLONE_CHILD_CLEARTID
CLONE_FILES
CLONE_FS
CLONE_PARENT_SETTID
CLONE_SETTLS
CLONE_SIGHAND
CLONE_SYSVSEM
CLONE_THREAD
CLONE_VM

$ ps -Lo pid,tid,comm -p "$SSHD" --no-headers | head -5
    812     812 sshd

--- /proc entries ---
812

What to read out of it — the flag list is the definition of a thread.

CLONE_VM (share memory), CLONE_FILES (share descriptors), CLONE_SIGHAND (share handlers), CLONE_THREAD (share the thread group). Compare with Module 02's fork, where the flags were essentially just SIGCHLD.

Same system call. Different flags. Completely different thing. That is the whole of thread creation on Linux, and it is why "Linux threads are just processes that share everything" is accurate rather than a simplification.

In the ps -Lo output, PID and TID are the same number for a single-threaded process. Run it against a threaded program and you get one PID repeated down the page with a different TID on each line.

Interview-grade detail. This design has a real consequence for signals, and it is a favourite question.

kill <PID> sends to the process. The kernel delivers it to any one thread that has not blocked it — you do not choose which. tgkill targets a specific thread.

That is why signal handling in threaded programs is done in one specific way: all threads block the signal, and one dedicated thread waits for it. Otherwise a signal can land on whichever thread happens to be convenient, including one in the middle of something delicate. If asked "how do you handle signals in a multithreaded program", that is the answer.

A3 · Why use threads at all

Official docs: pthreads(7) · fork(2) · clone(2)

Given the danger, why not just use processes?

ThreadsProcesses
Creating oneCheap — no new address spaceMore expensive — page tables to duplicate, as Module 02 showed
Switching between themCheaper — same page tables, so no address-translation flushFull context switch, from Module 01
Sharing dataFree — it is the same memoryNeeds pipes, sockets or shared memory
One crashesAll of them dieOnly that one dies
Getting it rightHard. Part B and Part C are entirely about thisMuch easier — isolation is the default
The honest summary, and it is worth saying in an interview.

Threads buy you cheap sharing. You pay for it with every bug in Parts B and C — bugs that appear only under load, cannot be reproduced reliably, and often vanish when you add logging.

Which is why the industry has largely moved towards designs that avoid raw shared state: worker processes in nginx and PostgreSQL, message passing in Go and Erlang, and single-threaded event loops in Node.js and Redis.

Redis is the clearest example. It handles enormous request rates on essentially one thread, because avoiding locks entirely turned out to be faster than coordinating many threads.

🧪 Exercise A3.1 — Compare the cost of a thread and a process
bash
# 500 processes: each ( ) is a fork, from Module 02
time ( for i in $(seq 1 500); do ( : ) ; done )

# 500 threads, via a runtime that has them
if command -v python3 >/dev/null; then
  time python3 -c "
import threading
ts=[threading.Thread(target=lambda: None) for _ in range(500)]
[t.start() for t in ts]
[t.join() for t in ts]"
fi
Expected result — click to reveal
plain text
$ time ( for i in $(seq 1 500); do ( : ) ; done )
real	0m0.361s
user	0m0.104s
sys	0m0.244s

$ time python3 -c "...500 threads..."
real	0m0.094s
user	0m0.048s
sys	0m0.041s

What to read out of it.

Threads came out roughly four times faster here, and the gap widens sharply as the process gets bigger — because forking has to duplicate page tables, and a thread does not.

But notice how small both numbers are. Under a millisecond each. Process creation on Linux is genuinely cheap, which is why "threads are faster" is a much weaker argument than people assume, and why nginx and PostgreSQL happily use worker processes.

The honest reason to choose threads is usually not creation speed — it is that they share memory for free. If your workers need constant access to the same large data structure, copying it between processes is the real cost.

Do not read too much into these numbers. Python threads are real operating-system threads, but Python's interpreter lock means they do not run Python code in parallel. This exercise measures creation cost, which is a fair comparison; it says nothing about throughput.

If the numbers come out closer on your machine, that is fine — the point is the shape, not the ratio.


💥 Part B · When sharing goes wrong

B1 · Race conditions

Official docs: pthreads(7) · futex(2) · clone(2)

Part A said threads share memory with no coordination. Here is what that costs.

Take the simplest possible operation: add one to a counter. It looks like a single step. It is not. It is three:

  1. Read the current value.
  2. Add one to it.
  3. Write the result back.

Now run two threads doing that at the same time, and let the timing fall badly:

Diagram source
sequenceDiagram
    participant A as Thread A
    participant M as counter (starts at 5)
    participant B as Thread B
    A->>M: read - gets 5
    B->>M: read - gets 5
    A->>A: add one, now has 6
    B->>B: add one, now has 6
    A->>M: write 6
    B->>M: write 6
    Note over M: counter is 6.<br>Two increments happened.<br>One was lost.

Both threads did exactly what they were told. Neither is buggy on its own. And one increment vanished.

That is a race condition: the result depends on the order two things happen to run in, and nothing is guaranteeing that order.

Why races are so much worse than ordinary bugs.
  • They are intermittent. Nine times out of ten the timing is fine and everything works.
  • They get worse under load, which means production and not your laptop.
  • They often disappear when you look. Adding a log line changes the timing and the bug goes away. This is real enough that it has a name: a Heisenbug.
  • The damage shows up somewhere else. The corrupted value is read later, by different code, which then looks like the guilty party.

This is why the answer is not "test harder". It is to make the dangerous section impossible to interleave in the first place — Section B2.

Real-world analogy — the last of the milk

Two flatmates, one fridge, from Part A.

Priya looks in the fridge at 6pm. No milk. She heads to the shop.

Sam looks in the fridge at 6:02pm. Still no milk — Priya has not got back yet. He heads to the shop too.

They come home with two bottles of milk.

Now look at what went wrong, because it is exactly the counter problem. Neither of them did anything unreasonable. Both checked. Both acted on what they saw. The problem is that checking and acting were two separate steps, and the other person acted in the gap.

And notice how badly it scales. Two flatmates rarely collide. Six flatmates in a busy household collide constantly. That is why races appear under load and not in testing.

Where the analogy stops working, and it is the ugly part. Two bottles of milk is a minor annoyance you notice immediately.

A lost increment is silent. Nothing errors, nothing logs, and the total is simply wrong. You find out weeks later when the numbers do not reconcile, and by then there is no trace of which write was lost.

🧪 Exercise B1.1 — Produce a real race, in the shell
bash
# 20 workers, each adding 1 to a shared file 50 times.
# The correct answer is obviously 1000. Predict what you will actually get.
echo 0 > /tmp/counter

for i in $(seq 1 20); do
  (
    for j in $(seq 1 50); do
      value=$(cat /tmp/counter)      # READ
      echo $((value + 1)) > /tmp/counter   # ADD and WRITE
    done
  ) &
done
wait

echo "expected: 1000"
echo "actual:   $(cat /tmp/counter)"
Expected result — click to reveal
plain text
expected: 1000
actual:   137

What to read out of it.

Your number will be different from mine, and different again if you run it twice. That variability is the bug — the answer depends on how the workers happened to interleave.

Not a few increments were lost. Nearly all of them were — 137 on an 8-core box, and single digits on a 2-CPU VM, so anywhere from 85% to 99% loss is normal. The fewer CPUs and the more workers, the worse it gets. Every time a worker read the file, did its arithmetic, and wrote back, any other worker that read in that gap was working from a stale value and overwrote the result.

Read the loop again and find the gap. Between value=$(cat ...) and echo ... >, this worker is holding a number that is already out of date. Twenty workers, fifty times each, and the window is wide open every time.

This is genuinely the same bug as two threads incrementing a variable. The shell makes the three steps visible; in a compiled program they are three machine instructions and the window is nanoseconds wide — which makes it rarer, and far harder to find.

Now imagine this at 500 hosts. This is the shape of a real and expensive class of bug:

Check-then-act on a shared resource. Two web requests both check "does this username exist?", both get no, both create it. Two workers both check "has this job been claimed?", both claim it, and the job runs twice. Two deploy scripts both check "is a deploy running?", both see no, and both deploy.

The fix is never "check more carefully". It is to make the check and the act one indivisible step — a database unique constraint, an atomic compare-and-set, or a lock. Section B2.

B2 · Critical sections and locks

Official docs: futex(2) · flock(2) · pthreads(7)

The stretch of code that must not be interleaved is the critical section. In Exercise B1.1 it is the three lines between reading the file and writing it back.

The fix is mutual exclusion — a guarantee that only one thread is inside at a time. The usual tool is a mutex, short for exactly that. It has two operations:

  • Lock — if someone else holds it, wait. Otherwise take it and continue.
  • Unlock — release it, and let one waiter through.

Two rules matter more than the mechanism:

  1. Everyone touching the shared data must use the same lock. One piece of code that skips it destroys the guarantee for everybody. A lock is a convention the kernel does not enforce.
  2. Hold it for as short a time as possible. Everything inside the critical section is single-threaded by definition. Make it big enough and you have removed the point of having threads.
Real-world analogy — the bathroom door

The flat has one bathroom. Without a lock you get the milk problem again: someone glances at the door, sees nobody, walks in, and meets a flatmate.

Fit a bolt and the problem disappears. Not because anyone is more careful — because checking and entering became one action. You cannot slide the bolt if it is already slid.

Both rules fall straight out of the analogy:

  • The bolt only works if everyone uses it. One flatmate who never bothers ruins it for the whole flat, and the bolt cannot make them.
  • Do not read a book in there. While you hold it, nobody else gets in. A lock held too long turns a shared flat into a queue.

Where the analogy stops working, and it is Part C. A bathroom is one room.

Real programs have several locks, and a thread often needs two at once. That is where deadlock comes from: you are holding the bathroom and waiting for the kitchen, while somebody else holds the kitchen and is waiting for the bathroom. Neither of you is doing anything wrong, and neither of you will ever move.

🧪 Exercise B2.1 — Add a lock and watch the race disappear
bash
# Same 20 workers, same 50 increments each - but now the read-modify-write
# happens inside a lock. flock gives us mutual exclusion in the shell.
echo 0 > /tmp/counter
: > /tmp/counter.lock

for i in $(seq 1 20); do
  (
    for j in $(seq 1 50); do
      flock /tmp/counter.lock -c \
        'value=$(cat /tmp/counter); echo $((value + 1)) > /tmp/counter'
    done
  ) &
done
wait

echo "expected: 1000"
echo "actual:   $(cat /tmp/counter)"
Expected result — click to reveal
plain text
expected: 1000
actual:   1000

What to read out of it.

Exactly 1000, every time. Run it ten times and you get 1000 ten times. Compare with Exercise B1.1, which gave a different wrong answer on every run.

Nothing about the arithmetic changed. The only difference is that the read-modify-write now happens inside flock, so no worker can start its read while another is between reading and writing.

You will also notice it is considerably slower. That is not overhead to be optimised away — it is the actual cost of correctness here. Twenty workers are now taking turns rather than running at once, and the critical section has become the bottleneck. That is rule 2 from above, measured.

Interview-grade detail. flock is worth knowing for its own sake, not just as a teaching device.

flock /var/lock/myjob.lock -c 'command' in a cron entry is the standard, correct way to stop overlapping runs — far better than the usual home-made PID file, which has exactly the check-then-act race from Section B1 built into it.

And it releases automatically when the process dies, including on SIGKILL, because the lock is attached to a file descriptor and Module 03 established that descriptors are closed when a process ends. A PID file has no such guarantee, which is why stale lock files are so common and flock never leaves one.

B3 · What a mutex actually is — the futex

A lock sounds like something the kernel must supervise. If it were, every lock and unlock would be a system call — and Module 01 measured what those cost.

Locks are taken millions of times a second in a busy program. That would be unusable.

So Linux uses a futex — a fast userspace mutex — built on one idea:

Only enter the kernel when you actually have to wait.

Taking an uncontended lock is a single atomic CPU instruction on a variable in ordinary memory. No system call. The kernel never learns the lock exists.

The kernel is only involved when a thread finds the lock already held and must sleep. Then it calls futex() to wait, and the unlocking thread calls futex() to wake it.

SituationWhat happensCost
Lock is freeOne atomic instruction in user spaceNanoseconds. No syscall.
Lock is heldfutex(FUTEX_WAIT) — the thread sleepsA syscall, plus however long it waits
Unlocking, nobody waitingOne atomic instructionNanoseconds. No syscall.
Unlocking, someone waitingfutex(FUTEX_WAKE)A syscall

One precision worth keeping: NPTL implements condition variables and pthread_join on futexes too, so a healthy idle thread pool parked waiting for work still generates FUTEX_WAIT. This is why futex calls in a trace are the first place to look for lock contention. But read them carefully: NPTL also implements condition variables and pthread_join on futexes, so an idle thread pool parked waiting for work generates plenty of FUTEX_WAIT with nothing wrong at all. The contention signature is a high call rate that tracks load, together with a high wall-clock time per call from strace -c -w — not the raw count.

Real-world analogy — the sign on the door versus ringing the concierge

Back to the bathroom, in a building with a concierge who can fetch you when a room is free.

The door has a vacant/engaged sign. You walk up, glance at it, and if it says vacant you flip it and go in. That took a second and you never spoke to anybody. That is the uncontended case: one atomic instruction, no kernel.

If it says engaged, now you need help. Standing outside watching the door is a waste of your afternoon, so you ring the concierge and say "fetch me when it is free." You go and sit down. That is FUTEX_WAIT, and it is a system call because somebody official has to remember you and wake you.

When the occupant leaves, they check whether anyone is queued. Nobody waiting? They just flip the sign and walk off — no concierge involved. Someone waiting? They tell the concierge to send them up. That is FUTEX_WAKE.

The design pays off because in a healthy system the door is usually vacant. You pay the expensive path only when there is genuinely a queue.

Where the analogy stops working, and it is the diagnostic. A concierge would notice a growing queue and tell someone.

Nothing tells you. Contention is invisible from the outside — the program is not erroring, and it may not even be using much CPU. It is just slow. Counting futex calls is how you see the queue, which is Section D2.

🧪 Exercise B3.1 — Watch contention appear in the system calls
bash
# LOW contention: workers with their own separate files - no sharing at all
rm -f /tmp/lowc.*; : > /tmp/lowc.lock
strace -f -c -o /tmp/low.txt bash -c '
for i in 1 2 3 4; do
  ( for j in $(seq 1 60); do echo x >> /tmp/lowc.$i; done ) &
done; wait'
echo "=== low contention ==="
grep -E "futex|total" /tmp/low.txt | tail -3

# HIGH contention: the same workers, all fighting for ONE lock
strace -f -c -o /tmp/high.txt bash -c '
for i in 1 2 3 4; do
  ( for j in $(seq 1 60); do
      flock /tmp/lowc.lock -c "echo x >> /tmp/shared"
    done ) &
done; wait'
echo "=== high contention ==="
grep -E "futex|flock|total" /tmp/high.txt | tail -4

rm -f /tmp/lowc.* /tmp/shared /tmp/low.txt /tmp/high.txt
Expected result — click to reveal
plain text
=== low contention ===
100.00    0.055152          16      3338        79 total

=== high contention ===
  0.10    0.001124           4       240           flock
100.00    1.105608          27     39704      2047 total

=== high contention, the same run with -w (wall clock) ===
 25.58    4.317391       17989       240           flock
100.00   16.880830         425     39686      2047 total

What to read out of it.

Same amount of actual work — 240 lines written either way. The high-contention run made about twelve times as many system calls and took roughly 20 times longer. Be honest about where that syscall count comes from: most of it is the cost of forking and execing flock (and the sh -c it runs) 240 times, roughly 128 syscalls apiece — not the lock. That is the weakness of doing this demo in a shell.

The flock line is the cost — but you have to ask for it, and this is the real lesson of the exercise. By default strace -c summarises system time, CPU spent inside the kernel. A thread blocked on a lock burns no CPU, so the default summary shows flock at about 4 microseconds a call and hides the waiting completely. Add -w and the same 240 calls report about 18,000 microseconds each. That is the queueing, finally visible.

strace -c counts syscalls and kernel CPU; strace -c -w counts the wall clock — and blocking only ever shows up in the second.

In a real threaded program the same shape appears as futex rather than flock, because the lock is in shared memory rather than on a file. The reading is identical: a large call count with a high time-per-call means threads are queueing.

Interview-grade detail. This gives you a genuinely useful production diagnostic and a memorable one-liner:

"If strace -c -w -f on a slow multithreaded process shows futex dominating the wall-clock time (-w matters — without it you get kernel CPU, and blocking is invisible), the threads are contending on a lock. Adding CPU will not help — the threads are not competing for CPU, they are queueing for a lock. The fix is a shorter critical section, finer-grained locks, or a lock-free design."

Then add the caution from Module 04: strace on a busy production process is dangerous, so confirm with perf or an eBPF tool instead. The signature to look for is the same either way.


🔒 Part C · Deadlock

C1 · The four conditions

Official docs: futex(2) · flock(2) · pthreads(7)

Section B2 warned about it. Here it is properly.

Deadlock is two or more threads each waiting for something another one holds, so that none of them can ever continue. Not slow — permanently stopped.

The classic case needs only two locks:

Diagram source
sequenceDiagram
    participant A as Thread A
    participant L1 as Lock 1
    participant L2 as Lock 2
    participant B as Thread B
    A->>L1: take Lock 1 - success
    B->>L2: take Lock 2 - success
    A->>L2: want Lock 2 - B has it, wait
    B->>L1: want Lock 1 - A has it, wait
    Note over A,B: Both waiting. Neither can release.<br>This never resolves.

Neither thread did anything wrong. Each took a lock, then asked for another. The only mistake is that they asked in opposite orders.

Deadlock needs four things to be true at once, and this list is worth knowing because breaking any one of them prevents it:

ConditionWhat it meansHow you could break it
1. Mutual exclusionThe resource can only be held by one at a timeRarely possible — it is usually the whole point
2. Hold and waitYou keep what you hold while asking for moreTake every lock you need at once, or release before asking
3. No preemptionNobody can take a lock away from youUse timeouts — give up and retry instead of waiting forever
4. Circular waitA closed loop of any length: A waits on B, B waits on C, C waits on A. Two-party cycles are easy to spot; three-party cycles are the ones that shipAlways take locks in the same order everywhere. The usual fix.
Condition 4 is the one you actually fix in practice, and the fix is almost embarrassingly simple.

Give every lock a rank, and always take them in rank order. If every piece of code takes Lock 1 before Lock 2, a cycle becomes impossible — someone would have to take 2 then 1, and nobody does.

It costs nothing at runtime, needs no timeouts, and no detection. It is just a rule the code follows.

Interviewers ask for the four conditions because reciting them is easy. Saying which one you would break, and why, is the part that shows understanding.

Real-world analogy — two cooks, one pan and one sauce

Two people are cooking in the shared kitchen. There is one good frying pan and one jar of sauce.

Priya picks up the pan, then reaches for the sauce. Sam already has it.

Sam picked up the sauce, and now reaches for the pan. Priya has it.

Both of them are standing there holding something, waiting for the other, and neither will put theirs down because they still need it. Dinner is never made. Nobody was rude, nobody made a mistake, and the kitchen is now permanently stuck.

Now walk the four conditions through the kitchen, because they stop being abstract:

  1. One pan. Two people cannot use it at once.
  2. Neither puts down what they hold while waiting for the other thing.
  3. You cannot snatch it out of the other person's hand.
  4. The loop: Priya waits on Sam, Sam waits on Priya.

And the fix is the one a real kitchen would use without thinking: agree that everyone picks up the pan first, then the sauce. Now the second person simply waits for the pan at the start, holding nothing, and the loop cannot form.

Where the analogy stops working, and it is why deadlock is hard. Two cooks would notice within seconds and sort it out by talking.

Threads cannot see each other, cannot negotiate, and have no idea anything is wrong. Each one believes it is simply waiting its turn, and it will believe that forever.

🧪 Exercise C1.1 — Build a deadlock and watch it happen (meant to hang)
bash
: > /tmp/lockA; : > /tmp/lockB

# Worker 1 takes A, then B.  Worker 2 takes B, then A.  Opposite order.
# Timeouts are there so this exercise ENDS - a real deadlock would not.
(
  flock -w 10 200 || { echo "W1: could not get A"; exit 1; }
  echo "W1: holding A"
  sleep 1
  echo "W1: now wants B..."
  flock -w 5 201 && echo "W1: got B" || echo "W1: TIMED OUT waiting for B"
  sleep 2          # keep holding A past W2's deadline, or W2 simply succeeds
) 200>/tmp/lockA 201>/tmp/lockB &

(
  sleep 0.2
  flock -w 10 201 || { echo "W2: could not get B"; exit 1; }
  echo "W2: holding B"
  sleep 1
  echo "W2: now wants A..."
  flock -w 5 200 && echo "W2: got A" || echo "W2: TIMED OUT waiting for A"
  sleep 2          # same on this side
) 201>/tmp/lockB 200>/tmp/lockA &

wait
rm -f /tmp/lockA /tmp/lockB
Expected result — click to reveal
plain text
W1: holding A
W2: holding B
W1: now wants B...
W2: now wants A...
W1: TIMED OUT waiting for B
W2: TIMED OUT waiting for A

What to read out of it — read the order of those lines.

Both workers successfully took one lock. Both then asked for the other one. And then nothing happened for five seconds, until the timeouts fired.

Without -w 5, those last two lines never appear. The script hangs forever, and the only way out is to kill it. That is a real deadlock: not slow, not retrying, not consuming CPU — permanently stopped.

Now look at what caused it, because it is only one thing. Worker 1 takes A then B. Worker 2 takes B then A. Change worker 2 to take A then B and the deadlock is impossible — it would simply wait for A at the start while holding nothing, and worker 1 would finish and release both.

That is condition 4 broken by lock ordering, and it is a change to the order of two lines.

Interview-grade detail. The -w timeout in this exercise is the other real defence, and it is worth naming as condition 3.

A lock acquisition with a timeout means a thread that cannot make progress gives up, releases what it holds, and retries rather than waiting forever. It converts a permanent hang into a recoverable slowdown.

This is exactly what databases do. PostgreSQL and MySQL detect deadlock cycles, pick a victim transaction, and abort it with a deadlock error — which is why "deadlock detected, transaction rolled back" is an error application code is expected to catch and retry. The database chose condition 3, breaking the deadlock by taking a lock away.

C2 · Recognising deadlock on a running system

Deadlock has a very specific signature, and it is one you can now read using everything from Modules 02 and 04.

SymptomWhy
The service stops respondingThreads are stuck and cannot serve anything
CPU usage is near zeroWaiting on a lock means sleeping, not spinning. This is the giveaway.
Threads in state S, not R or DInterruptible sleep. They are waiting on a futex, not on disk.
WCHAN begins futex_futex_do_wait on 6.x, futex_wait_queue on 5.xModule 02's column, naming exactly what they are parked on
It never recoversThe difference between deadlock and mere contention
The process is killableUnlike D state from Module 02 — S accepts signals normally
Deadlock versus the other two things that look like it. All three present as "the service is hung", and they need completely different responses.

Deadlock — CPU near zero, threads in S on futex_wait, never recovers. A code bug. Restarting clears it until it happens again.

Contention — CPU may be high or low, futex calls enormous, and it does make progress, just slowly. A performance problem, not a correctness one.

D state — from Module 02. Stuck on storage, not killable, and nothing to do with locks at all.

The three-way check is quick: what state are the threads in, and is the CPU busy? S with idle CPU is a lock. D is storage. R with busy CPU is real work or a spin loop.

Real-world analogy — the difference between a queue and a jam

Stand at the kitchen door and watch.

A queue is people waiting their turn, and every so often one goes in and comes out. Annoying at dinner time, but it clears. That is contention.

A jam is two people in the doorway, each waiting for the other to move, and nothing changes for an hour. That is deadlock.

They look identical in a photograph. The difference is only visible over time: does the queue move?

That is exactly why you should sample twice rather than once. Take ps now, take it again in thirty seconds, and see whether the same threads are parked in the same place. Contention moves. Deadlock does not.

Where the analogy stops working, and it is a genuinely nasty case. In a doorway, everyone can see the jam.

In a thread pool, only some of the workers may be deadlocked. The service still answers some requests, on the threads that are still free, so it looks alive and merely slow. Then those threads get stuck too, one at a time, and the service dies gradually over hours. That is far harder to spot than a clean freeze, and it is the common shape in production.

🧪 Exercise C2.1 — Read the signature of a stuck process
bash
: > /tmp/dlA; : > /tmp/dlB

# Two workers, opposite lock order, NO timeout - a genuine deadlock
( flock 200; sleep 0.5; flock 201; echo "W1 done" ) 200>/tmp/dlA 201>/tmp/dlB &
W1=$!
( sleep 0.2; flock 201; sleep 0.5; flock 200; echo "W2 done" ) 201>/tmp/dlB 200>/tmp/dlA &
W2=$!
sleep 3

echo "=== are they using CPU? ==="
# $W1/$W2 are the SHELLS. The process actually blocked on the lock is their
# `flock` child, so look at the whole subtree or you will read do_wait.
ps -o pid,stat,pcpu,wchan:24,comm --ppid $W1 --ppid $W2 -p $W1,$W2 --no-headers

echo "=== sample again 3 seconds later - has anything moved? ==="
sleep 3
ps -o pid,stat,pcpu,wchan:24,comm --ppid $W1 --ppid $W2 -p $W1,$W2 --no-headers

echo "=== which files are they holding locks on? ==="
sudo ls -l /proc/$W1/fd 2>/dev/null | grep -E 'dlA|dlB'

echo "=== and unlike D state, they CAN be killed ==="
# Kill the shells AND their flock children: the children inherited fds 200/201,
# and flock(2) releases a lock only when EVERY duplicate descriptor is closed.
pkill -9 -P $W1; pkill -9 -P $W2
kill -9 $W1 $W2 2>/dev/null; sleep 1
ps -p $W1 >/dev/null 2>&1 || echo "gone - it was killable"
pgrep -a flock || echo "no orphaned flock processes left"
rm -f /tmp/dlA /tmp/dlB
Expected result — click to reveal
plain text
=== are they using CPU? ===
  10412 S     0.0 do_wait                  bash
  10415 S     0.0 do_wait                  bash
  10419 S     0.0 locks_lock_inode_wait    flock
  10420 S     0.0 locks_lock_inode_wait    flock

=== sample again 3 seconds later - has anything moved? ===
  10412 S     0.0 do_wait                  bash
  10415 S     0.0 do_wait                  bash
  10419 S     0.0 locks_lock_inode_wait    flock
  10420 S     0.0 locks_lock_inode_wait    flock

=== which files are they holding locks on? ===
l-wx------ 1 zaeem zaeem 64 Aug 20 18:22 200 -> /tmp/dlA
l-wx------ 1 zaeem zaeem 64 Aug 20 18:22 201 -> /tmp/dlB

gone - it was killable

What to read out of it — four separate signals, and together they are conclusive.

State S, CPU 0.0. Both processes are asleep, using nothing. This is the fact that rules out a spin loop or real work, and it is why "the service is hung but the CPU is idle" points at locks rather than load.

WCHAN says locks_lock_inode_wait — on the flock processes. Module 02 introduced this column and this is exactly what it is for: the kernel naming what a process is parked on. Note which process, because this is a real trap. The two shells report do_wait, because they are waiting for their flock child; the child is the one holding the lock and the one blocked on it. Always follow the tree down to the task that is actually stuck. In a threaded program the equivalent line begins futex_futex_do_wait on kernel 6.x, futex_wait_queue on 5.x — and the reading is the same.

Two samples, three seconds apart, identical. That is the test that separates deadlock from contention. A contended lock would show different processes parked at different moments, and work would be getting done. Here nothing has moved at all.

The file descriptors show what they hold. 200 -> /tmp/dlA — Module 03's /proc/PID/fd telling you which lock this process is sitting on. Do the same for the other process and you can see the cycle directly.

And kill -9 worked. That is the clean distinction from D state in Module 02: a deadlocked process is in S, so signals reach it normally. Unkillable means storage, not locks.

One caveat worth carrying into production, and it is why the exercise kills the children too. Killing the shell alone does not release the locks, because its flock child inherited the descriptors, and flock(2) releases a lock only when every duplicate descriptor is closed — and fork duplicates them. That is what flock(1)'s -o/--close option is for, and it is the same reason a "dead" service can still be holding a lock through a surviving child.

Now imagine this at 500 hosts. The runtime usually has a better tool than ps, and it is worth knowing which:

Javajstack <pid> prints every thread's stack and explicitly reports Found one Java-level deadlock with both sides of the cycle. kill -QUIT does the same via the signal from Module 04.

Go — a total deadlock panics with fatal error: all goroutines are asleep - deadlock!; kill -QUIT dumps all goroutine stacks.

Pythonpy-spy dump --pid <pid> needs no cooperation from the process (pip install py-spy; it is not packaged in Ubuntu).

C and C++gdb -p <pid> -batch -ex 'thread apply all bt', the single most common way to get a native stack out of a hung process.

Anythingcat /proc/<tid>/stack as root shows the kernel-side stack of a single thread. For a userspace mutex deadlock that only confirms "parked on a futex"; its real value is diagnosing D state and kernel-side hangs.

The generic signature gets you to "it is a lock". The runtime tool tells you which lock.

C3 · Livelock, starvation and priority inversion

Official docs: futex(2) · pthreads(7) · ps(1)

Three more failure modes that are not deadlock but are often mistaken for it.

ProblemWhat is happeningHow it looks
LivelockThreads keep reacting to each other and never make progressCPU is busy — that is the difference from deadlock
StarvationOne thread never gets the lock because others keep taking it firstMostly fine, with some requests occasionally taking forever
Priority inversionA high-priority thread waits on a lock held by a low-priority one that cannot get CPUThe important work is stalled by the unimportant work
Real-world analogy — three ways to be stuck that are not a jam

Livelock — the corridor dance. Two people meet in a narrow corridor. Both step left. Both step right. Both step left again. They are moving constantly and getting nowhere, and they will keep going as long as they keep politely reacting to each other. That is why livelock burns CPU while deadlock does not, and it is a classic result of naive retry logic: everyone backs off and retries at the same moment, forever.

Starvation — the quiet one at the bar. The bar is busy. Every time the barman looks up, somebody louder catches his eye. The quiet person is not blocked, is not stuck, and is being passed over every single time. Nothing is broken; the ordering is simply unfair. In production this is the request at the 99th percentile taking thirty seconds while the average is fine.

Priority inversion — the surgeon behind the trolley. A surgeon needs to get through a door. A porter is in the doorway with a trolley, and cannot move because the corridor is full of people with nothing urgent to do. The most important person is blocked by the least important, through no fault of either. The fix is to let the porter through first — priority inheritance, where the lock holder temporarily borrows the waiter's priority.

Where these analogies stop working, and it is why they get misdiagnosed. A person in a corridor can see the dance and stop.

None of these three reports itself. Livelock looks like the service is busy and working. Starvation looks like an occasional slow request. Priority inversion looks like the important job is simply slow. All three are found by measuring, never by an error message.

🧪 Exercise C3.1 — Livelock burns CPU; deadlock does not
bash
: > /tmp/llA; : > /tmp/llB

# Livelock: both workers grab one lock, fail to get the second,
# release and retry immediately - forever, at full speed.
for w in 1 2; do
  (
    end=$((SECONDS+5))
    while [ $SECONDS -lt $end ]; do
      flock -n 200 && { echo "W$w got it"; break; }
    done
    echo "W$w: 5s of retrying, no progress"
  ) 200>/tmp/llA &
done
sleep 2

echo "=== livelock: is the machine busy? ==="
top -bn1 2>/dev/null | sed -n '3p;7,9p'
wait
echo "=== CPU actually consumed by the retry loops ==="
times
echo "(compare with Exercise C2.1, where CPU was 0.0 and 'times' would show nothing)"
rm -f /tmp/llA
Expected result — click to reveal
plain text
=== livelock: is the machine busy? ===
%Cpu(s): 45.5 us, 54.5 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  687 root      20   0    7160   5152   2012 S  18.2   0.1   0:00.35 bash
  688 root      20   0    7160   5152   2012 S  18.2   0.1   0:00.36 bash
W2: 5s of retrying, no progress
W1: 5s of retrying, no progress
=== CPU actually consumed by the retry loops ===
0m0.030s 0m0.041s
0m7.291s 0m2.155s
(compare with Exercise C2.1, where CPU was 0.0 and 'times' would show nothing)

What to read out of it.

0.0 id — both CPUs saturated for the whole five seconds, and the shell's times builtin reports 7.3 s of user plus 2.2 s of system time consumed by children in 5 s of wall clock on a 2-CPU box. Compare directly with Exercise C2.1, where the deadlocked processes were S at 0.0 and consumed nothing.

Note that ps %CPU on the two bash lines reads only about 18%: the CPU is being burned inside short-lived flock children, and a parent's %CPU never includes a reaped child's time. That is exactly why times, or the machine-wide %Cpu(s) line, is the honest measurement here.

Same outcome — no work is getting done — and the opposite signature. That single difference is how you tell them apart, and it decides where you look next.

Livelock is usually a retry loop with no backoff. Everyone fails, everyone retries instantly, everyone collides again. The fix is randomised exponential backoff: wait a little, wait longer each time, and add jitter so the retries stop landing together.

Interview-grade detail. The jitter matters as much as the backoff, and it is the part people leave out.

Without randomness, every client that failed at the same moment retries at the same moment, and keeps colliding in lockstep. This is the thundering herd, and it is why a service that goes down briefly can be knocked over again the instant it comes back — every client reconnecting on the same timer.

"Exponential backoff with jitter" is the standard answer, and being able to say why the jitter is not optional is what makes it a good one.


🔬 Part D · Seeing threads on a real system

D1 · Reading threads with ps and top

Everything you learned about ps in Module 02 works per-thread, with one flag.

CommandWhat it gives you
ps -eLOne line per thread instead of per process
ps -eLo pid,tid,pcpu,stat,wchan:20,commThe full picture: which process, which thread, its CPU, its state, what it waits on
ps -o nlwp -p PIDJust the thread count for one process
top -HInteractive, per-thread. Press H inside top to toggle
cat /proc/PID/statusThe Threads: line
ls /proc/PID/taskOne directory per thread, each with its own status, stat and wchan

The reason this matters is arithmetic — but not the arithmetic people expect. ps computes %CPU as total CPU time ÷ elapsed time, summed over every thread. It is not divided by the thread count. One thread pinned at 100% therefore shows as roughly 100% at the process level too. What hides it is the machine: on a 32-core box one saturated thread is 100% in ps but only about 3% of capacity, so uptime, host dashboards and top's Solaris mode all report a quiet machine. The per-thread view is what tells you which thread — and whether one thread is capped at a single core while the other 31 sit idle, which is the real diagnosis: this workload cannot use more than one core.

Real-world analogy — the team's timesheet

A manager looks at a six-person team's total hours and sees a normal week. Nothing to investigate.

Break it down per person and one of them worked every hour of it while five did almost nothing.

The team total was accurate and completely uninformative. Averages hide exactly the thing you are looking for.

ps without -L is the team total. ps -eL and top -H are the per-person breakdown, and for a threaded service the breakdown is nearly always the interesting view.

Where the analogy stops working, and it is a real limitation. People have names on a rota.

Threads mostly do not. comm shows the process name for every thread by default, so all eight lines say java and none of them tells you which is the garbage collector and which is a request handler. Well-written programs set per-thread names — visible in /proc/PID/task/TID/comm — and when they do, this becomes dramatically easier.

🧪 Exercise D1.1 — Find one hot thread inside a quiet-looking process
bash
set +m   # quieten job-control notices, or the output is buried in [n] Done lines

# A process with FIVE threads - a main thread plus four workers - where only ONE is busy.
# We fake it with a process group so the shape is visible without writing C.
python3 - <<'EOF' &
import threading, time
def busy():
    end = time.time() + 8
    while time.time() < end: pass          # one hot thread
def idle():
    time.sleep(8)                           # three sleeping workers
for f in (busy, idle, idle, idle):
    threading.Thread(target=f).start()
EOF
P=$!
sleep 2

echo "=== process level: what does plain ps say? ==="
ps -o pid,pcpu,nlwp,comm -p $P --no-headers

echo "=== thread level: which one is actually busy? ==="
ps -Lo pid,tid,pcpu,stat,wchan:16,comm -p $P --no-headers

echo "=== per-thread names, if the program sets them ==="
for t in /proc/$P/task/*; do echo "$(basename $t): $(cat $t/comm 2>/dev/null)"; done
wait $P 2>/dev/null
Expected result — click to reveal
plain text
=== process level: what does plain ps say? ===
  8495 98.0    5 python3

=== thread level: which one is actually busy? ===
  8495  8495  0.4 Sl   futex_do_wait    python3
  8495  8497 98.0 Rl   -                python3
  8495  8498  0.0 Sl   hrtimer_nanoslee python3
  8495  8499  0.0 Sl   hrtimer_nanoslee python3
  8495  8500  0.0 Sl   hrtimer_nanoslee python3

=== per-thread names, if the program sets them ===
8495: python3
8497: python3
8498: python3
8499: python3
8500: python3

What to read out of it — the two views tell completely different stories.

At the process level: 98.0% CPU with nlwp 5. On a 2-CPU box that is one core fully consumed. It tells you the process is busy — but not why, and not that only one of its five threads can ever make progress. On a 32-core host the same 98% is 3% of the machine, and every host-level dashboard would call it idle.

At the thread level, thread 8497 is at 98.0% in state Rl while every other thread sits at 0.0 in Sl. One thread is saturating a core; the main thread and the three sleepers are all asleep. That is the fact the process view cannot give you.

The trailing l in Sl and Rl is ps telling you the process is multi-threaded — a useful tell on its own.

Read the WCHAN column too, exactly as in Module 02. hrtimer_nanoslee means waiting on a timer — those are the sleep(8) threads. futex_do_wait is the main thread, blocked joining its children on a lock, which is your first sighting of the futex from Section B3 in the wild. The busy thread shows -, because it is not waiting for anything. And the busy thread shows -, because it is not waiting for anything; it is running.

Notice TID 11204 equals the PID. That is the first thread, from Section A2.

And the comm list shows every thread called python3. No per-thread names, which is the limitation from the analogy above. A JVM would show GC Thread#0, C2 CompilerThread0 and so on, and that makes the difference between "a thread is hot" and "the garbage collector is hot".

Now imagine this at 500 hosts. This is the standard investigation for "the service is slow but CPU looks fine":

top -H -p <pid> and watch for one line pinned high, or ps -eLo pid,tid,pcpu,comm --sort=-pcpu | head across the machine.

Take the hot TID and hand it to the runtime's own tool — for a JVM, convert it to hex and search the jstack output for nid=0x..., which names the exact Java thread. That is the standard route from "something is hot" to "this specific piece of code is hot", and it starts here.

D2 · Too many threads

Official docs: pthreads(7) · getrlimit(2) · proc(5)

Threads are cheap, not free. Three separate ceilings exist, and they produce different errors.

LimitWhereWhat you see when you hit it
Per-user process/thread countulimit -u, RLIMIT_NPROCResource temporarily unavailable (EAGAIN)
System-wide thread count/proc/sys/kernel/threads-maxThe same error, machine-wide
Memory for stacks8 MB reserved per thread by default on glibc (taken from RLIMIT_STACK). musl uses 128 KB, Go goroutines start near 8 KB and grow, and a JVM's -Xss is typically 512 KB–1 MBOut of memory, or allocation failures

That last row is the one that surprises people. Each thread reserves 8 MB of address space for its stack by default. A thousand threads reserves 8 GB of address space. It is virtual, not physical, so it usually does not use real memory — but it is why "just increase the thread pool" has a ceiling, and Module 08 will explain the virtual-versus-physical distinction properly.

More threads stops helping, and then starts hurting.

Beyond roughly the number of CPU cores, extra threads do not add parallelism — there are no more cores to run them on. What they add is:

  • Context switching, at the cost Module 01 measured.
  • Lock contention, because more threads want the same locks.
  • Memory, for stacks.

This is why a thread pool sized at 500 is very often slower than one sized at 16, and why sizing pools by "number of cores" rather than "number of requests" is the usual advice.

The exception is threads that mostly wait — on network or disk. Those are not competing for CPU, so many more can be useful. Which is exactly why the industry moved to async and event loops for I/O-heavy work: you get the concurrency without the threads.

Real-world analogy — hiring more cooks for one kitchen

The kitchen has four hobs. Orders are backing up, so you hire more cooks.

Cooks five through eight genuinely help — while one waits for water to boil, another can use the hob. Waiting is where extra people pay off.

Hire twenty and the kitchen gets slower. There are still four hobs. Now sixteen people are standing around, everyone is queueing for the same pan, and half the day is spent squeezing past each other. You have added cost and contention without adding capacity.

The four hobs are your CPU cores. The queueing for the pan is lock contention. Squeezing past each other is context switching.

Where the analogy stops working, and it is the useful exception. A cook waiting for water still occupies floor space.

A thread waiting on the network occupies almost nothing — it is asleep in state S, consuming no CPU. That is why a server can hold ten thousand idle connections happily but falls over with ten thousand computing threads. And it is why async I/O exists: it gets the ten thousand waits without the ten thousand cooks.

🧪 Exercise D2.1 — Find your limits and hit one (meant to fail)
bash
# The three ceilings
echo "per-user process/thread limit: $(ulimit -u)"
echo "system-wide thread max:        $(cat /proc/sys/kernel/threads-max)"
echo "threads in use right now:      $(ps -eL --no-headers | wc -l)"

# Default stack reservation per thread
ulimit -s   # in KB

# Now hit the per-user limit deliberately, in a throwaway subshell.
# NOTE: RLIMIT_NPROC is NOT enforced for real UID 0 - see getrlimit(2).
# As root this quietly does nothing; run it as an ordinary user.
[ "$(id -u)" -eq 0 ] && echo "WARNING: you are root - RLIMIT_NPROC is not enforced for uid 0"
( ulimit -u 50
  echo "limit lowered to $(ulimit -u)"
  for i in $(seq 1 80); do
    sleep 30 &
  done 2>/tmp/nproc_err.txt
  wait 2>/dev/null
) &
sleep 2
[ -s /tmp/nproc_err.txt ] && tail -2 /tmp/nproc_err.txt \
  || echo "(no errors - are you root? RLIMIT_NPROC is not enforced for uid 0)"
pkill -f "sleep 30" 2>/dev/null
rm -f /tmp/nproc_err.txt
Expected result — click to reveal
plain text
per-user process/thread limit: 7557
system-wide thread max:        15114
threads in use right now:      412
$ ulimit -s
8192

limit lowered to 50
bash: fork: retry: Resource temporarily unavailable
bash: fork: Resource temporarily unavailable

What to read out of it.

ulimit -s is 8192 KB — 8 MB per thread stack, and that is the number behind the memory row in the table. A 1,000-thread process reserves about 8 GB of address space for stacks alone.

Resource temporarily unavailable is EAGAIN, and it is the same errno Module 01 listed. On a socket it means "no data yet, try again"; on fork or thread creation it means "you have hit a process or thread limit". Same errno, entirely different meaning depending on the call — which is exactly why Module 01 insisted the per-call ERRORS section matters more than a generic table.

Notice bash retried before failing: fork: retry: then fork:. It attempted several times before giving up, which is why this failure sometimes shows up as a slow service before it shows up as a broken one.

Now imagine this at 500 hosts. Resource temporarily unavailable on a production box is nearly always one of two things, and they are distinguished by scope:

One user or service affected — that user's RLIMIT_NPROC. For a systemd service there are two unit settings and they are different mechanisms: LimitNPROC= sets RLIMIT_NPROC itself, and TasksMax= sets the cgroup pids.max ceiling. Both surface as EAGAIN, which is why they get conflated. Neither is /etc/security/limits.conf, which applies only to PAM logins and not to services systemd starts. This catches people out constantly.

Everything affected — the machine hit kernel.threads-max or kernel.pid_max. Usually a thread leak: a service that creates threads and never joins them, climbing steadily for days.

Check the trend, not the instant: ps -eL --no-headers | wc -l on a timer. A count that only rises is a leak, and it has a deadline.

🎯 Interview questions — Threads and concurrency

Q. What is the difference between a process and a thread?

A process has its own address space; threads within a process share one. Threads share the memory, the open file descriptors, the working directory, the signal handlers and the PID. Each thread has its own stack, registers, thread ID, errno and signal mask.

The consequences follow directly:

  • Communication. Threads share data for free. Processes need pipes, sockets or shared memory.
  • Cost. Threads are cheaper to create and switch between, because there is no address space to duplicate and no page-table switch.
  • Safety. A crash in one thread takes the whole process down. A crashed process leaves its siblings alone.

The details that separate candidates:

  • Say how Linux actually implements it. There is no separate thread object — clone() with CLONE_VM | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD creates a task sharing everything, and fork is the same call sharing nothing. Threads and processes are the same kernel object with different sharing flags.
  • Get the identifiers right. Every thread has a unique TID; all threads in a process share a PID, which is really the thread group ID and equals the first thread's TID. ps -eL shows them; /proc/PID/task/ lists them.
  • Mention the safety trade as a design decision, not a footnote: browsers use separate processes per tab specifically so one crash cannot take the rest down, despite the higher cost.
Q. What is a race condition, and how do you prevent one?

A race condition is when the result depends on the order two operations happen to run in, and nothing enforces that order. The classic case is check-then-act, or a read-modify-write on shared data: two threads both read the same value, both increment it, both write it back, and one update is silently lost.

You prevent it by making the critical section indivisible — mutual exclusion via a mutex, or an atomic operation such as compare-and-swap that does the whole read-modify-write in one step.

Two rules matter: every piece of code touching the data must use the same lock, and the critical section must be as short as possible, because everything inside it is single-threaded by definition.

The details that separate candidates:

  • Explain why races are so hard, rather than just defining one. They are intermittent, worsen under load, often vanish when you add logging, and the corruption surfaces far from its cause. "Test harder" is not a strategy.
  • Give a non-threading example, because that shows you understand the pattern rather than the vocabulary: two requests both checking "does this username exist", both getting no, both inserting. The fix there is a database unique constraint — the same idea, enforced by a different layer.
  • Name the lock-free option: atomics and compare-and-swap avoid a lock entirely for simple cases and are how counters are usually done properly.
Q. What is deadlock? Name the conditions, and how would you fix it in real code?

Deadlock is two or more threads each holding a resource the other needs, so none can proceed — permanently. It requires four conditions simultaneously: mutual exclusion, hold-and-wait, no preemption, and circular wait. Breaking any one prevents it.

In practice you break circular wait: define a global lock ordering and always take locks in that order. If every code path takes lock A before lock B, a cycle cannot form. It costs nothing at runtime and needs no detection.

The second-line defence is breaking no-preemption with timeoutstry_lock with a deadline, so a thread that cannot progress releases what it holds and retries.

The details that separate candidates:

  • Naming which condition you would break, and why, rather than reciting all four. The list is easy; the choice is the answer.
  • Describe the signature on a live system: service unresponsive, CPU near zero, threads in state S with WCHAN showing futex_wait, and no change across repeated samples. Contrast with D state from Module 02, which is storage and is not killable, and with livelock, which looks similar but burns CPU.
  • Name the runtime tool: jstack reports Java-level deadlocks explicitly; kill -QUIT dumps goroutine stacks in Go; py-spy dump for Python.
  • Mention how databases handle it: they detect the cycle, abort one transaction, and return a deadlock error the application is expected to catch and retry. That is condition 3 broken deliberately.
Q. A multithreaded service is slow. CPU is not saturated. What do you check?

Work through the possibilities in order, because each has a distinct signature:

  1. Per-thread CPU first. top -H -p <pid> or ps -eLo pid,tid,pcpu,stat,wchan:20 --sort=-pcpu. Process-level CPU is an average and hides a single hot thread completely.
  2. Lock contention. Threads in S with WCHAN of futex_wait, and a futex-dominated call count. The threads are queueing, not computing — adding CPU will not help.
  3. Blocked on I/O. Threads in D state, high iowait. That is Module 02's territory, not a locking problem.
  4. Deadlock. Same as case 2 but it never recovers. Sample twice, thirty seconds apart, and see whether anything moved.
  5. Too many threads. Beyond core count, extra threads add context switching and contention without adding parallelism. Check nlwp and the involuntary context switch count from Module 02.

The details that separate candidates:

  • "CPU is not saturated" is the clue, not the problem. It rules out compute and points at waiting — for a lock, for I/O, or for another service.
  • Distinguish contention from deadlock by whether progress is made, and say how you would test it: two samples separated in time.
  • Volunteer the caution from Module 04: strace on a busy production process can slow it tenfold, so confirm with perf or an eBPF tool. Knowing when not to use a tool is worth as much as knowing the tool.

🏁 Part E · Practice, capstone, reference and review

E1 · Production practice

SituationWhat you runWhat it tells you
Service slow, process CPU looks finetop -H -p PIDPer-thread CPU. One hot thread is invisible in the process average
Find the busiest thread anywhereps -eLo pid,tid,pcpu,stat,wchan:20,comm --sort=-pcpu \| headThe whole picture in one command
Service hung, CPU near zeroCheck thread STAT and WCHANSfutex_wait = a lock. D = storage. R = spinning
Is it deadlock or just contention?Sample ps twice, 30s apartContention moves. Deadlock does not
Suspect lock contentionstrace -c -f -p PID for a few secondsfutex dominating call count and time. Time-box it in production
Java service stuckjstack PID or kill -QUIT PIDReports Java-level deadlocks explicitly, with both sides of the cycle
Go service stuckkill -QUIT PIDDumps every goroutine stack
Python service stuckpy-spy dump --pid PIDNeeds no cooperation from the process
One thread's kernel-side stacksudo cat /proc/TID/stackExactly where in the kernel it is parked
Resource temporarily unavailableulimit -u, /proc/sys/kernel/threads-maxThread or process limit. For a service, it is TasksMax in the unit file
Suspected thread leakps -o nlwp -p PID on a timerA count that only ever rises has a deadline
Stopping overlapping cron runsflock /var/lock/job.lock -c 'command'Correct by construction. A PID file has the check-then-act race built in

E2 · Capstone exercise

🧪 CAPSTONE — Four concurrency faults, told apart by signature
bash
# ---------- FAULT 1: a lost-update race ----------
echo 0 > /tmp/cap_counter
for i in $(seq 1 10); do
  ( for j in $(seq 1 50); do
      v=$(cat /tmp/cap_counter); echo $((v+1)) > /tmp/cap_counter
    done ) &
done; wait
echo "FAULT 1 -> expected 500, got $(cat /tmp/cap_counter)"
#   a) Why is the answer wrong, and where exactly is the window?
#   b) Give two different fixes, one with a lock and one without.

# ---------- FAULT 2: deadlock ----------
: > /tmp/cap_A; : > /tmp/cap_B
( flock 200; sleep 0.5; flock -w 3 201 || echo "W1 stuck"; sleep 2 ) 200>/tmp/cap_A 201>/tmp/cap_B &
D1=$!
( sleep 0.2; flock 201; sleep 0.5; flock -w 3 200 || echo "W2 stuck"; sleep 2 ) 201>/tmp/cap_B 200>/tmp/cap_A &
# the trailing `sleep 2` matters: without it, whichever worker times out first
# releases its lock and hands it to the other, and the deadlock disappears.
sleep 1.5
ps -o pid,stat,pcpu,wchan:16 -p $D1 --no-headers
wait
#   c) State and CPU? Which of the four conditions is present, and which would you break?

# ---------- FAULT 3: hot thread hidden by an average ----------
python3 -c "
import threading,time
def busy():
    e=time.time()+6
    while time.time()<e: pass
def idle(): time.sleep(6)
[threading.Thread(target=f).start() for f in (busy,idle,idle,idle,idle)]" &
P3=$!; sleep 2
ps -o pid,pcpu,nlwp -p $P3 --no-headers
ps -Lo tid,pcpu,stat -p $P3 --no-headers | sort -k2 -rn | head -3
wait $P3 2>/dev/null
#   d) The process figure and the thread figures disagree. Explain the arithmetic.

# ---------- FAULT 4: limits ----------
echo "threads now: $(ps -eL --no-headers | wc -l) / max $(cat /proc/sys/kernel/threads-max)"
#   e) A service dies with EAGAIN on thread creation. Name three possible ceilings
#      and say which config setting applies to a systemd service.
rm -f /tmp/cap_counter /tmp/cap_A /tmp/cap_B
What a good answer looks like — click to reveal

a–b, the race. The increment is three steps — read, add, write — and the window is between the read and the write. Any worker that reads during another's window works from a stale value and overwrites it, so updates are lost. Two fixes: wrap the read-modify-write in a lock (flock), or use an operation that is atomic by construction — an atomic increment, a compare-and-swap, or in a database a single UPDATE ... SET n = n + 1 rather than SELECT-then-UPDATE.

c, the deadlock. State S, CPU 0.0. The shells show WCHAN do_wait because they are waiting on their flock children; the children themselves show locks_lock_inode_wait, which is the lock wait. All four conditions are present, and the one to break is circular wait: make both workers take cap_A before cap_B. A complete answer also names the fallback — timeouts, which break no-preemption and convert a permanent hang into a retry — and distinguishes this from D state (storage, unkillable) and livelock (CPU busy).

d, the average. The process-level %CPU is the sum of every thread's CPU time divided by elapsed timeps does not divide by thread count — so one thread at ~100% inside a six-thread process still reads as roughly 100%. What the process view cannot tell you is that a single thread is responsible: the thread view shows one thread in Rl near 100% and the rest in Sl at 0.0. Two lessons follow. A process pinned at 100% on a many-core host looks like a quiet machine in host-level metrics. And a thread capped at exactly one core is the signature of a serialised workload, which no amount of extra CPU will fix.

e, the ceilings. Three: the per-user limit (ulimit -u / RLIMIT_NPROC), the system-wide limit (/proc/sys/kernel/threads-max, and relatedly kernel.pid_max), and memory for the 8 MB-per-thread stack reservations. For a systemd service the one that applies is TasksMax in the unit file — /etc/security/limits.conf does not apply to services systemd starts, which is a very common misdiagnosis.

Why this is the capstone. All four present as "the service is not working properly", and all four are told apart by two columns: the thread state and the CPU figure.

S with idle CPU is a lock. R with busy CPU and no progress is livelock. D is storage. A high per-thread figure inside a quiet process is a hot thread. That is the same habit as Modules 02, 03 and 05 — read the state before you act.

E3 · Official documentation reference

TopicOfficial pageOffline equivalent
Threads — the whole modelpthreads(7)man 7 pthreads
How threads are createdclone(2)man 2 clone
Thread IDsgettid(2)man 2 gettid
Locks, under the hoodfutex(2)man 2 futex
File lockingflock(2) · flock(1)man 1 flock
Thread and process limitsgetrlimit(2)man 2 getrlimit
Per-thread inspectionps(1) · proc(5)man 1 ps
The standardPOSIX.1-2024 Base Specifications Issue 8man 7 standards
Where to start. Read man 7 pthreads — its opening section is the shared-versus-private table from A1, written by the people who implemented it.

Then man 2 futex, specifically the first two paragraphs. They explain the "only enter the kernel when you must wait" design in a few sentences, and once that clicks, futex in a trace stops being noise and becomes a measurement.

E4 · Self-assessment

  1. List what threads share and what each thread has privately. Which item in the shared list causes the most bugs?
  2. How does Linux actually create a thread? What is the difference from fork?
  3. A process has PID 4000 and four threads. What are their PIDs? What are their TIDs?
  4. Explain a race condition using the counter example. Where exactly is the window?
  5. Why is "we will test for it" not a strategy for race conditions?
  6. What is a futex, and why is a mutex not simply a system call?
  7. You see millions of futex calls in a trace. What does that mean, and would more CPU help?
  8. Name the four conditions for deadlock. Which do you break in practice, and how?
  9. A service is hung. Threads are in S, CPU is 0%. What is it? What if they were in R at 90%? What if D?
  10. How do you tell deadlock from contention on a live system?
  11. A process shows 100% CPU on a 32-core host and the host dashboard says the machine is idle. Why do those two agree, and how do you find out whether one thread is saturated?
  12. A service fails with EAGAIN creating threads. Name three ceilings, and the setting that applies under systemd.

E5 · Sources

Technical content is sourced from the official documentation listed in E3.

Next: Module 07 — CPU Scheduling, nice & Load Average. You now know that Linux schedules threads, not processes, and you have met voluntary and involuntary context switches, run-queue state and load average in passing. Module 07 makes the scheduler itself the subject.
Spotted a mistake or want something added? Send me a note.