๐Ÿ›‘ Pattern 9: Shutdown & Errors

What actually happens when a child raises, when the OS kills it, and how to stop workers on purpose without corrupting everything they touched. The surprises here bite everyone new to processes.

The Big Surprise: A Raw Process Exception Does Not Reach You

Coming from most languages, you expect an unhandled exception in a child to blow up your program. It doesn't. A child Process runs a separate interpreter: when its target raises, the traceback prints to the child's stderr and the child exits with a non-zero exitcode. The parent sails on, oblivious โ€” unless you check exitcode yourself.

import multiprocessing as mp

def worker():
    raise ValueError("boom")          # prints a traceback in the CHILD, then the child dies

if __name__ == "__main__":
    p = mp.Process(target=worker)
    p.start()
    p.join()
    # The parent never saw the exception. You must inspect the exit code.
    print("exitcode:", p.exitcode)    # -> 1  (0 == clean, >0 == raised, <0 == killed by signal N)
    if p.exitcode != 0:
        raise RuntimeError(f"child failed with exitcode {p.exitcode}")
Trap: exitcode is None until the process has finished โ€” so always join() (or check p.is_alive()) before reading it. A positive code means the target raised; a negative code -N means the OS killed it with signal N (e.g. -9 = SIGKILL, -11 = SIGSEGV segfault).

Pools & Executors Re-Raise For You โ€” When You Read the Result

This is the practical reason to prefer a pool over raw Process for anything that returns a value. When a worker in a Pool or ProcessPoolExecutor raises, the library pickles the exception, ships it back, and re-raises it in the parent โ€” but only at the moment you actually ask for that task's result.

from concurrent.futures import ProcessPoolExecutor

def maybe_fail(x):
    if x == 3:
        raise ValueError(f"cannot handle {x}")
    return x * x

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        fut = pool.submit(maybe_fail, 3)
        try:
            fut.result()               # <- the ValueError is re-raised HERE, in the parent
        except ValueError as e:
            print("parent caught:", e) # traceback shows both parent and child frames

        # With map(), the exception surfaces when you ITERATE onto the failing item:
        results = pool.map(maybe_fail, [1, 2, 3, 4])
        try:
            for r in results:          # 1, 4, then boom on the 3rd
                print(r)
        except ValueError as e:
            print("map raised:", e)
Key Insight: a raw Process failure is silent (check exitcode); a pool/executor failure is deferred โ€” it waits inside the Future and detonates when you call .result() or iterate the map. If you submit a hundred tasks and never read their results, a hundred exceptions vanish without a trace.
The picklable-exception caveat: to travel home, the exception object itself must pickle. A custom exception whose __init__ takes extra required arguments often can't be reconstructed, so instead of your real error you get a confusing wrapper about pickling โ€” the same tax as any other data crossing the boundary (Pattern 8). Keep worker exceptions simple, or catch and return a plain error string.

When the OS Kills a Worker: No Exception Can Come Back

A Python exception is a cooperative thing โ€” the worker has to be alive to raise and pickle it. A segfault in a C extension or the OOM killer reaping your process gives the worker no chance to say goodbye. The two pool libraries handle this catastrophe differently:

Worker dies abruptly (SIGSEGV / SIGKILL / OOM)What you observe
ProcessPoolExecutorโœ… Detects the dead worker and raises BrokenProcessPool on every pending Future โ€” you find out.
multiprocessing.PoolโŒ Can hang forever โ€” the classic "my Pool froze and never returns" bug.
Takeaway: if your workers touch native code that might crash (NumPy/C libs, GPU bindings), prefer ProcessPoolExecutor โ€” a hang is far harder to debug than a BrokenProcessPool. Once a pool is broken it's dead; build a fresh one.

The Worker Lifecycle

