Concurrency in practice
In one line
Real systems handle many tasks at once with a few proven patterns (thread pools, producer-consumer queues, event loops and process pools), chosen by whether the work is I/O-bound or CPU-bound.
What it is
Concurrency means structuring a program so several tasks make progress in overlapping time; parallelism means they actually run at the same instant on different cores. A web server handling 10,000 open connections is concurrent; a video encoder using 8 cores is parallel.
Instead of creating raw threads and locks everywhere, production code uses a small set of patterns: thread pools (a fixed set of reusable workers), producer-consumer with a bounded queue, event loops with async I/O (one thread juggling many waiting tasks), process pools for CPU-heavy work, and message passing (channels or actors) instead of shared memory.
Why interviewers ask
Interviewers want to see you can apply OS ideas to real systems: why Node.js handles thousands of connections on one thread, why a Python CPU-bound job needs processes, how to stop a fast producer from overwhelming a slow consumer, and how to size a pool. These questions often appear in backend and system design rounds.
How it works
- 1Classify the work. I/O-bound tasks (HTTP calls, database queries, file reads) mostly wait; CPU-bound tasks (image resizing, compression, ML inference) mostly compute.
- 2For I/O-bound work, use a thread pool or an event loop. While one task waits on the network, others run. One event loop thread can handle thousands of idle connections because waiting costs no CPU.
- 3For CPU-bound work, use about one worker per core (a process pool in Python because of the GIL, threads in Java or Go). More workers than cores only adds switching.
- 4Connect stages with a bounded queue (producer-consumer). Producers add jobs; consumers take them. When the queue is full, producers block or are rejected, which is backpressure.
- 5Prefer passing messages over sharing state. If state must be shared, keep it small and protected, or use thread-safe structures.
- 6Plan shutdown and failures: timeouts on every external call, sentinel values or cancellation to stop workers, and retries for idempotent operations.
Worked example · Producer-consumer with 4 workers and a queue of size 3
- A producer reads image ids 0, 1, 2, ... and puts them on a bounded queue with room for 3 items. Four worker threads take items and resize the images.
- t = 0: the producer puts 0, 1, 2. Workers w0 to w2 immediately take them; w3 is waiting on an empty queue.
- The producer puts 3 (w3 takes it), then 4, 5 and 6, which sit in the queue. The queue is now full (3 items).
- The producer tries to put 7 and blocks: this is backpressure. It cannot run ahead of the workers and fill memory.
- w1 finishes image 1 and takes image 4 from the queue. Now there is space, so the producer wakes up and puts 7.
- After the last image, the producer puts one sentinel per worker. Each worker exits when it reads a sentinel, and the main thread joins all four. No image is processed twice and none is lost.
Picture it
A restaurant kitchen: waiters (producers) put orders on a rail with limited space (bounded queue), and a fixed team of cooks (worker pool) takes orders from it. If the rail is full, waiters stop taking new orders for a moment. One very fast waiter (the event loop) can serve many tables because most of the time diners are just thinking.
Reference code
import queue
import threading
tasks = queue.Queue(maxsize=100) # bounded: put() blocks when full (backpressure)
STOP = None # sentinel that tells a worker to exit
def worker(name):
while True:
item = tasks.get() # blocks until an item is available
if item is STOP:
break
print(f"{name} resized image {item}")
workers = [threading.Thread(target=worker, args=(f"w{i}",)) for i in range(4)]
for w in workers:
w.start()
for image_id in range(20): # the producer
tasks.put(image_id)
for _ in workers: # one sentinel per worker
tasks.put(STOP)
for w in workers:
w.join()
queue.Queue is thread-safe, so no explicit lock is needed. The maximum size of 100 makes put() block when consumers fall behind. Each worker exits when it reads the sentinel, and putting one sentinel per worker guarantees all four stop.
Key patterns
Thread pool
When: Many short I/O-bound tasks
How: Reuse a fixed set of workers (for example ThreadPoolExecutor); size it from how much time tasks spend waiting and downstream limits.
Producer-consumer with a bounded queue
When: Stages run at different speeds
How: Queue between stages; a maximum size gives backpressure so memory cannot grow without limit.
Event loop / async I/O
When: Thousands of mostly idle connections (chat, APIs, proxies)
How: One thread runs non-blocking tasks; never block it with CPU-heavy or blocking calls.
Process pool for CPU work
When: CPU-bound work, especially in Python
How: About one worker per core; pass small inputs and outputs to keep copying cheap.
Fan-out / fan-in
When: Independent subtasks whose results are combined
How: Submit all subtasks, then gather the results (futures, asyncio.gather, WaitGroup) with a timeout.
Real-world use
Node.js and Python asyncio
Node.js runs JavaScript on a single event loop thread and uses libuv for non-blocking I/O, so one process can serve many concurrent connections; a CPU-heavy function blocks every request, which is why such work goes to worker threads. FastAPI on asyncio follows the same model in Python.
Go goroutines and channels
Go multiplexes many lightweight goroutines onto a few OS threads and encourages sharing data by communicating over channels, making producer-consumer pipelines a natural fit.
Java ExecutorService
Java backends submit tasks to thread pools from Executors, often with a bounded queue and a rejection policy; Java 21 added virtual threads for cheap blocking-style I/O concurrency.
Message queues between services
Kafka, RabbitMQ and SQS are producer-consumer at system scale: services publish jobs, consumer groups process them, and queue depth signals when to scale out consumers.
In a project
A script that downloads 10,000 product images from a supplier, resizes them and uploads them to object storage takes 6 hours running one item at a time.
- Profile one item: download about 300 ms (I/O), resize about 50 ms (CPU), upload about 200 ms (I/O). Most of the time is waiting.
- Download with a pool of 32 threads (or asyncio) and a limit per supplier to respect their rate limits.
- Pass downloaded images through a bounded queue to a process pool with one worker per core for resizing.
- Upload with another thread pool, with retries and timeouts; make uploads idempotent by using the product id as the object key.
- Log progress and failures so a rerun skips finished items.
Impact: Total time drops from about 6 hours to roughly 15-20 minutes on an 8-core machine, memory stays flat thanks to the bounded queue, and a failure halfway can be resumed instead of restarted.
Trade-offs
Use it when
- Thread pools or async: I/O-bound work such as API calls, database queries and web scraping
- Process pools: CPU-bound work, especially in CPython
- Bounded queues: whenever producers can outpace consumers
- Message passing: when you want fewer locks and simpler reasoning
Avoid it when
- A thread per request without limit: memory and switching costs explode under load
- Blocking or CPU-heavy calls inside an event loop: they stall every other task
- Unbounded queues: they hide overload until memory runs out
- More CPU-bound workers than cores: extra workers only add context switches
Cost: A common rule of thumb for pool size is cores x (1 + wait time / compute time): about the core count for CPU-bound work, many more for I/O-bound work, capped by downstream limits like database connections.
Common mistakes
- Using threads for CPU-bound Python code and expecting a speed-up.
- Calling blocking code (like requests.get or time.sleep) inside an async function, which blocks the whole event loop.
- Using unbounded queues or unbounded thread creation, so overload becomes an out-of-memory crash.
- Forgetting timeouts on external calls, so a few slow requests tie up every worker.
- Setting a pool size far larger than downstream capacity, such as 200 threads sharing a 20-connection database pool.
Interview questions
Quick revision
- First ask: I/O-bound or CPU-bound?
- I/O-bound: thread pool or async event loop.
- CPU-bound: about one worker per core; processes in CPython.
- Producer-consumer + bounded queue = backpressure.
- Never block the event loop.
- Timeouts on every external call; retries only for idempotent work.
- Pool size about cores x (1 + wait / compute), capped by downstream limits.
- Prefer message passing to shared state.
Check yourself
1. A Python service makes thousands of slow HTTP calls. Which approach fits best?
2. What does a bounded queue give you in a producer-consumer pipeline?
3. What happens if you run a 2-second CPU-heavy function inside a Node.js request handler?
4. For a purely CPU-bound task on an 8-core machine, about how many workers should you start with?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.