Parallelism with processes for CPU-bound work

Split heavy computation across worker processes that run on separate cores.

Python
from multiprocessing import Pool


def count_primes(limit):
    return sum(1 for n in range(2, limit) if all(n % d for d in range(2, int(n ** 0.5) + 1)))


if __name__ == "__main__":
    limits = [20000, 20000, 20000, 20000]
    with Pool(2) as pool:
        results = pool.map(count_primes, limits)
    print(results)
    print(results == [count_primes(limit) for limit in limits])
Output
[2262, 2262, 2262, 2262]
True

Explanation

CPU-bound work, such as number crunching, is limited by processor speed rather than waiting. To run it in parallel you need multiple cores doing useful work at once. In the default CPython build the global interpreter lock lets only one thread execute Python bytecode at a time, so threads cannot speed this work up. The multiprocessing module sidesteps the GIL by starting separate worker processes, each with its own interpreter and memory.

Pool.map splits the input list among the workers, runs the function in each, and returns the results in input order. Because processes do not share memory, arguments and results are copied between them by pickling, so the function must be defined at the top level of a module and the data must be picklable. That copying has a cost, so parallelism pays off when the computation per task is much larger than the cost of sending its data. Recent CPython versions also offer an optional free-threaded build without the GIL, but multiprocessing remains the portable approach.

The if __name__ == "__main__" guard matters: on Windows and macOS child processes are started by importing the main module again, and without the guard each child would try to start more children. The program counts primes below 20000 in four tasks and checks the pool's answers against a plain sequential computation. It does not assert a speed-up, since that depends on the number of available cores.

Explore the dry run
Guided walkthroughReady
1from multiprocessing import Pool
2
3
4def count_primes(limit):
5 return sum(1 for n in range(2, limit) if all(n % d for d in range(2, int(n ** 0.5) + 1)))
6
7
8if __name__ == "__main__":
9 limits = [20000, 20000, 20000, 20000]
10 with Pool(2) as pool:
11 results = pool.map(count_primes, limits)
12 print(results)
13 print(results == [count_primes(limit) for limit in limits])

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
A top-level function can be pickled and sent to worker processes.—
—
The guard stops child processes from re-running the pool setup when they import this module.—
—
Two worker processes share the four tasks. Results come back in input order.4 tasks across 2 workers
—
There are 2262 primes below 20000, the same for each identical task.4 tasks across 2 workers
[2262, 2262, 2262, 2262]
The parallel answers match a sequential computation.4 tasks across 2 workers
[2262, 2262, 2262, 2262]
True

Cost and common mistakes

Ideal speed-up is the number of cores p, giving about T/p plus process start-up and data-copy overhead. Real speed-up is lower because of that overhead and any serial part of the work.

  • Without the __main__ guard, spawn-based platforms can fail or start processes recursively.
  • Arguments and results are copied between processes. Sending large data to tiny tasks can make the parallel version slower.
  • Processes do not share variables. Changes made in a worker are not visible in the parent unless you return them.

Try it yourself

Why is the prime count the same for all four tasks?

Reveal answer and reasoning

Each task receives the same limit, 20000, and count_primes is a pure function, so every worker computes the same answer.

Further reading