Multithreading #
When a program needs to do many things at once — downloading files while updating the UI, or processing many network requests simultaneously — running everything sequentially feels slow. Threading is the answer: running several execution flows concurrently within one process. Python provides the full-featured threading module for this, but there’s one important quirk you need to understand before using it — the GIL (Global Interpreter Lock), which determines when threading actually delivers a performance benefit.
What Are Threads and the GIL? #
Before writing code, it’s important to understand Python’s execution model. A thread is the smallest unit of execution within a process — all threads in one process share the same memory, which makes inter-thread communication easy but also prone to conflicts.
What makes Python unique is the Global Interpreter Lock (GIL): a mutex that ensures only one thread can run Python bytecode at a time. In other words, threading in Python doesn’t provide true parallelism for CPU-bound operations.
When threading is effective in Python:
✓ I/O-bound tasks → downloading files, querying databases, HTTP requests
(the GIL is released while waiting for I/O, other threads can run)
✗ CPU-bound tasks → heavy computation, image processing, machine learning
(the GIL isn't released, threads take turns but don't run in parallel)
→ use multiprocessing for these cases
The GIL is a CPython design trade-off for memory safety. If you need true parallelism for CPU-bound tasks, use themultiprocessingmodule or libraries likenumpythat release the GIL internally.
To visualize how the GIL limits multiple threads to serial (alternating) execution even on multi-core CPUs, look at the sequence diagram below:
sequenceDiagram
participant CPU
participant T1 as Thread 1
participant T2 as Thread 2
Note over T1, T2: Sharing the Same Memory in One Process
T1->>CPU: Acquire GIL & Start Execution (Python Bytecode)
Note over T2: Waiting (Blocked by GIL)
T1->>CPU: Release GIL (Waiting for I/O / Tick Done)
T2->>CPU: Acquire GIL & Start Execution (Python Bytecode)
Note over T1: Waiting (Blocked by GIL)Creating Threads #
There are two ways to create threads in Python: using a plain function as the target, or subclassing threading.Thread. Both are valid — pick whichever fits the complexity of your thread’s logic.
Way 1: Thread with a Function #
The most concise way. Good for simple logic that doesn’t need internal state.
import threading
import time
def download_file(file_name):
print(f"[{file_name}] Starting download...")
time.sleep(2) # simulated I/O
print(f"[{file_name}] Download complete.")
# Create two threads running at the same time
thread1 = threading.Thread(target=download_file, args=("report.pdf",))
thread2 = threading.Thread(target=download_file, args=("image.png",))
thread1.start()
thread2.start()
# Wait for both to finish before continuing
thread1.join()
thread2.join()
print("All files downloaded.")
Way 2: Thread with a Subclass #
Good when your thread needs state, extra methods, or more complex logic.
import threading
import time
class DownloadThread(threading.Thread):
def __init__(self, file_name):
super().__init__()
self.file_name = file_name
self.result = None # can store state
def run(self):
print(f"[{self.file_name}] Starting download...")
time.sleep(2)
self.result = f"Content of {self.file_name}"
print(f"[{self.file_name}] Done.")
thread1 = DownloadThread("report.pdf")
thread2 = DownloadThread("image.png")
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(thread1.result) # access the result after the thread finishes
print(thread2.result)
Always callthread.join()if the main thread needs to wait for other threads to finish. Withoutjoin(), the program can exit before background threads finish their work — causing truncated output or resources not being cleaned up properly.
Race Conditions and Locks #
Because all threads share the same memory, problems arise when two threads try to modify the same data simultaneously. This is called a race condition — the result is non-deterministic and often causes bugs that are hard to reproduce.
import threading
# ANTI-PATTERN: a counter without protection
counter = 0
def increment():
global counter
for _ in range(100000):
counter += 1 # this operation is NOT atomic — a race condition can occur
thread1 = threading.Thread(target=increment)
thread2 = threading.Thread(target=increment)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(counter) # the result is NOT always 200000 — can be less due to the race condition
The solution is threading.Lock — only one thread can hold the lock at a time.
import threading
# CORRECT: use a Lock to protect shared state
class Counter:
def __init__(self):
self.value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock: # automatically acquires and releases
self.value += 1
counter = Counter()
def increment_many():
for _ in range(100000):
counter.increment()
thread1 = threading.Thread(target=increment_many)
thread2 = threading.Thread(target=increment_many)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(counter.value) # always 200000
Besides Lock, Python also provides RLock (Reentrant Lock) for cases where the same thread needs to acquire a lock it already holds — for example in recursive calls.
import threading
# RLock: the same thread can acquire it multiple times
rlock = threading.RLock()
def recursive_function(n):
with rlock:
if n <= 0:
return
print(f"Level {n}")
recursive_function(n - 1) # a plain Lock would deadlock here
recursive_function(3)
Inter-Thread Communication with Event #
Sometimes one thread needs to wait for a signal from another thread before continuing. threading.Event is the synchronization primitive for this case — a boolean flag that any thread can set or clear.
import threading
import time
ready_event = threading.Event()
def worker():
print("Worker: waiting for data to be ready...")
ready_event.wait() # blocks until the event is set
print("Worker: data received, starting to process.")
def preparation():
print("Preparation: preparing data...")
time.sleep(3)
print("Preparation: data ready, signaling.")
ready_event.set() # send a signal to the worker
t_worker = threading.Thread(target=worker)
t_prep = threading.Thread(target=preparation)
t_worker.start()
t_prep.start()
t_worker.join()
t_prep.join()
Events can also be used as a stop mechanism for continuously running threads:
import threading
import time
stop_event = threading.Event()
def monitor():
while not stop_event.is_set():
print("Monitor: system running normally...")
time.sleep(1)
print("Monitor: stopped.")
t = threading.Thread(target=monitor)
t.start()
time.sleep(4)
stop_event.set() # send the stop signal
t.join()
print("Program finished.")
Limiting Access with Semaphore #
threading.Semaphore is useful when you want to limit how many threads may access a resource simultaneously — for example database connections, API slots, or file I/O.
import threading
import time
# Maximum 3 concurrent database connections
connection_pool = threading.Semaphore(3)
def query_database(worker_id):
print(f"Worker-{worker_id}: waiting for a connection slot...")
with connection_pool:
print(f"Worker-{worker_id}: connected, running the query.")
time.sleep(2) # simulated query
print(f"Worker-{worker_id}: done, releasing the connection.")
# 7 workers competing for 3 connection slots
threads = [threading.Thread(target=query_database, args=(i,)) for i in range(7)]
for t in threads:
t.start()
for t in threads:
t.join()
print("All queries finished.")
ThreadPoolExecutor #
Creating and managing threads manually gets tedious for large numbers of tasks. concurrent.futures.ThreadPoolExecutor provides a high-level abstraction: you just submit tasks, and the pool handles the thread lifecycle.
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def process_item(item_id):
time.sleep(1) # simulated I/O
return f"Result of item-{item_id}"
# A pool with 4 worker threads
with ThreadPoolExecutor(max_workers=4) as executor:
# submit all tasks at once
futures = {executor.submit(process_item, i): i for i in range(8)}
# collect results as they finish (order may differ)
for future in as_completed(futures):
item_id = futures[future]
try:
result = future.result()
print(f"Item-{item_id} done: {result}")
except Exception as e:
print(f"Item-{item_id} error: {e}")
map() is a more concise alternative if you don’t need per-task error handling:
from concurrent.futures import ThreadPoolExecutor
def download(url):
# simulated download
return f"Content of {url}"
urls = ["https://api.example.com/data/1",
"https://api.example.com/data/2",
"https://api.example.com/data/3"]
with ThreadPoolExecutor(max_workers=3) as executor:
results = list(executor.map(download, urls))
for r in results:
print(r)
When to Use Threading vs Alternatives #
Use threading when:
✓ Tasks are I/O-bound (HTTP requests, file read/write, DB queries)
✓ You need shared state between tasks
✓ The number of tasks isn't huge (hundreds, not thousands)
Consider multiprocessing when:
✗ Tasks are CPU-bound (intensive computation, image processing)
✗ The GIL becomes a measurable bottleneck
Consider asyncio when:
✗ The number of tasks is very large (thousands of concurrent connections)
✗ Tasks are entirely I/O-bound and can be written asynchronously
✗ You want better memory efficiency than a thread pool
Summary #
- The GIL limits threading for CPU-bound tasks — Python only runs one thread of bytecode at a time; for heavy computation, use
multiprocessing.- Threading is effective for I/O-bound tasks — while a thread waits for I/O, the GIL is released and other threads can run.
- Use
Lockto protect shared state — without a Lock, concurrent read-write operations can cause race conditions with non-deterministic results.RLockfor recursive cases — the same thread can acquire an RLock many times without deadlocking.Eventfor inter-thread signals — useset()to signal andwait()to block until signaled.Semaphoreto limit concurrent access — ideal for connection pools or rate limiting.ThreadPoolExecutorfor easier thread management — a high-level abstraction that handles the thread lifecycle automatically.- Always
join()the threads you create — make sure cleanup happens properly before the program exits.