Threading in Python: A Practical Guide to Concurrent Execution
Python’s threading module lets you run multiple tasks at the same time. Threads are useful when your program needs to wait for external things—like network responses, file reads, or user input—while doing other work. This guide shows you how to create threads, synchronize them safely, and avoid common pitfalls.
When to Use Threads
Threads shine for I/O-bound tasks: downloading files, calling APIs, reading from disks, or waiting for user input. While one thread waits for data, others can keep your program busy.
For CPU-bound work—number crunching, image processing, machine learning—threads are less effective because of Python’s Global Interpreter Lock (GIL). The GIL prevents multiple threads from running Python bytecode simultaneously. If you’re doing heavy computation, consider multiprocessing instead.
Creating Threads
The simplest way to create a thread is with the Thread class:
import threading
import time
def download_file(filename):
print(f"Starting download: {filename}")
time.sleep(2) # Simulate I/O work
print(f"Finished: {filename}")
# Create threads
thread1 = threading.Thread(target=download_file, args=("data.csv",))
thread2 = threading.Thread(target=download_file, args=("image.png",))
# Start them
thread1.start()
thread2.start()
# Wait for both to finish
thread1.join()
thread2.join()
print("All downloads complete")
Output:
Starting download: data.csv
Starting download: image.png
Finished: data.csv
Finished: image.png
All downloads complete
Both downloads run in parallel without blocking each other, cutting the total time roughly in half compared to sequential execution. The join() calls wait for each thread to finish before the main program continues, ensuring you do not exit prematurely while background work is still in progress.
Subclassing Thread
For more control, subclass Thread and override the run() method:
import threading
import time
class DownloadTask(threading.Thread):
def __init__(self, filename):
super().__init__()
self.filename = filename
def run(self):
print(f"Downloading {self.filename}")
time.sleep(2)
print(f"Done: {self.filename}")
# Use it
task = DownloadTask("report.pdf")
task.start()
task.join()
This pattern works well when each thread needs its own state or behavior.
Sharing Data Between Threads
When multiple threads access the same data, you need synchronization. Without it, race conditions can corrupt data:
import threading
counter = 0
def increment():
global counter
for _ in range(1000000):
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # Likely less than 4000000!
The counter ends up wrong because counter += 1 isn’t atomic—it involves reading, adding, and writing. Threads can interleave between these steps.
Locks: Protecting Shared Data
Use a Lock to ensure only one thread accesses a resource at a time:
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(1000000):
with lock:
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # Exactly 4000000
The with lock: statement acquires the lock before the critical section and releases it afterward—even if an exception occurs.
Other Synchronization Primitives
The threading module provides several synchronization tools:
- RLock: A reentrant lock that the same thread can acquire multiple times
- Condition: Wait for specific conditions with
wait()andnotify() - Semaphore: Limits access to a fixed number of resources
- Event: One thread signals, others wait
- Barrier: Threads wait for each other at a synchronization point
# Example: Barrier for phased processing
import threading
def process_phase(barrier, phase):
print(f"Phase {phase} starting")
barrier.wait() # Wait for all threads
print(f"Phase {phase} complete")
barrier = threading.Barrier(3)
threads = [
threading.Thread(target=process_phase, args=(barrier, i))
for i in range(1, 4)
]
for t in threads:
t.start()
for t in threads:
t.join()
A Barrier forces all participating threads to pause at a synchronization point until every thread has arrived, then releases them all at once. This is useful for staged processing where each phase depends on the previous phase completing across all workers — for example, when you need all threads to finish loading data before any of them begin processing it.
Thread-Safe Queues
The queue module provides thread-safe queues for passing data between threads:
import threading
import queue
import time
def producer(q):
for i in range(5):
time.sleep(0.5)
q.put(i)
print(f"Produced: {i}")
def consumer(q):
while True:
item = q.get()
if item is None: # Poison pill
break
print(f"Consumed: {item}")
q.task_done()
q = queue.Queue()
producer_thread = threading.Thread(target=producer, args=(q,))
consumer_thread = threading.Thread(target=consumer, args=(q,))
producer_thread.start()
consumer_thread.start()
producer_thread.join()
q.put(None) # Poison pill to stop consumer
consumer_thread.join()
The queue handles all synchronization internally — you do not need locks for basic put and get operations. This producer-consumer pattern with a poison pill sentinel value is a clean way to signal a worker thread to exit after all work items have been processed. For simpler cases, queue.Queue.join() and queue.Queue.task_done() let you block until every item has been consumed.
Daemon Threads
Set a thread as a daemon to let the program exit without waiting for it:
thread = threading.Thread(target=background_task, daemon=True)
thread.start()
# Program exits even if thread is still running
Daemon threads are useful for monitoring, heartbeat, or cleanup tasks that should never prevent the application from shutting down. The main program exits as soon as all non-daemon threads finish, and any remaining daemon threads are terminated abruptly — so only use them for work where an incomplete cleanup is acceptable.
Best Practices
- Keep threads focused: Each thread should have a clear purpose
- Avoid excessive threads: Too many threads add overhead; a few dozen is usually plenty
- Always join or detach: Don’t leave threads dangling
- Use queues for communication: They’re safer than shared state
- Handle exceptions in threads: Uncaught exceptions can silently kill threads
- Consider higher-level APIs:
concurrent.futures.ThreadPoolExecutormanages a pool of workers for you
from concurrent.futures import ThreadPoolExecutor
def fetch_url(url):
# Simulate work
return f"Result from {url}"
with ThreadPoolExecutor(max_workers=4) as executor:
results = executor.map(fetch_url, ["url1", "url2", "url3"])
for result in results:
print(result)
The executor handles thread creation, reuse, and cleanup automatically. For most I/O-bound workloads, ThreadPoolExecutor should be your first choice — it avoids the boilerplate of managing threads manually while still giving you the concurrency benefits. The max_workers parameter lets you cap simultaneous threads to prevent overwhelming external services or system resources.
Common Pitfalls
- Forgetting to join: Threads won’t clean up properly
- Deadlocks: Two threads waiting on each other’s locks
- Race conditions: Unsynchronized access to shared data
- GIL misconceptions: Threads won’t speed up CPU-bound work
- Modifying globals carelessly: Use locks or thread-local storage
See Also
threadingmodule — Full module referencequeuemodule — Thread-safe queuesconcurrent.futures— High-level thread pool APImultiprocessing— Process-based parallelism for CPU-bound tasks