Threading
A thread is a separate line of execution that runs inside the same process as your main program, sharing the same memory. Python's built-in threading module lets you create and run several of these at once. Threading is one of the most useful tools for programs that spend a lot of time waiting — for a network response, a database query, or a file to finish writing — because while one thread waits, another can keep making progress.
Creating and starting a thread
import threading
import time
def download(name, delay):
print(f"{name}: starting")
time.sleep(delay) # stands in for a slow network call
print(f"{name}: done")
t1 = threading.Thread(target=download, args=("file-1", 2))
t2 = threading.Thread(target=download, args=("file-2", 2))
t1.start()
t2.start()
t1.join() # wait for t1 to finish
t2.join() # wait for t2 to finish
print("Both downloads complete")file-1: starting file-2: starting file-1: done file-2: done Both downloads complete
Both "starting" lines print immediately, and both "done" lines print about two seconds later — not four. The two time.sleep() calls overlapped instead of running one after the other. .join() blocks the calling thread until the target thread has finished; without it, the main program could reach the final print() before the downloads are actually done.
Where threading helps — and where it doesn't
Threading shines for I/O-bound work: anything where the thread spends most of its time waiting on something outside the CPU — network requests, disk reads, database queries, sleep(). While one thread is blocked waiting, Python is free to run another thread.
Workload type | Example | Does threading help? |
|---|---|---|
I/O-bound | HTTP requests, file/database I/O, waiting on a socket | Yes — threads overlap their waiting time |
CPU-bound | Number crunching, image/video processing, parsing | No — the GIL serializes Python bytecode execution |
Race conditions
Sharing memory between threads is powerful, but it is also exactly where things go wrong. If two threads read and modify the same variable without coordination, the final result can depend on the unpredictable order in which the operating system happens to run them — a race condition.
import threading
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # not atomic! read, add, write — in 3 separate steps
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # expected 400_000 — often prints something lower371248
counter += 1 looks like a single step, but it is really three: read the current value, add one, write it back. Two threads can both read the same value before either writes it back, so one of the increments is silently lost. Run this enough times and you'll get a different wrong number almost every time — the hallmark of a race condition.
Fixing it with a Lock
threading.Lock guarantees that only one thread at a time can be inside the protected section of code, turning the risky read-modify-write sequence back into something safe.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # only one thread runs this block at a time
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) # always 400_000400000
with lock:acquires the lock on entry and releases it automatically on exit, even if an exception is raised inside the block.Only lock the smallest section of code that actually needs protecting — holding a lock longer than necessary throws away the benefit of having multiple threads in the first place.
A lock does not make code faster; it makes shared state correct. Speed for I/O-bound work comes from overlapping the waiting, not from the lock itself.