Why this matters
The last lesson showed that threads do not speed up CPU-heavy pure-Python code because of the GIL. Yet many real jobs are exactly that: crunching numbers, parsing thousands of files, resizing images, running simulations. The standard answer in Python is to use several processes, each with its own interpreter and its own GIL. This lesson covers how that works, what it costs, and the high-level concurrent.futures interface that makes both thread and process pools easy and interchangeable.
Processes versus threads
A process has its own memory space. Two processes running Python have two separate interpreters, two heaps of objects and two GILs, so the operating system can run them on two cores at the same time.
main process worker process 1 worker process 2
+-------------------+ pipe +------------------+ +------------------+
| interpreter + GIL | <-------> | interpreter+GIL | | interpreter+GIL |
| your objects | pickled | its own objects | | its own objects |
+-------------------+ bytes +------------------+ +------------------+
The price of isolation:
- Starting a process costs milliseconds and memory (a whole interpreter), far more than a thread.
- Communication goes through pipes. Arguments and results are pickled (serialized to bytes with the
picklemodule), sent, and unpickled on the other side. Big arguments cost time to copy; some objects (open files, locks, lambdas) cannot be pickled at all. - No shared state by default: a worker that modifies a global changes its own copy. Results must be returned.
Rule of thumb: use processes for CPU-bound work made of reasonably big chunks with small inputs and outputs.
concurrent.futures
The concurrent.futures module gives one interface for both kinds of pools:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
An executor owns a pool of workers. You hand it work and get back results.
map
executor.map(func, iterable, ...) is the parallel version of the built-in map:
from concurrent.futures import ProcessPoolExecutor
def square(n):
return n * n
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=2) as executor:
results = list(executor.map(square, range(6)))
print(results)
[0, 1, 4, 9, 16, 25]
Key facts:
- Results come back in input order, regardless of which task finishes first.
- With several iterables,
funcgets one argument from each:executor.map(pow, [2, 3], [5, 2])computespow(2, 5)andpow(3, 2). - The
withblock shuts the pool down at the end, waiting for running tasks to finish. - If a task raises an exception, it is re-raised in the main process when you reach that result.
- For many tiny tasks, pass
chunksize=(process pools only) so each round trip carries a batch.
submit and futures
executor.submit(func, *args) schedules one call and immediately returns a Future: an object standing for a result that will exist later.
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def slow(n):
time.sleep(0.1 * n)
return n * 10
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {executor.submit(slow, n): n for n in [3, 1, 2]}
for fut in as_completed(futures):
print(futures[fut], "->", fut.result())
1 -> 10
2 -> 20
3 -> 30
fut.result() waits if necessary, then returns the value or re-raises the task's exception. as_completed yields futures in completion order, useful for progress reporting. Other methods: fut.done(), fut.cancel(), fut.exception().
Because both executors share the interface, switching between threads and processes is often a one-word change, which makes it easy to measure which works better.
The main guard and start methods
How does a worker process get your function? It depends on the start method:
- fork (the default on Linux in Python 3.13): the OS duplicates the running process. The child starts with a copy of everything already imported and defined. Fast, but copying a process that has threads running can deadlock, so Python 3.14 changes the Linux default to
forkserver. - spawn (default on macOS and Windows): a fresh interpreter starts and imports your main module to find the function by name.
- forkserver: a clean helper process is started early and forks workers on request.
With spawn and forkserver, importing your main script in the worker runs its top-level code again. If that top-level code creates a pool, each worker would try to create a pool, and so on. Python detects this and raises an error. The cure is the guard you met in the modules lesson:
def work(x): # top level: workers can import it
...
if __name__ == "__main__":
# only runs in the original process, not when a worker imports this file
with ProcessPoolExecutor() as ex:
...
Always write it, even on Linux, so your code works everywhere and on future Python versions. Also define worker functions at the top level of a module: functions are pickled by name (__main__.work), so lambdas and nested functions cannot be sent.
multiprocessing
concurrent.futures is built on the lower-level multiprocessing module, which mirrors the threading API and adds tools for when you need more control:
import multiprocessing as mp
def worker(q, n):
q.put(n * n)
if __name__ == "__main__":
q = mp.Queue()
procs = [mp.Process(target=worker, args=(q, n)) for n in range(3)]
for p in procs: p.start()
for p in procs: p.join()
print(sorted(q.get() for _ in range(3)))
[0, 1, 4]
mp.Queue pickles items through a pipe; mp.Pool is an older pool API; mp.Value, mp.Array and multiprocessing.shared_memory allow genuinely shared memory for simple data. Note sorted(...): results from a queue arrive in completion order, which varies.
How many workers?
ProcessPoolExecutor() defaults to the number of CPUs (os.process_cpu_count() in 3.13). More processes than cores just adds switching and memory overhead for CPU-bound work. Thread pools default to min(32, cpus + 4), since threads mostly wait. Measure with time.perf_counter() instead of guessing: for small jobs, the startup and pickling overhead of processes can make the parallel version slower than the plain loop.
Common mistakes
- No
__main__guard: with the spawn method you getRuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. - Lambdas or nested functions as tasks in a process pool:
PicklingError: Can't pickle <function <lambda> ...>: attribute lookup <lambda> on __main__ failed. - Expecting globals to update: a worker's changes to a global variable happen in its copy. Return values instead.
- Huge arguments: sending a 1 GB list to each task means pickling 1 GB each time. Send file names or indices and let workers load their own data.
- Using processes for I/O-bound work: threads (or asyncio, next lesson) are cheaper.
Recap
- Processes have separate memory and separate GILs, so they run CPU-bound Python in parallel, at the cost of startup time and pickling all data passed between them.
ThreadPoolExecutorandProcessPoolExecutorshare one API:map(results in input order),submitreturning aFuture,as_completedfor completion order.- Guard pool-creating code with
if __name__ == "__main__":and define task functions at module top level. multiprocessingoffersProcess,Queue,Pooland shared memory for finer control.
Going further: read the "Programming guidelines" section of the multiprocessing docs, then time count_primes from the exercise over larger ranges with 1, 2 and 4 workers on your own machine.