PythonThreading

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

Python
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.

Threading doesn't speed up CPU-bound work
For **CPU-bound** work — heavy number crunching, image processing, parsing huge amounts of text — threading in Python typically does *not* make things faster. The standard CPython interpreter has a Global Interpreter Lock (GIL) that allows only one thread to execute Python bytecode at a time, even on a multi-core machine. That's just a teaser here — the GIL deserves (and gets) its own dedicated page — but the short version is: for CPU-heavy work, look at the `multiprocessing` module instead of `threading`.

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.

Python
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 lower
371248

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.

Python
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_000
400000
  • 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.

Note
A thread that tries to acquire a lock it (or another thread) is already holding — without a matching release — will simply wait forever. This is called a deadlock. Using `with lock:` instead of manual `lock.acquire()` / `lock.release()` calls avoids most accidental deadlocks caused by forgetting to release on an exception path.
Prefer higher-level tools when you can
For many real programs you don't need to manage `Thread` objects directly at all — `concurrent.futures.ThreadPoolExecutor` gives you a pool of worker threads and a simple `.map()` / `.submit()` interface on top of everything shown here.