multiprocessing: True Parallelism
Reviewed & published by Brayan K
The multiprocessing module runs your code in separate OS processes — each with its own Python interpreter and its own GIL — so CPU-heavy work can run on multiple cores at the same time.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Where threads take turns under the GIL, processes run genuinely in parallel. This is the tool you reach for when you need raw computational throughput.
What You'll Learn in This Lesson
- Launching work with multiprocessing.Process
- Why processes sidestep the GIL for CPU-bound work
- The all-important if __name__ == '__main__' guard
- Fanning out tasks with Pool.map
- Returning data through a Queue
- The modern ProcessPoolExecutor API
1 Process, the Guard, and Returning Data
A Process works much like a Thread — give it a target and args, then start() and join(). But because processes don't share memory, you can't just write to a shared dict. Instead you pass results back through a Queue. Notice the if __name__ == "__main__": guard — it's mandatory.
from multiprocessing import Process, Queue
def square(n, q):
q.put((n, n * n)) # send the result back
if __name__ == "__main__": # required guard!
q = Queue()
procs = [Process(target=square, args=(n, q)) for n in [2, 3, 4]]
for p in procs:
p.start()
for p in procs:
p.join()
results = sorted(q.get() for _ in procs)
print(results)
# [(2, 4), (3, 9), (4, 16)]from multiprocessing import Process, Queue
def square(n, q):
q.put((n, n * n))
if __name__ == "__main__":
q = Queue()
procs = [Process(target=square, args=(n, q)) for n in [2, 3, 4]]
for p in procs:
p.start()
for p in procs:
p.join()
# sort so output is deterministic regardless of finish order
results = sorted(q.get() for _ in procs)
print(results)
# ✅ Expected output:
# [(2, 4), (3, 9), (4, 16)]2 Pool.map: Fan Out CPU Work
A Pool manages a fixed set of worker processes. pool.map(func, iterable) splits the iterable across the workers, runs them in parallel on different cores, and returns the results in order. This is the cleanest way to parallelize CPU-bound work:
from multiprocessing import Pool
def heavy(n):
total = 0
for i in range(n * 1000): # a little real CPU work
total += i
return total
if __name__ == "__main__":
with Pool(processes=4) as pool:
results = pool.map(heavy, [1, 2, 3, 4])
print(results)
# [499500, 1999000, 4498500, 7998000]Because each heavy call runs in its own process with its own GIL, four cores can grind away simultaneously — something threads simply cannot do for this kind of pure-Python loop.
from multiprocessing import Pool
def heavy(n):
total = 0
for i in range(n * 1000):
total += i
return total
if __name__ == "__main__":
with Pool(processes=4) as pool:
results = pool.map(heavy, [1, 2, 3, 4])
print(results)
# ✅ Expected output:
# [499500, 1999000, 4498500, 7998000]3 The Modern API: ProcessPoolExecutor
concurrent.futures.ProcessPoolExecutor is the modern, unified interface. The best part: it shares the exact same API as ThreadPoolExecutor from the previous lesson. Swap one class name and your code goes from threads to processes:
from concurrent.futures import ProcessPoolExecutor
def cube(n):
return n ** 3
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=3) as pool:
results = list(pool.map(cube, range(1, 6)))
print(results)
# [1, 8, 27, 64, 125]from concurrent.futures import ProcessPoolExecutor
def cube(n):
return n ** 3
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=3) as pool:
results = list(pool.map(cube, range(1, 6)))
print(results)
# Expected output:
# [1, 8, 27, 64, 125]🎯 Your Turn: Fill in the Blanks
Replace each ___ so a Pool doubles every number. Remember the guard and which method maps a function over the list.
from multiprocessing import Pool
def double(n):
return n * 2
if ___ == "__main__": # hint: the special module name
with Pool(processes=2) as pool:
results = pool.___(double, [10, 20, 30]) # hint: maps func over list
print(results)
# Expected output:
# [20, 40, 60]⚠️ Common Errors
❌ Omitting the __main__ guard
# Top-level code that spawns processes — recursion bomb on Win/macOS
with Pool() as pool:
pool.map(work, data)✅ Wrap it in the guard
if __name__ == "__main__":
with Pool() as pool:
pool.map(work, data)❌ Expecting a shared variable to update
total = 0
def add(n):
global total
total += n # changes the CHILD's copy, not yours✅ Return the value and combine results
with Pool() as pool:
total = sum(pool.map(lambda n: n, data))❌ Using processes for tiny, fast tasks
If each task is trivial, process startup and pickling overhead can make multiprocessing slower than a plain loop. Reserve it for genuinely heavy, CPU-bound work.
🎯 Mini Challenge: Count Vowels in Parallel
Use a ProcessPoolExecutor to count the vowels in each word, build a word→count dictionary, and print the total number of vowels.
from concurrent.futures import ProcessPoolExecutor
def count_vowels(word):
return (word, sum(1 for c in word if c in "aeiou"))
if __name__ == "__main__":
words = ["banana", "apple", "sky", "queue"]
with ProcessPoolExecutor(max_workers=2) as pool:
counts = dict(pool.map(count_vowels, words))
print(counts)
print("Total vowels:", sum(counts.values()))
# Expected output:
# {'banana': 3, 'apple': 2, 'sky': 0, 'queue': 4}
# Total vowels: 9📚 Keep Learning
- • multiprocessing — Process-based parallelism
- • concurrent.futures.ProcessPoolExecutor
- • multiprocessing — Programming guidelines
Frequently Asked Questions
Lesson complete — you can use every CPU core!
You can launch a Process, return data through a Queue, fan work out with Pool.map, and use the modern ProcessPoolExecutor. You understand why processes sidestep the GIL for CPU-bound work — and you never forget the if __name__ == "__main__": guard.
Practice quiz
How does the multiprocessing module achieve true parallelism despite the GIL?
- It disables the GIL for all threads
- It runs everything on a single core faster
- Each process is a separate interpreter with its own GIL, so the OS can run them on different cores
- It converts processes into threads
Answer: Each process is a separate interpreter with its own GIL, so the OS can run them on different cores. Separate processes each have their own interpreter and GIL, so they truly run in parallel on multiple cores.
Why is the 'if __name__ == "__main__":' guard required for multiprocessing?
- Without it, spawning processes on Windows/macOS re-imports the script and recursively launches more processes
- It speeds up imports
- It is only needed for threads
- It silences warnings
Answer: Without it, spawning processes on Windows/macOS re-imports the script and recursively launches more processes. Process spawning re-imports your module; the guard ensures launch code runs only in the original program.
What does pool.map(func, iterable) return?
- A single combined value
- Results in random completion order
- Nothing; it only has side effects
- The results in the same order as the input iterable
Answer: The results in the same order as the input iterable. Pool.map splits work across workers but returns results ordered to match the input.
Because processes do NOT share memory, how do you get results back from a Process?
- By modifying a global variable
- Through a Queue (or similar IPC) that the process puts results into
- By printing them only
- Results are shared automatically
Answer: Through a Queue (or similar IPC) that the process puts results into. Each process has its own memory, so results are passed back via a Queue, pipe, or return value.
Multiprocessing is the BEST fit for which kind of workload?
- CPU-bound tasks like heavy math or image processing
- I/O-bound tasks that mostly wait
- Tasks that need shared mutable state
- Very tiny, trivial tasks
Answer: CPU-bound tasks like heavy math or image processing. Processes shine for CPU-bound work because they use multiple cores; threads are better for I/O-bound waiting.
Which modern class shares the same API as ThreadPoolExecutor but uses processes?
- multiprocessing.Manager
- asyncio.ProcessPool
- concurrent.futures.ProcessPoolExecutor
- threading.ProcessPool
Answer: concurrent.futures.ProcessPoolExecutor. ProcessPoolExecutor mirrors ThreadPoolExecutor's API, so you can switch engines by changing one class name.
Why might trying to update a shared global variable from a child process NOT work?
- Globals are read-only in Python
- The child modifies its own copy of the variable, not the parent's
- Multiprocessing deletes globals
- It always works
Answer: The child modifies its own copy of the variable, not the parent's. Processes don't share memory, so the child changes its own copy; you must return values and combine them.
When can multiprocessing actually be SLOWER than a plain loop?
- When each task is heavy and CPU-bound
- When you use a Pool
- When you use the __main__ guard
- When tasks are tiny and fast, so startup and pickling overhead dominates
Answer: When tasks are tiny and fast, so startup and pickling overhead dominates. Process startup and pickling cost can outweigh the work for trivial tasks, making it slower.
What does the pattern [Process(target=square, args=(n, q)) for n in nums] create?
- One process that loops over nums
- A list of Process objects, one per value of n
- A thread pool
- A Queue of results
Answer: A list of Process objects, one per value of n. It builds one Process per item; you then start() and join() each of them.
Why must data sent to and from worker processes be picklable?
- To compress it
- To encrypt it
- Because processes don't share memory, so arguments and results are serialized (pickled) and copied
- Because the GIL requires it
Answer: Because processes don't share memory, so arguments and results are serialized (pickled) and copied. Crossing process boundaries means data is pickled and copied, so non-picklable objects fail.
Continue this course
- Previous: Threading & the GIL
- Next: Thread-Safe Queues (queue) — Coordinate producers and consumers safely with queue.Queue
- Quick reference: Python cheat sheet
Frequently asked questions
How does multiprocessing get around the GIL?
Each process is a separate Python interpreter with its own memory and its own GIL. Because they're independent OS processes, the operating system can schedule them on different CPU cores simultaneously. So while threads share one GIL and take turns, processes truly run at the same time — which is exactly what CPU-bound work needs.
Why is the 'if __name__ == "__main__":' guard required?
On Windows and macOS, new processes are created by importing your script fresh. Without the guard, that re-import would launch the process-spawning code again, leading to infinite recursion of new processes. Putting the launch code inside 'if __name__ == "__main__":' ensures it only runs in the original program, not in the freshly imported children.
When should I use multiprocessing vs threading?
Use multiprocessing for CPU-bound work — heavy math, image processing, parsing large files — because it uses multiple cores. Use threading for I/O-bound work — network calls, disk reads — where you're mostly waiting. A rough rule: if your task pegs a CPU core, go multiprocessing; if it mostly waits, go threading.
What's the cost of using processes?
Processes are heavier than threads. Starting them takes time, and because they don't share memory, any data you send to them or get back must be pickled (serialized) and copied. For tiny, fast tasks the overhead can outweigh the benefit. Multiprocessing pays off when each task does enough real work to dwarf that startup and copying cost.