Python: Multiprocessing

When a task is CPU-intensive, multithreading cannot help because the GIL prevents parallel execution. Multiprocessing sidesteps the GIL entirely by running separate Python interpreter processes, each with its own memory space. This tutorial covers every tool you need: from creating individual processes to process pools, shared memory, inter-process communication, and synchronisation.

Why Multiprocessing Instead of Threading for CPU-bound Tasks

Python's threading module is genuinely useful for I/O-bound work, where threads spend most of their time waiting on network responses, disk reads, or database queries. The GIL releases during those waits, so other threads make progress. For CPU-bound work, however, the GIL means that only one thread executes Python bytecode at any moment, regardless of how many CPU cores the machine has. A program that uses four threads to compress files may actually run slower than a single-threaded version because of the contention and context-switching overhead around the GIL.

The multiprocessing module solves this by launching separate OS processes instead of threads. Each process has its own Python interpreter, its own GIL, and its own memory space. Four processes on a quad-core machine can genuinely run four CPU-bound tasks simultaneously. The trade-off is that sharing data between processes requires explicit mechanisms, because they do not share memory by default.

Concurrency Tool Selection

Choosing the wrong tool is the most common source of performance problems in Python concurrency code.

  • threading: I/O-bound work (network requests, file reads, database queries). The GIL releases during I/O waits.
  • multiprocessing: CPU-bound work (number crunching, image processing, data transformation). Achieves true parallelism.
  • asyncio: high-volume I/O with thousands of concurrent operations on a single thread. Lowest overhead per connection.
  • concurrent.futures: high-level wrapper over both threading and multiprocessing with a consistent interface.

Threading vs Multiprocessing on a CPU-bound Task

Python

The same workload run with threads and then with processes, demonstrating the real-world difference.

The multiprocessing Module

Python's multiprocessing module is designed to feel similar to threading. Many of the concepts carry over: you create a Process object, give it a target function and arguments, call start(), and then join() to wait for it. The module also provides process pools, shared memory primitives, inter-process queues and pipes, manager objects, and synchronisation primitives that work across process boundaries.

One important implementation note: on Windows and macOS (using the default "spawn" start method), Python serialises (pickles) the target function and its arguments to send them to the child process. This means the target function and its arguments must be picklable. Lambda functions and locally defined functions cannot be pickled, so they must be defined at the module level. On Linux, the default start method is "fork", which copies the parent process's memory directly, so pickling is not needed for the initial arguments, but it brings its own complications around resources held by the parent.

Windows Requirement: the if __name__ == '__main__' Guard

On Windows, child processes import the main module to bootstrap themselves. Without the guard, the import triggers another round of process creation, looping infinitely. Always wrap the code that spawns processes in this block.

  • if __name__ == '__main__': is not optional on Windows when using multiprocessing.
  • This guard is harmless on Linux/macOS, so including it makes code portable.
  • Module-level code outside this guard runs in every child process as well as the parent.

Creating and Starting Processes

Creating a process follows the same pattern as creating a thread: instantiatemultiprocessing.Process with a target callable and optional args orkwargs, then call start(). The join() method blocks the calling process until the child finishes. You can also check whether a process is still alive withp.is_alive(), access its exit code via p.exitcode (available after it finishes), and give it a name with the name parameter.

Because child processes do not share memory with the parent, return values from the target function are not automatically available in the parent. You must use a Queue,Pipe, shared memory object, or a process pool to get results back. This is a common source of confusion for developers coming from threading.

Creating and Starting a Process

Python

Basic process creation, naming, joining, and reading the exit code.

Subclassing Process

Python

The subclass approach for processes that carry state or need extra methods.

Process vs Thread: Key Differences

Understanding the practical differences between Process and Thread helps you choose the right tool and avoid wasting time debugging communication issues that arise from choosing the wrong one.

Process vs Thread at a Glance