Every one of these endings routes back through the same place: join() (or the pool's shutdown) is where the process is reaped and where its outcome becomes visible.

stateDiagram-v2 [*] --> Idle: start() Idle --> Running: OS schedules target Running --> Returned: target returns
exitcode 0 Running --> Raised: unhandled exception
exitcode > 0 Running --> Terminated: terminate() / kill()
or OS signal
exitcode < 0 Returned --> Reaped: join() / with-block Raised --> Reaped: join()
(pool re-raises on .result()) Terminated --> Reaped: join() Reaped --> [*]
Zombies come from skipping the last arrow. A child that finished but was never join()ed lingers as a defunct entry in the OS process table. A child whose parent crashed becomes an orphan that keeps running headless. The cure for both is the same: always join(), or let a with block do it for you.

Stopping Work: The Blunt Way vs. The Graceful Way

Sometimes you need to stop a worker that isn't done. There are two philosophies, and you should reach for them in this order โ€” graceful first, blunt only as a last resort.

The Blunt Instruments: terminate() and kill()

Why blunt is dangerous: if the worker is killed while holding a Lock or mid-write to a Queue, it can leave the lock held forever or the queue's internal buffer half-written and corrupted โ€” poisoning every other process that touches it. terminate() is safe only for workers that own no shared state.

The Graceful Way: An Event the Worker Polls

Instead of killing the worker, ask it to stop and let it finish its current unit of work and clean up. A shared multiprocessing.Event is the standard signal.

import multiprocessing as mp
import time

def worker(stop_event, results):
    while not stop_event.is_set():            # check the flag each loop
        results.put(do_one_unit_of_work())    # a whole, safe unit
    cleanup()                                 # runs because we EXIT normally

def do_one_unit_of_work():
    time.sleep(0.2)
    return "chunk"

def cleanup():
    print("worker cleaned up and exited gracefully")

if __name__ == "__main__":
    stop_event = mp.Event()
    results = mp.Queue()
    p = mp.Process(target=worker, args=(stop_event, results))
    p.start()

    time.sleep(1)              # let it run a while
    stop_event.set()           # ask it to stop โ€” no corruption, cleanup runs
    p.join(timeout=5)          # give it a bounded chance to exit
    if p.is_alive():           # it ignored us / is stuck
        p.terminate()          # NOW escalate to the blunt instrument
        p.join()

A sentinel value on a queue is the equivalent idiom for pipeline workers: the parent puts a special marker (often None) and each worker exits when it pulls that marker off the queue (Pattern 3).

The escalation ladder: ask nicely (Event/sentinel) โ†’ wait with a timeout (join(timeout=...)) โ†’ force it (terminate()) โ†’ nuke it (kill()). Walk down it in order; jump to the bottom only for a worker that has genuinely stopped responding.

Shutting Down a Pool or Executor

Pools have their own vocabulary for the graceful-vs-abrupt distinction.

CallLibraryMeaning
close()PoolAccept no new tasks; let queued ones finish.
join()PoolBlock until all workers exit. Must call close() (or terminate()) first.
terminate()PoolKill all workers now, dropping unfinished tasks. Abrupt.
shutdown(wait=True)ExecutorNo new tasks; block until running ones finish. What with does on exit.
shutdown(cancel_futures=True)Executor (3.9+)Also drop tasks that haven't started yet; running ones still finish.
from concurrent.futures import ProcessPoolExecutor

def task(x):
    return x * x

if __name__ == "__main__":
    # PREFER the context manager: it calls shutdown(wait=True) for you on exit,
    # even if the block raises. This is the clean, correct default.
    with ProcessPoolExecutor() as pool:
        futures = [pool.submit(task, i) for i in range(1000)]
    # <-- all workers reaped here; no zombies, no leaks

    # Manual equivalent when you need to bail out early and drop pending work:
    pool = ProcessPoolExecutor()
    futures = [pool.submit(task, i) for i in range(1000)]
    pool.shutdown(wait=True, cancel_futures=True)   # 3.9+: skip not-yet-started tasks
Default: use the with block. It guarantees the pool is shut down and every worker reaped no matter how the block exits โ€” which is also exactly what surfaces any deferred exceptions and prevents the leaked-process and hang problems above.

Ctrl-C: Why It Hits Everyone, and How to Tame It

Scenario: you press Ctrl-C to stop a running pool. On a fork/spawn setup, the terminal sends SIGINT to the whole process group โ€” parent and every worker at once. Each worker raises its own KeyboardInterrupt, tracebacks stampede across your terminal, workers die mid-task, and the pool can wedge instead of exiting cleanly.

The standard recipe: ignore SIGINT in the workers so only the parent handles the interrupt. You install the ignore inside a pool initializer, which runs once per worker at startup.

import multiprocessing as mp
import signal, time

def init_worker():
    # Workers ignore Ctrl-C; only the PARENT will react to SIGINT.
    signal.signal(signal.SIGINT, signal.SIG_IGN)

def task(x):
    time.sleep(1)
    return x * x

if __name__ == "__main__":
    pool = mp.Pool(initializer=init_worker)      # every worker ignores SIGINT
    try:
        result = pool.map_async(task, range(20))
        print(result.get(timeout=60))            # parent blocks here, still sees Ctrl-C
    except KeyboardInterrupt:
        print("\ninterrupted โ€” terminating workers")
        pool.terminate()                         # parent decides to stop everyone
    else:
        pool.close()
    finally:
        pool.join()
Why the initializer: a child inherits the parent's signal handlers at fork/spawn, so you must reset them inside the worker. Handling the interrupt in one place (the parent) turns a chaotic stampede into a single, deliberate shutdown.

Zombies, Orphans, and Daemon Children

import multiprocessing as mp
import atexit

def heartbeat():
    while True:
        emit_heartbeat()          # a background helper we don't need to join

def emit_heartbeat():
    pass

if __name__ == "__main__":
    p = mp.Process(target=heartbeat, daemon=True)  # dies with the parent
    p.start()
    atexit.register(p.terminate)                   # belt-and-braces cleanup on exit
    # ... main program runs; when it exits, the daemon is killed automatically
Real-world default: wrap pools in with, always read every Future.result() (so exceptions surface instead of vanishing), reserve terminate()/kill() for workers that stopped responding, and reach for the graceful Event/sentinel path whenever a worker owns a lock, a queue, a file, or any shared state. Blunt shutdown of stateful workers is how "it worked in dev" becomes "it corrupted the queue in prod."