skip to content

Multiprocessing

When work is CPU-bound, the way around the GIL is separate processes. Interviewers expect Process and Pool but care more about the costs: start methods, pickling arguments, nothing shared by default.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

20

Why doesn't a global list mutated inside a multiprocessing.Process appear changed in the parent?

level: juniorimportance: must knowfreq 70%

answer

  1. Two processes, two address spaces
  2. The child mutates a different object
  3. Copy-on-write diverges on first write
  4. Sharing must be named, not assumed
  5. Queue, Pipe, Value/Array, shared_memory, Manager

basics

~20 s

Each multiprocessing.Process is a separate OS process with its own memory, so the child mutates its own copy and nothing flows back. Sharing needs an explicit channel: a Queue, a Pipe, Value/Array, a shared memory block or a Manager proxy.

solid answer

~40 s

Threads share one address space; processes do not. When `multiprocessing.Process` starts a child, that child either re-imports your module (under the `spawn` and `forkserver` start methods) or gets a copy-on-write snapshot of the parent's pages (under `fork`). Either way the child's `items` is a *different* list object living in different memory, so `items.append(...)` is invisible to the parent, and even under `fork` the first write breaks the copy-on-write page and diverges. To actually move data you must name a mechanism: `multiprocessing.Queue` or `multiprocessing.Pipe` to send objects by copy, `multiprocessing.Value`/`Array` for a small block of shared C-typed memory, `multiprocessing.shared_memory.SharedMemory` for a raw shared byte buffer, or `multiprocessing.Manager()` for proxied `list`/`dict` objects served by a helper process. Each of those has a different copy cost.

code

python · 13 lines
python
import multiprocessing as mp

items = []

def add():
    items.append("child")
    print("in child:", items)

if __name__ == "__main__":
    p = mp.Process(target=add)
    p.start()
    p.join()
    print("in parent:", items)  # []

go deeper

for a junior

Be ready to state the core fact in one sentence: separate processes have separate memory, so a global changed in the child is invisible to the parent. Know that a Queue is the usual way to get a result back.

for a middle

Explain the mechanics: what the child inherits under fork versus what it re-imports under spawn and forkserver, why copy-on-write diverges on first write, and the four transports available with their rough copy costs.

for a senior

Show that you design for it. Name which transport carries each piece of state in a real pipeline, and diagnose the silent-data-loss shape where a worker mutates a global that nobody ever reads back.

for a principal

Own the tradeoff: whether the workload justifies process isolation at all given the serialization tax, and whether shared mutable state across processes should exist rather than being replaced by returning results and aggregating in one place.