The table below covers the most important dimensions you need to reason about when choosing.

  • Memory: Threads share the parent process memory space. Processes have separate memory; sharing requires explicit IPC.
  • GIL: Threads are limited by the GIL for CPU-bound Python code. Processes bypass the GIL entirely.
  • Creation cost: Threads are cheap to create (microseconds). Processes are heavier (milliseconds) due to OS-level forking or spawning.
  • Communication: Threads communicate via shared variables (with locks). Processes use Queue, Pipe, shared memory, or Manager objects.
  • Fault isolation: A crashing thread can corrupt the entire process. A crashing child process does not affect the parent.
  • Use case: Use threads for I/O-bound concurrency. Use processes for CPU-bound parallelism.

Memory Isolation Demonstrated

Python

A thread modifying a shared variable is visible to the parent; a process modifying its copy is not.

Process Pools: Pool.map() and Pool.starmap()

Creating one process per task is rarely what you want. OS processes are heavyweight, and spawning hundreds of them would overwhelm the machine. A multiprocessing.Pool solves this by maintaining a fixed number of worker processes. Tasks are assigned to idle workers as they become available. The pool handles all the bookkeeping of distributing work, collecting results, and reusing processes for multiple tasks.

Pool.map(func, iterable) applies a function to every item in an iterable and returns the results in the same order as the input. It blocks until all results are ready. Pool.starmap(func, iterable_of_tuples) works the same way, but it unpacks each tuple as separate positional arguments to the function, which is useful when the function takes more than one argument.

Pool.map(): Parallel Data Processing

Python

Applying a CPU-intensive transformation to a large list of items.

Pool.starmap(): Multi-Argument Functions

Python

When each task takes more than one argument, starmap unpacks tuples for you.

Pool.map_async() and Pool.apply_async()

Python

Non-blocking submission for tasks where you need to do other work while waiting.

Shared Memory: Value and Array

Most of the time you communicate between processes by sending messages through a queue or pipe. But for simple numeric data that processes need to update at high frequency, message-passing has overhead. multiprocessing.Value and multiprocessing.Arraycreate objects backed by shared memory that multiple processes can read and write directly. No serialisation is involved, so they are fast.

Value(typecode, initial_value) creates a single shared scalar. Array(typecode, size_or_initializer) creates a fixed-length shared array. The typecode uses the same single-character codes as Python's array module: 'i' for signed integer,'d' for double, 'f' for float, and so on. Both objects have an internal lock accessible as .get_lock(), but you should acquire it explicitly when you need atomic read-modify-write operations to avoid race conditions.

Value: Shared Scalar Counter

Python

Multiple processes incrementing a shared integer safely with a lock.

Array: Shared Numeric Array

Python

Distributing work across processes that each write to a different slice.

Queue and Pipe for Inter-process Communication

When you need to pass Python objects between processes, rather than raw numeric data, the two main tools are multiprocessing.Queue and multiprocessing.Pipe. Both serialise objects with pickle to move them across the process boundary.

A Queue is multi-producer/multi-consumer: many processes can put items in and many can get items out, making it the natural choice for task queues and result collection. A Pipe creates two connected Connection objects. Each end can send and receive, but a pipe is optimised for one-to-one communication because there are only two endpoints. For a simple producer-consumer pair a pipe is faster and more direct; for fan-out/fan-in patterns with many workers, a queue is cleaner.

Queue: Fan-out Task Queue with Result Collection

Python

A work queue feeds multiple workers; results flow back through a result queue.

Pipe: Direct One-to-One Communication

Python

A child process sends computed results back to the parent via a pipe connection.

Manager Objects

multiprocessing.Value and Array only support simple C-style numeric types. When you need processes to share a Python list, dictionary, namespace, or other complex object, you use a Manager. A manager launches a separate server process that owns the data and exposes proxy objects. All other processes interact with the data by sending method calls through the proxy, which the manager server handles and applies to the real object.

The proxy approach means manager objects work across machines (with the right connection settings), not just across local processes. The downside is that every operation involves inter-process communication through the manager server, which adds latency compared to direct shared memory. For frequent, fine-grained updates, Value or Arraywith a lock is faster. For high-level objects that are updated less frequently, managers are the right choice.

Manager: Shared List and Dictionary

Python

Multiple processes appending to a shared list and updating a shared dictionary.

