Python: Multithreading
Multithreading lets a Python program do multiple things at once. Understanding its real strengths, its limits under the GIL, and its synchronisation tools is what separates code that works correctly under concurrency from code that fails only in production.
Concurrency vs Parallelism
These two words are often used interchangeably, but they describe different things. Concurrency means dealing with multiple tasks by switching between them, giving the appearance of simultaneous progress. A single-core machine can be concurrent: while one task is waiting for a network response, another runs. No two tasks are ever executing at the exact same instant, but none are stuck waiting for the other to finish.
Parallelism means actually executing multiple tasks at the same physical instant, which requires multiple CPU cores. A quad-core machine running four threads simultaneously is parallel. The important practical distinction: you need parallelism to speed up CPU-heavy computation, but concurrency alone is sufficient to dramatically improve programs that spend most of their time waiting on I/O.
Which Tool to Use for Which Problem
Python offers three main concurrency models. The right choice depends on the type of work.
- threading: best for I/O-bound work (network requests, file I/O, database queries). The GIL releases during I/O waits, so multiple threads genuinely progress concurrently.
- multiprocessing: best for CPU-bound work. Each process has its own Python interpreter and therefore its own GIL, achieving true parallelism.
- asyncio: best for high-volume I/O with thousands of concurrent connections. Uses cooperative multitasking on a single thread with minimal overhead.
The GIL: Global Interpreter Lock
The GIL is a mutex inside CPython (the standard Python implementation) that ensures only one thread executes Python bytecode at a time. It exists because CPython's memory management is not thread-safe: the reference counting mechanism that decides when to free objects would corrupt memory if two threads modified reference counts simultaneously.
The practical consequence: Python threads cannot run CPU-bound code in parallel on multiple cores. However, the GIL is released whenever a thread performs I/O, calls a C extension that manages its own GIL state (like NumPy), or sleeps. This is why threading is genuinely useful for network-heavy programs even under the GIL. It is worth noting that Python 3.13 introduced an experimental "free-threaded" build that removes the GIL, but it is not yet the default.
GIL Impact on CPU vs I/O Work
PythonThreads help with I/O-bound tasks but not pure CPU-bound computation in CPython.
The threading Module
Python's threading module is the standard library's high-level interface to OS threads. It provides Thread objects, synchronisation primitives (Lock, RLock, Semaphore, Event, Condition,Barrier), and utility functions like threading.current_thread()and threading.active_count().
Inspecting Active Threads
PythonQuerying the threading module for information about the current state.
Creating and Starting Threads
There are two ways to create a thread: pass a callable to Thread(target=...), or subclass threading.Thread and override its run() method. The first style is more common and concise. The subclass approach is useful when the thread needs to carry significant state or expose additional methods.
Calling t.start() launches the thread. Calling t.join() blocks the calling thread until t finishes. Forgetting to join() is not an error, but it means your main thread may exit before the worker finishes. Results and exceptions from worker threads must be communicated back explicitly, usually via a shared data structure or a queue.Queue.
Two Ways to Create a Thread
PythonFunction-based and subclass-based, with argument passing and join.
Thread Synchronisation: Lock and RLock
A Lock is the most basic synchronisation primitive. Only one thread can hold the lock at a time. Any thread that calls acquire() while another holds it will block until the lock is released. The critical section, the code that must not run concurrently, goes between acquire() andrelease(). The idiomatic way to do this is with a with statement, which acquires on entry and releases on exit, even if an exception occurs.
An RLock (reentrant lock) can be acquired multiple times by the same thread without deadlocking. The lock is only fully released when it has been released as many times as it was acquired. Use it when a function that holds a lock may call another function that also tries to acquire the same lock.
Lock: Protecting Shared State
PythonPreventing a race condition on a shared counter.
RLock: Reentrant Locking
PythonA thread that needs to acquire the same lock twice without deadlocking.
Semaphore, Event, and Condition
Beyond Lock, the threading module provides three higher-level primitives that cover different coordination patterns.
A Semaphore is a counter-based lock. Initialising it withn allows up to n threads to be inside the guarded section at once. When the counter reaches zero, new threads block until one of the current holders releases. This is the right tool for limiting concurrency, for example capping how many simultaneous connections are made to a server.
An Event is a simple flag. One thread sets it; other threads wait for it. event.wait() blocks until event.set() is called.event.clear() resets the flag. A Condition combines a lock with a notification mechanism. Threads can call wait() to release the lock and sleep until another thread calls notify() ornotify_all(), at which point they reacquire the lock and proceed.
Semaphore: Limiting Concurrent Access
PythonCapping simultaneous database connections to three.
Event: Thread-to-Thread Signalling
PythonA worker thread waiting for a start signal from the main thread.
Condition: Producer-Consumer Pattern
PythonA consumer waiting until a producer has data ready.
Race Conditions and Deadlocks
A race condition occurs when the correctness of a program depends on the exact timing of two or more threads. If thread A reads a value, then thread B modifies it, then thread A writes based on its stale read, the result is wrong. Race conditions are notoriously hard to reproduce because they depend on thread scheduling, which varies across runs, machines, and load conditions.
A deadlock occurs when two or more threads are permanently blocked, each waiting for a resource that the other holds. Thread A holds Lock 1 and waits for Lock 2. Thread B holds Lock 2 and waits for Lock 1. Neither can proceed. The standard prevention strategy is to always acquire locks in a consistent global order across all threads.
Deadlock: A Classic Two-Lock Scenario
PythonHow inconsistent lock ordering causes permanent blocking.
Daemon Threads
By default, a Python program does not exit until all non-daemon threads have finished. A daemon thread is marked as a background worker. When the last non-daemon thread (usually the main thread) exits, Python terminates all remaining daemon threads abruptly, without allowing them to clean up. Setdaemon=True in the constructor or set t.daemon = True before callingt.start().
Daemon threads are appropriate for background tasks that have no meaningful work to do after the main program finishes: heartbeat senders, cache warmers, log flushers. Do not use them for threads that must write data to disk or close network connections gracefully on exit.
Daemon vs Non-Daemon Threads
PythonThe program exits immediately when only daemon threads remain.
Thread Pools with concurrent.futures.ThreadPoolExecutor
Creating and destroying threads has overhead. For repeated short tasks, a thread pool maintains a fixed set of worker threads that pick up tasks from a queue, eliminating that overhead. concurrent.futures.ThreadPoolExecutor is the standard library's high-level thread pool. It returns Future objects that let you retrieve results or handle exceptions without manually joining threads.
executor.submit(fn, *args) schedules a single call and returns aFuture. executor.map(fn, iterable) applies the function to every item in the iterable, preserving order in the results. Using the executor as a context manager ensures all threads are cleaned up when the block exits.
ThreadPoolExecutor: submit() and map()
PythonParallel downloads with a pool of four worker threads.
Timer Threads
threading.Timer is a subclass of Thread that runs a function after a specified delay. It is the lightweight alternative to scheduling libraries for simple one-shot delayed execution. You can cancel a timer before it fires by calling timer.cancel(), as long as the timer has not already started executing.
Timer: Delayed and Cancellable Execution
PythonRunning a function after a delay, with the option to cancel.
Barrier Objects
A threading.Barrier is a synchronisation point that a fixed number of threads must all reach before any of them can continue. You initialise it with a party count. Each thread calls barrier.wait() when it arrives. The call blocks until all parties have called wait(), at which point all of them are released simultaneously.
Barriers model the "gather and go" pattern in parallel computation: run a phase, wait for everyone to finish, then run the next phase. One thread per barrier reset receives a return value of 0 from wait(), which can be used to trigger phase-transition work like resetting shared state.
Barrier: Synchronising a Multi-Phase Computation
PythonAll workers must complete phase 1 before any can start phase 2.
Summary: Choosing the Right Synchronisation Primitive
Each primitive addresses a specific coordination need.
- Lock: mutual exclusion. Only one thread executes the critical section at a time.
- RLock: mutual exclusion where the same thread may re-acquire the lock it already holds.
- Semaphore: limit concurrency. Allow up to N threads in a section simultaneously.
- Event: one-time or repeatable signalling. One thread signals; others wait for the flag.
- Condition: conditional waiting with notification. Threads wait for a state change and are woken by producers.
- Barrier: phase synchronisation. All threads must arrive at a point before any continue.
Quiz - Test Your Knowledge
Ten questions on concurrency concepts, the GIL, thread creation, synchronisation primitives, race conditions, deadlocks, daemon threads, and the standard library tools. Several questions ask about subtle distinctions, so consider each option carefully before answering.
Knowledge Check
1. What is the key difference between concurrency and parallelism?
2. Why does the GIL limit Python threads for CPU-bound work?
3. Which type of workload benefits most from Python multithreading despite the GIL?
4. What is a race condition?
5. What is the difference between Lock and RLock?
6. What is a deadlock?
7. What happens to a daemon thread when the main thread exits?
8. What is the primary advantage of using ThreadPoolExecutor over creating threads manually?
9. What does threading.Event allow threads to do?
10. What does threading.Barrier do?