Multithreading and Multiprocessing Basics
Concurrency vs parallelism, the GIL explained honestly, threading for I/O-bound work, multiprocessing for CPU-bound work, and a clear decision framework.
Two Different Ideas That Get Used Interchangeably — And Shouldn't
Before touching any code, this module needs one distinction to be genuinely solid, because almost every confusing explanation of threading and the GIL online skips it: concurrency and parallelism are not the same thing, even though they sound like synonyms.
Concurrency is about structure — a program is concurrent if it is composed of multiple independent tasks that can be in progress at overlapping times, whether or not they are literally executing at the exact same instant. A single chef juggling four dishes, switching attention between them as each one needs a step done, is running concurrently — only one dish is actually being worked on at any given instant, but all four are "in progress" together. Parallelism is about execution — actual simultaneous execution, multiple things happening at the literal same instant. Four chefs, each fully dedicated to one dish, working at the same time, is parallelism.
Concurrency: dealing with many things at once (structure)
Parallelism: doing many things at once (execution)
# A single-core machine can be concurrent, but never truly parallel.
# A multi-core machine can be both.This distinction is the reason this module exists as one topic instead of two. Python's threading module gives you concurrency — genuinely useful for a specific kind of workload, covered in Part 03 onward — but, because of the Global Interpreter Lock covered next, it does not give you true parallelism for Python code itself. Python's multiprocessing module gives you real parallelism, at the cost of more overhead. Knowing which one you actually need starts with knowing which of these two words describes your actual problem.
The GIL — What It Actually Is, and Its One Real Consequence
CPython (the standard, most widely used Python implementation — the one you get from python.org) has a mechanism called the Global Interpreter Lock, almost always shortened to "the GIL." Its job: ensure that only one thread executes Python bytecode at a time, even on a machine with many CPU cores, and even when a program has started several threads.
The GIL exists for a genuinely reasonable engineering reason, not as an oversight. CPython manages memory using reference counting — every object tracks how many things currently refer to it, and gets cleaned up automatically once that count hits zero. Without some form of locking, two threads incrementing or decrementing the same object's reference count at the exact same instant could corrupt that count, leading to memory being freed while something still uses it, or never freed at all. The GIL sidesteps this entire category of bug with one blunt but effective rule: only one thread touches Python objects at a time, full stop.
# On a machine with 8 CPU cores, running 8 Python threads that are all doing
# pure CPU-bound computation (math, loops, data processing) will NOT run
# meaningfully faster than running them one after another on a single thread.
# The GIL ensures only one of those 8 threads is actually executing
# Python bytecode at any given moment — the other 7 are waiting their turn.The GIL Is Released During I/O — And That Changes Everything
Here is the detail that makes threading genuinely worthwhile despite the GIL: the GIL is released whenever a thread is waiting on I/O — a network request, reading a file from disk, waiting on a database query, or any operation where the CPU itself is idle, waiting for something external to respond. While one thread is blocked waiting on I/O, the GIL is free, and another thread can run Python bytecode during that wait.
This is the entire reason threading is worth learning at all in Python: for I/O-bound work — work that spends most of its time waiting on something external rather than computing — threads let your program make productive use of that waiting time instead of sitting idle. Downloading ten files from ten different URLs, one after another, spends nearly all its time waiting on network responses; running those ten downloads on ten threads lets nine of them wait on the network simultaneously while the tenth's response is being processed, dramatically cutting total wall-clock time even though only one thread executes Python bytecode at any given instant.
I/O-BOUND: most of the time is spent WAITING — network calls, file reads,
database queries, waiting on user input. Threading helps a lot here.
CPU-BOUND: most of the time is spent COMPUTING — number crunching, image
processing, parsing large amounts of data in pure Python.
Threading does NOT help here, because of the GIL — see Part 06.Creating, Starting, and Joining Threads
The standard library's threading module is the direct way to create and manage threads. The core pattern: create a Thread object pointing at a function to run, call .start() to begin running it concurrently, and call .join() to wait for it to finish before continuing.
import threading
import time
def download_file(name, seconds):
print(f"Starting download: {name}")
time.sleep(seconds) # stands in for a real network wait
print(f"Finished download: {name}")
thread1 = threading.Thread(target=download_file, args=("report.pdf", 2))
thread2 = threading.Thread(target=download_file, args=("invoice.pdf", 2))
start = time.time()
thread1.start()
thread2.start()
thread1.join() # wait for thread1 to finish
thread2.join() # wait for thread2 to finish
print(f"Total time: {time.time() - start:.1f} seconds")
# Roughly 2 seconds total — NOT 4 — because both downloads happened concurrently,
# each spending its time WAITING (time.sleep releases the GIL, just like real I/O does)Compare that to running the same two calls sequentially, with no threading at all — it would take roughly 4 seconds, the sum of both waits, since the second download cannot even begin until the first one fully completes.
import threading
def process_item(item):
print(f"Processing {item}")
items = ["order-1", "order-2", "order-3", "order-4"]
threads = [threading.Thread(target=process_item, args=(item,)) for item in items]
for t in threads:
t.start()
for t in threads:
t.join()
print("All items processed").join() on every thread you start, unless you deliberately want it to keep running independently in the background (a "daemon thread," set with thread.daemon = True before starting). Forgetting to join threads is a common source of programs that print "done" and exit while background work is technically still in progress, or that hang unexpectedly because the main program is waiting on threads it never properly tracked.When Threads Share Data, Things Can Go Wrong in New Ways
A race condition happens when two or more threads read and modify the same shared data at overlapping times, and the final result depends on the unpredictable timing of which thread happened to run when — a bug that can pass every test run and then fail unpredictably in production under real load. Here is a genuinely broken example, deliberately simple, to make the mechanism visible.
import threading
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # this looks like ONE step, but it isn't — see below
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 (4 threads × 100,000 increments each)
# Actual: some number LESS than 400,000, and it varies between runsWhy this happens: counter += 1 is not a single atomic operation at the bytecode level — it is actually three separate steps: read the current value of counter, add 1 to it, and write the new value back. The GIL guarantees only one thread runs Python bytecode at a time, but it can still switch which thread is running between those three steps. If thread A reads counter as 41, then gets paused before writing back 42, and thread B also reads counter as 41 and writes back 42, thread A's eventual write of 42 overwrites thread B's — one of the two increments is silently lost.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # only one thread can be inside 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) # 400,000, reliably, every timeA threading.Lock, used with the with statement exactly as shown, ensures that once one thread enters the locked block, every other thread trying to enter must wait until the first one leaves — turning the three-step read-modify-write sequence back into something that behaves as a single, uninterruptible operation from the perspective of other threads.
Real Parallelism for CPU-Bound Work
For CPU-bound work — the kind of workload the GIL genuinely prevents threading from speeding up — Python's multiprocessing module offers a different approach entirely: instead of multiple threads inside one process sharing one GIL, it spins up multiple entire separate processes, each with its own Python interpreter and its own GIL. Since each process is independent, they genuinely run in parallel across multiple CPU cores.
import multiprocessing
import time
def compute_heavy(n):
return sum(i * i for i in range(n))
numbers = [10_000_000] * 4
# Sequential — one after another
start = time.time()
results = [compute_heavy(n) for n in numbers]
print(f"Sequential: {time.time() - start:.2f}s")
# Parallel — using a process pool
if __name__ == "__main__":
start = time.time()
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(compute_heavy, numbers)
print(f"Parallel: {time.time() - start:.2f}s")
# On a 4+ core machine, the parallel version is meaningfully faster —
# genuinely close to 4x for this kind of embarrassingly parallel CPU work,
# because each process runs on its own core with its own GIL.if __name__ == "__main__": guard is not optional here on some platforms. Creating new processes involves re-importing your script in each new process (particularly on Windows and when using the spawn start method, which is the default on macOS since Python 3.8). Without the guard, that re-import can trigger the process-creation code again inside each new process, causing infinite recursive process spawning. Always put multiprocessing pool/process creation code inside this guard.The trade-off: separate processes do not share memory the way threads do. Passing data between processes involves serializing it (similar in spirit to the JSON serialization from Module 16) and sending it across a process boundary, which has real overhead. This is exactly why multiprocessing is worth its cost specifically for CPU-bound work — the parallel speedup has to be large enough to be worth the process-creation and data-passing overhead, which is not usually true for lightweight I/O-bound tasks.
Threading, Multiprocessing, or Async — Choosing Correctly
Putting Parts 01 through 06 together into a single decision framework you can actually apply:
Is the work I/O-bound (mostly WAITING on something external)?
→ threading, OR asyncio (Module 35 — often the better modern choice
for large numbers of concurrent I/O operations)
Is the work CPU-bound (mostly COMPUTING)?
→ multiprocessing — real parallelism across cores, bypassing the GIL entirely
Is the work a mix, or genuinely small-scale?
→ Start simple, sequential code. Introduce concurrency only once you've
actually measured that waiting time (I/O) or compute time (CPU) is a
real bottleneck — concurrency adds real complexity and new bug categories
(race conditions, deadlocks) that are not worth paying for prematurely.This module deliberately mentions asyncio without covering it in depth — that is the entire subject of Module 35, immediately next in this track. The short version worth knowing now: for a very large number of concurrent I/O-bound operations (hundreds or thousands of simultaneous network requests, for example), asyncio generally scales better than one OS thread per operation, because threads have real memory and scheduling overhead that async coroutines mostly avoid. Threading is still the right, simpler choice for a smaller, fixed number of concurrent I/O tasks, or when integrating with existing threading-based libraries.
time module (or the cProfile standard library profiler for anything more serious) can confirm whether a piece of code is actually I/O-bound or CPU-bound, and how much time it is really costing, before investing in threading or multiprocessing to speed it up. Concurrency bugs are hard enough to justify only introducing when there is a measured, real problem to solve.A Raleigh Data Team Picks the Wrong Tool, Then the Right One
A data analytics team builds a nightly job that processes a batch of 500,000 customer records: for each record, it runs a moderately expensive text-cleaning routine (pure Python string processing, no network calls) and writes the cleaned result to an output file. The job takes 40 minutes, and the team wants it faster.
The first attempt — threading, and why it barely helps
An engineer, having read that "threading speeds things up," rewrites the job to process records across 8 threads. The result: almost no improvement — maybe 2-3% faster, well within normal run-to-run variance. Confused, the team investigates and lands squarely on Part 02 of this module: the text-cleaning routine is CPU-bound, not I/O-bound. There is no waiting for the GIL to release during — it is pure computation, start to finish — so eight threads compete for the same single GIL and effectively run one at a time regardless of thread count.
The fix — multiprocessing, and the real result
The team rewrites the job using multiprocessing.Pool, splitting the 500,000 records into chunks distributed across 8 worker processes on an 8-core machine, following the exact pattern in Part 06. The job's runtime drops from 40 minutes to roughly 6 — close to the theoretical 8x improvement, since text cleaning on independent records is exactly the kind of "embarrassingly parallel" CPU-bound workload multiprocessing is built for.
The team's retrospective conclusion becomes a rule they now apply before reaching for either tool: identify whether the bottleneck is waiting or computing before picking threading or multiprocessing, not after. Threading is nearly free to try, which made it tempting to reach for first — but "nearly free to try" is not the same as "likely to help," and the GIL means it genuinely does not help pure CPU-bound work, no matter how many threads you throw at it.
Four Misconceptions About Threads and Processes
5 Interview Questions — With Complete Answers
Concurrency Mistakes Beginners Make Constantly
Errors You Will Hit With Threads and Processes — And Exactly Why
🎯 Key Takeaways
- ✓Concurrency (structuring work as overlapping tasks) and parallelism (genuinely executing tasks at the same instant) are different concepts — a single core can be concurrent but never parallel.
- ✓The GIL ensures only one thread executes Python bytecode at a time in CPython, which exists to keep reference-counting memory management safe without fine-grained locking everywhere.
- ✓The GIL is released during I/O waits — which is exactly why threading genuinely speeds up I/O-bound work (network calls, file reads) despite not enabling true parallel computation.
- ✓Threading provides little to no speedup for CPU-bound (pure computation) work, because the GIL prevents more than one thread from running Python bytecode at a time regardless of thread count.
- ✓A race condition happens when threads read/modify shared data at overlapping times without synchronization — use threading.Lock (via a "with" block) to make a read-modify-write sequence atomic.
- ✓multiprocessing achieves real parallelism by running separate OS processes, each with its own interpreter and GIL — the right tool for CPU-bound work, at the cost of serialization overhead for passing data between processes.
- ✓Always wrap multiprocessing.Pool/Process creation in if __name__ == "__main__": to avoid runaway process spawning on platforms using the spawn start method.
- ✓Decision framework: I/O-bound → threading or asyncio (Module 35). CPU-bound → multiprocessing. Measure before reaching for either — concurrency adds real complexity.
What comes next
Module 35 covers async Python — coroutines, the event loop, and asyncio — the modern approach to handling large numbers of concurrent I/O-bound operations that this module referenced but did not fully cover.
Module 35 → Async Python — asyncio, async/awaitDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.