When to Use Each Shared Data Tool

Each option has a different cost-benefit trade-off depending on the type and frequency of data sharing.

  • Queue / Pipe: best when processes exchange discrete messages or stream items. Clean ownership model; no locking needed by you.
  • Value / Array: best for high-frequency numeric updates. Direct shared memory, minimal overhead, but you must manage locks manually.
  • Manager list / dict: best when you need rich Python types across processes and update frequency is moderate.
  • No sharing: often the cleanest design. Pass inputs at start, collect outputs at the end via Pool.map() or a result queue.

concurrent.futures.ProcessPoolExecutor

multiprocessing.Pool is powerful, but its API predates modern Python idioms.concurrent.futures.ProcessPoolExecutor provides the same underlying capability through a higher-level interface that mirrors ThreadPoolExecutor. If you have used the threading tutorial's ThreadPoolExecutor examples, the pattern here will look identical: you swap the class name and get parallelism instead of concurrency.

The key concept is the Future object returned by executor.submit(). A future represents a computation that may not have completed yet. You can call future.result()to block until it finishes and get the return value, or pass the future toconcurrent.futures.as_completed() to process results in the order they finish rather than the order they were submitted. Exceptions raised inside the worker function are re-raised when you call future.result(), which makes error handling straightforward.

ProcessPoolExecutor with submit() and map()

Python

Parallel computation with future-based result retrieval.

Exception Handling with ProcessPoolExecutor

Python

Exceptions in worker processes are captured and re-raised on future.result().

Synchronisation: Lock and Semaphore

Even though processes have separate memory, you still need synchronisation when they share a resource: a Value, an Array, a file, or a database connection pool. The multiprocessing module provides Lock, RLock, Semaphore,Event, Condition, and Barrier, which mirror their threadingequivalents but work across process boundaries. You must pass them to child processes as constructor arguments; they cannot be created inside the child and shared with the parent because the child has a separate memory space.

A critical point: you cannot use threading.Lock to protect shared state across processes. Each process would get its own independent lock object, providing no mutual exclusion. Always use multiprocessing.Lock when coordinating multiple processes.

multiprocessing.Lock: Protecting a Shared Counter

Python

Without the lock, concurrent increments produce a race condition; with it, the result is always correct.

multiprocessing.Semaphore: Limiting Concurrent Access

Python

Capping how many child processes can write to a file simultaneously.

multiprocessing.Event: Signalling Between Processes

Python

Worker processes waiting for a start signal from the main process.

Summary: Synchronisation Primitive Selection

The right choice depends on exactly what coordination you need between processes.

  • Lock: mutual exclusion. Only one process at a time can enter the critical section.
  • RLock: mutual exclusion where the same process may re-acquire the lock it already holds.
  • Semaphore: bounded concurrency. Allow up to N processes in a section simultaneously.
  • Event: signalling. One process flags an event; others wait for it.
  • Condition: conditional waiting. Processes wait for a state change announced by another.
  • Barrier: phase synchronisation. All processes must reach a checkpoint before any continues.

Quiz - Test Your Knowledge

Ten questions covering the GIL and why it matters, the distinction between processes and threads, process pools, shared memory primitives, IPC options, Manager objects, the ProcessPoolExecutor API, and synchronisation across process boundaries. Several questions hinge on subtle but important distinctions, so read each option carefully.

Knowledge Check

1. Why does multiprocessing outperform multithreading for CPU-bound tasks in CPython?

2. What is the most important practical difference between a Process and a Thread in Python?

3. What does Pool.map() do?

4. What is the difference between Pool.map() and Pool.starmap()?

5. What is the purpose of multiprocessing.Value and multiprocessing.Array?

6. Which IPC primitive is best suited for streaming a sequence of items from one process to another in a pipeline fashion?

7. What is the main advantage of multiprocessing.Manager over Value and Array?

8. What is the key difference between a multiprocessing.Queue and a multiprocessing.Pipe?

9. What is the primary benefit of ProcessPoolExecutor over the multiprocessing.Pool class?

10. Why must you use multiprocessing.Lock instead of threading.Lock when protecting shared state across processes?