Multithreading & concurrency explained
Threads vs processes, the race conditions that bite you, how locks and deadlocks work, the GIL, and how to write code that is actually thread-safe, with real examples.
Threads vs processes
A process is a running program with its own isolated memory. A thread is a unit of execution inside a process; threads in the same process share memory. That shared memory is what makes threads cheap to communicate, and also what makes concurrency hard, multiple threads reading and writing the same data at once is the root of most concurrency bugs.
| Thread | Process | |
|---|---|---|
| Memory | Shared within the process | Isolated, its own space |
| Create cost | Cheap | Heavier |
| Communicate by | Shared memory (fast, needs locks) | Messages / IPC (slower, safer) |
| If one crashes | Can take down the whole process | Contained to that process |
| Reach for | Shared-state concurrency | Isolation + true CPU parallelism |
Concurrency vs parallelism
They are different. Concurrency is structuring a program as independent tasks that can make progress in overlapping time periods. Parallelism is actually running tasks at the same instant on multiple CPU cores. You can have concurrency on a single core (the scheduler interleaves tasks); parallelism needs multiple cores. Concurrency is about design; parallelism is about execution.
Race conditions
A race condition happens when the result depends on the unpredictable timing of threads. The
classic example is two threads incrementing a shared counter: read, add 1, write back is three
steps, and if both read the same value before either writes, one increment is lost.
import threading
counter = 0
def work():
global counter
for _ in range(100_000):
counter += 1 # read, add, write: three steps, NOT atomic
threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # expected 400000, but often less: a race The bug is intermittent and timing-dependent, which is why concurrency bugs are so hard to reproduce.
Locks, mutexes, and deadlocks
The fix is to make the dangerous sequence atomic, indivisible. A lock (or mutex, “mutual exclusion”) ensures only one thread enters a critical section at a time; others wait.
lock = threading.Lock()
def safe_work():
global counter
for _ in range(100_000):
with lock: # only one thread inside at a time
counter += 1 # now the increment is atomic Hold locks for as short a time as possible: locking too much serializes your program and kills the benefit of threads; locking too little leaves races open. And beware the deadlock, threads waiting on each other forever (A holds lock 1 and wants lock 2 while B holds lock 2 and wants lock 1). The standard prevention is to always acquire multiple locks in the same global order, hold as few at once as possible, and use timeouts so a stuck acquisition fails loudly instead of hanging.
Writing thread-safe code
- Don’t share mutable state if you can avoid it, the safest shared data is no shared data.
- Prefer immutability: data that never changes after creation is automatically thread-safe.
- Use higher-level tools: thread-safe collections, atomic types, and message-passing (queues or channels) are easier to get right than hand-rolled locks.
- Protect every access to shared mutable data with the same lock, guarding only some accesses still races.
Async vs threads (and the GIL)
Not all concurrency needs threads. For I/O-bound work, waiting on the network, disk, or a
database, async / non-blocking models (event loops, async/await) let a single
thread juggle thousands of in-flight operations without the overhead and locking of real threads.
| Async | Threads | Processes | |
|---|---|---|---|
| Best for | I/O-bound (waiting) | Shared-state concurrency | CPU-bound parallelism |
| Real parallelism | No (one thread) | Limited (GIL in CPython) | Yes |
| Overhead | Lowest | Medium | Highest |
| Shares memory | Yes | Yes (locks needed) | No (isolated) |
One language-specific gotcha: in CPython (the standard Python interpreter), a
Global Interpreter Lock (GIL) lets only one thread run Python bytecode at a time, so threads
do not give you CPU parallelism for pure-Python work, reach for multiprocessing there. Threads
still help I/O-bound Python because the GIL is released while waiting. (Newer CPython is adding an optional
free-threaded build that relaxes this.)
FAQ
What is the difference between a thread and a process?
A process is a running program with its own isolated memory; a thread is a unit of execution inside a process, and threads in the same process share memory. Threads are cheaper and communicate through shared data; processes are isolated, so one crashing does not take the others down.
Concurrency vs parallelism, what is the difference?
Concurrency is structuring a program as independent tasks that can make progress in overlapping time; parallelism is actually running tasks at the same instant on multiple cores. You can have concurrency on a single core (the scheduler interleaves tasks); parallelism needs more than one core. Concurrency is about design, parallelism is about execution.
Why is my concurrency bug so hard to reproduce?
Race conditions depend on the exact timing between threads, which varies run to run. Stress tests, thread sanitizers, and adding logging (which itself changes timing) are how you flush them out.
Is more threads always faster?
No. Past the number of CPU cores, extra threads add scheduling and locking overhead. For I/O-bound work, an async model usually scales better than piling on threads.
What is a deadlock and how do I prevent it?
A deadlock is when threads wait on each other forever: A holds lock 1 and wants lock 2 while B holds lock 2 and wants lock 1. Prevent it by always acquiring multiple locks in the same global order, holding as few locks at once as possible, and using timeouts so a stuck acquisition fails loudly.
Does Python's GIL make threads useless?
No, only for one case. In CPython, the Global Interpreter Lock lets one thread run Python bytecode at a time, so threads do not speed up CPU-bound pure-Python work, use multiprocessing for that. The lock is released during I/O, so threads still help I/O-bound work. (Newer CPython versions are adding an optional free-threaded mode.)
When should I use async instead of threads?
Use async for I/O-bound work that is mostly waiting on the network, disk, or a database, a single thread can juggle thousands of in-flight operations with no locking. Use threads or multiple processes for CPU-bound work that needs real parallelism.
What is a critical section?
The span of code that touches shared data and must not run in two threads at once. You protect it with a lock so only one thread is inside at a time.
Related concepts
OOP concepts · Memory leaks · Smart pointers · Design patterns · all references.
KB Cafe’s threading how-tos were among its most-linked developer articles in the C# and Java era, back when getting locking right was the difference between a working server and a 3am page. This is the modern restoration: the same hard-won fundamentals, with code and the trade-offs that matter today.