### The one fact everything else follows from A thread shares its parent's address space; a *process* does not. `multiprocessing.Process` creates a real operating-system process, and the operating system gives it a private virtual address space. A module-level name like `items` is not one object seen by two processes — after the child starts there are two `items` lists at two addresses, and mutating either one tells the other nothing. This surprises people because the code *looks* like the threading code that works. With `threading.Thread`, `items.append("x")` in the worker really is visible in the main thread, because both run inside one interpreter in one address space. Swap in `multiprocessing.Process` and the identical line silently does nothing useful. ### What the child actually starts with How the child gets its initial state depends on the start method, and the two families behave differently enough to be worth knowing: * **`spawn` and `forkserver`** — the child is a fresh interpreter that **imports your module again**. Module-level code runs a second time, so `items` is rebuilt as a brand-new empty list. Anything the parent computed after import time simply is not there unless it was pickled and passed as an argument. This is why a target function and its arguments have to be picklable, and why module-level code needs an `if __name__ == "__main__":` guard. * **`fork`** — the child begins as a duplicate of the parent, so `items` *starts out* holding whatever the parent had. That is a trap, not a feature: the pages are **copy-on-write**, so the first mutation gives the child its own private copy of the page and the two diverge from that instant. Reads look shared; writes never are. On Python 3.14 the default start method on Unix other than macOS is `forkserver`; macOS and Windows use `spawn`. `fork` must be asked for explicitly. So on a modern default install the child usually re-imports and starts from a clean module state. A further wrinkle: even *reading* under `fork` is not free. CPython touches an object's reference count on almost every access, and the refcount lives in the object header, so merely iterating a large parent list in the child writes to those pages and copies them. "Copy-on-write means my big read-only dict is free" is wrong in CPython. ### The mechanisms that do share Once you accept that nothing is shared implicitly, sharing is a choice among four shapes: 1. **Message passing by copy** — `multiprocessing.Queue`, `multiprocessing.SimpleQueue`, or the pair of connection objects from `multiprocessing.Pipe`. Objects are serialized in the sender, written to an OS pipe or socket, and rebuilt in the receiver. Nothing is shared; a *copy* arrives. This is the default answer and the right one for results, work items and events. 2. **Shared C-typed memory** — `multiprocessing.Value` and `multiprocessing.Array` allocate a small block of memory mapped into every child, holding one C value or a fixed-length array of them. Real sharing, no copy, but only for machine types, and you must synchronize writes. 3. **A raw shared byte block** — `multiprocessing.shared_memory.SharedMemory` maps a named region that any process can attach to by name and read or write through a `memoryview`. Zero-copy for large buffers; you own its lifetime and its layout. 4. **A proxied server** — `multiprocessing.Manager()` starts a helper process that owns real `list`, `dict`, `Namespace`, `Lock` and similar objects, and hands out proxies. The proxy makes the code look like ordinary Python, but every method call is a round trip to that process. ### The mental model to carry into the interview Say it as a rule: **in multiprocessing, state does not leak — it is transported.** Then, for any variable in a proposed design, ask *which* of the four transports carries it and what that costs. A worker computing a summary should return it (queue or pool result); a large read-mostly buffer belongs in shared memory; a small counter belongs in `Value` under its lock; a convenient shared dict for coordination is what a `Manager` proxy is for, at proxy-round-trip prices. The corresponding anti-pattern is a global that a child mutates and a parent later reads. It does not raise, it does not warn, and under `fork` it can even look plausible in a small test because the pre-fork contents are there. It just quietly loses every update.

  • Under the fork start method the child can already see the parent's data. Why is that not real sharing?
    Because the pages are copy-on-write. The child starts with the same physical pages mapped read-only-ish, and the first write to a page gives the child a private copy; from then on the two processes diverge. Reads can look shared, writes never propagate in either direction. In CPython even reading is not free, since touching an object updates its reference count and dirties the page.
  • If you need a shared counter across four worker processes, which mechanism do you reach for and why?
    `multiprocessing.Value` with its default lock, because a counter is a single machine integer and belongs in shared C-typed memory rather than in a serialized message. A `Manager` proxy would also work and is more convenient, but every increment becomes a round trip to the manager process. Better still, have each worker count locally and send one total back through a queue, so the shared write happens once per worker.
  • Why must the target function and its arguments be picklable at all?
    Because under `spawn` and `forkserver` there is no memory to inherit: the parent serializes the callable reference and the argument tuple and writes them down a pipe to a fresh interpreter, which reconstructs them. That is why a lambda, a local closure, an open socket or a `threading.Lock` cannot be passed, and why the target usually has to be a module-level function reachable by import.

Threads are two people editing one shared document; processes are two people who were each handed a photocopy. Marking up your photocopy never changes anyone else's.

saying these in an interview costs you the question

  • Thinking processes share globals the way threads do
  • Claiming copy-on-write makes a large read-only structure free in CPython
  • Assuming a mutation in the child propagates back after join
  • Confusing multiprocessing.Queue with an in-process queue.Queue
  • Believing a passed object is shared rather than copied

context

open as a page

Why does multiprocessing.Pool refuse a lambda as its worker function?

level: juniorimportance: must knowfreq 65%

basics

~20 s

A pool sends the function to its worker processes by pickling it, and pickle stores a function only as a module-plus-qualified-name reference. A lambda has no importable name, so the send fails. Use a module-level function instead.

open as a page

What do multiprocessing.Process.start() and join() do?

level: juniorimportance: must knowfreq 65%

basics

~20 s

start() launches a new operating-system process that runs the target callable; join() blocks the caller until that child has exited and reaps it. Calling run() instead executes the work in the current process and spawns nothing.

open as a page

Why does multiprocessing code need an `if __name__ == "__main__":` guard under spawn?

level: juniorimportance: must knowfreq 70%

basics

~20 s

Under the spawn start method the child launches a fresh interpreter and re-imports your main module to reach the target function. Without the guard, the module-level code that launched the process runs again in the child, so CPython raises RuntimeError.

open as a page

Why does multiprocessing.Queue deadlock when you join the child before draining it?

level: middleimportance: must knowfreq 55%

basics

~20 s

A multiprocessing.Queue is a bounded OS pipe fed by a background thread in the writing process. If the pipe fills, that thread blocks until the reader drains it, and the child cannot exit — so the parent's join() waits forever. Drain first, then join.

open as a page

How do multiprocessing.Pool's map, imap and imap_unordered differ?

level: middleimportance: must knowfreq 55%

basics

~20 s

map() blocks and returns a list of every result in input order. imap() returns a lazy iterator that yields results in input order as they arrive. imap_unordered() yields each result the moment it is ready, in completion order.

open as a page

What distinguishes multiprocessing's fork, spawn and forkserver start methods, and which is default where?

level: middleimportance: must knowfreq 60%

basics

~20 s

fork clones the parent's memory, so it is fast but inherits threads and held locks. spawn starts a clean interpreter and re-imports, so it is slow but safe. forkserver forks each worker from a small single-threaded server, combining most of both.

open as a page

How does an exception raised inside a multiprocessing.Pool worker reach the parent?

level: middleimportance: must knowfreq 60%

basics

~20 s

The worker catches the exception, pickles it together with a text rendering of the child's traceback, and sends it back. The parent re-raises it out of AsyncResult.get(), or hands it to error_callback if you supplied one.

open as a page

What happens to pending futures when a ProcessPoolExecutor worker is killed?

level: seniorimportance: must knowfreq 50%

basics

~20 s

Every pending and running future is completed with BrokenProcessPool, and every later submit raises it too. The executor is permanently unusable, so recovery means building a new one and resubmitting the work that did not finish.

open as a page

What does a negative multiprocessing.Process.exitcode mean?

level: juniorimportance: should knowfreq 40%

basics

~20 s

A negative exitcode of -N means the child was killed by signal N and ran no cleanup. None means it has not finished, 0 means a clean exit, and a positive number is the status the child chose.

open as a page

Why does counter.value += 1 on a multiprocessing.Value race, and how do you fix it?

level: middleimportance: should knowfreq 45%

basics

~10 s

counter.value += 1 is a read, an add and a write, and another process can land between them, so increments are lost. Hold the value's own lock around the whole read-modify-write with with counter.get_lock():.

open as a page

How do you make an object holding a threading.Lock picklable for a worker process?

level: middleimportance: should knowfreq 40%

basics

~20 s

Define getstate on the class to drop the unpicklable attribute from the state it returns, and setstate to rebuild that attribute after the object is restored in the child. Only the data needs to travel; the lock is recreated locally.

open as a page

What does the chunksize argument to multiprocessing.Pool.map control?

level: middleimportance: should knowfreq 40%

basics

~20 s

chunksize is how many items of the iterable are batched into a single task handed to one worker. Larger chunks mean fewer dispatches and less per-item overhead; smaller chunks spread work more evenly and shorten the idle tail.

open as a page

When does multiprocessing.shared_memory beat a Manager proxy for a 200 MB invoice-bitmap payload?

level: seniorimportance: should knowfreq 35%

basics

~20 s

When the payload is large and read-mostly. A Manager proxy pickles a round trip to a server process on every access, so a 200 MB bitmap is copied repeatedly; multiprocessing.shared_memory.SharedMemory maps one block that workers read in place, at the price of owning its lifecycle and layout yourself.

open as a page

Why did multiprocessing.Pool slow down a translation-memory updater that ships each record's full context?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Every argument and every result is pickled, piped and unpickled, so a task that ships a large context but does milliseconds of work pays more at the boundary than it saves. Send small handles and batch the records.

open as a page

Why can multiprocessing's fork start method deadlock a child of a threaded service?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Forking duplicates only the calling thread but copies every lock exactly as it was. A lock another thread held at fork time arrives in the child locked forever, with no owner left to release it, so the child hangs.

open as a page

When should a library use multiprocessing.get_context() instead of set_start_method()?

level: middleimportance: nice to knowfreq 25%

basics

~10 s

multiprocessing.set_start_method() mutates process-global state and is meant to be called once by the application. A library should call multiprocessing.get_context("spawn") and use that context's Process, Pool and Queue, leaving everyone else's choice untouched.

open as a page

Why can a custom exception raised in a multiprocessing worker fail to reach the parent?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

A worker's exception is pickled, and rebuilding it calls the class with its args tuple. If a custom init takes parameters it never forwards to the base class, reconstruction raises TypeError and the real failure is lost.

open as a page

Why can a with-block around multiprocessing.Pool lose pending results?

level: seniorimportance: nice to knowfreq 25%

basics

~10 s

Pool.exit calls terminate(), not close() then join(). Leaving the with-block while apply_async or map_async submissions are still outstanding kills the workers immediately, so those tasks never run and their AsyncResult objects are never fulfilled.

open as a page

When is maxtasksperchild on a multiprocessing.Pool worth setting?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Set it when workers accumulate what they should not keep across tasks — leaked memory or stale per-process cached state. The worker retires after that many tasks and a fresh one replaces it; each recycle costs a process start.

open as a page