๐Ÿง  Pattern 4: Shared Memory

Message-passing copies every byte. When the data is huge or the counter is hot, copying is the bottleneck. These are the escape hatches that let processes see the same bytes.

The Problem: Copying Isn't Free

Pattern 3 gave you message passing โ€” safe, simple, and it copies. Send a 2 GB NumPy array to four workers and you've pickled and copied 8 GB. Increment a shared counter through a queue and every tick is a round-trip. For big buffers or hot shared state, the copy costs more than the computation.

Scenario: four workers each need to read the same 500 MB feature matrix. With message passing, each worker gets its own 500 MB copy โ€” 2 GB of RAM and a lot of pickling โ€” even though the data is read-only and identical for everyone.

Shared memory fixes this: the OS maps the same physical bytes into multiple processes. There's one copy of the data, and every process reads and writes it directly. The catch is that you've now re-introduced the thing processes were protecting you from โ€” shared mutable state โ€” so you're back to needing locks (Pattern 6).

Value and Array: Shared Scalars and Buffers

mp.Value and mp.Array are ctypes-backed primitives that live in shared memory. You declare a C type code up front: Value('i', 0) is a shared 32-bit int, Array('d', [1.0, 2.0, 3.0]) is a shared array of doubles. You read and write through the .value attribute (for Value) or by indexing (for Array). Because they're plain C types, they're fast and involve no pickling.

Not atomic: counter.value += 1 is a read, an add, and a write โ€” three steps. Two processes can interleave and lose updates, exactly like the threading race. Shared memory does not make operations atomic.
The fix: every Value/Array ships with a built-in lock, reachable via .get_lock(). Wrap read-modify-write sequences in it. Use the lock as a context manager.
import multiprocessing as mp

def increment(counter, times):
    for _ in range(times):
        with counter.get_lock():      # MANDATORY โ€” += is not atomic
            counter.value += 1

if __name__ == "__main__":
    counter = mp.Value('i', 0)        # shared 32-bit int, starts at 0

    workers = [
        mp.Process(target=increment, args=(counter, 100_000))
        for _ in range(4)
    ]
    for w in workers:
        w.start()
    for w in workers:
        w.join()

    print(counter.value)              # 400000 โ€” exact, because of the lock

Drop the with counter.get_lock(): and you'll get some number less than 400000, and a different one on every run. See Pattern 6 for why the lock is non-negotiable and how cross-process locks differ from threading ones.

shared_memory.SharedMemory: Zero-Copy Buffers (3.8+)

multiprocessing.shared_memory.SharedMemory is a raw, named block of shared memory โ€” just bytes, addressable by a string name. It's the way to share a large NumPy array with zero copy: you allocate a block, wrap it with a NumPy view via the buffer= argument, and then pass only the block's name (a small string) to the workers. Each worker attaches to the same block by that name and sees the same bytes โ€” no pickling of the array itself.

flowchart TB SHM[("SharedMemory block
name='psm_ab12'
one copy of the bytes")] SHM --- P1["Process 1
ndarray view"] SHM --- P2["Process 2
ndarray view"] SHM --- P3["Process 3
ndarray view"]
import numpy as np
from multiprocessing import Process
from multiprocessing.shared_memory import SharedMemory

def worker(name, shape, dtype):
    shm = SharedMemory(name=name)                 # ATTACH by name (no copy)
    arr = np.ndarray(shape, dtype=dtype, buffer=shm.buf)
    arr[:] = arr * 2                              # mutate the shared bytes in place
    shm.close()                                   # detach THIS process's mapping

if __name__ == "__main__":
    data = np.arange(10, dtype=np.int64)
    shm = SharedMemory(create=True, size=data.nbytes)   # allocate the block

    # wrap the block as a NumPy array and fill it
    shared = np.ndarray(data.shape, dtype=data.dtype, buffer=shm.buf)
    shared[:] = data[:]

    p = Process(target=worker, args=(shm.name, data.shape, data.dtype))
    p.start()
    p.join()

    print(shared)          # [0 2 4 6 8 10 12 14 16 18] โ€” worker's writes are visible
    shm.close()            # detach the parent's mapping
    shm.unlink()           # EXACTLY ONE process frees the block (see gotcha)

Note what crossed the process boundary: just shm.name, data.shape, and data.dtype โ€” a few dozen bytes. The array itself never got pickled.

The Gotcha: SharedMemory Won't Free Itself

It leaks unless you tell it not to. A SharedMemory block is an OS-level resource that outlives the Python objects pointing at it. Every process that attached must call close() to detach, and exactly one process must call unlink() to actually release the underlying block. Forget unlink() and the memory stays allocated after your program exits, and you'll see resource_tracker: ... leaked shared_memory objects warnings.
The discipline:
  • close() โ€” call in every process, detaches that process's mapping.
  • unlink() โ€” call in exactly one process (usually the creator/owner), frees the block.
  • Never unlink() twice, and never unlink() while another process still needs the data โ€” it invalidates the block for everyone.
Mental model: close() is "I'm done looking at it"; unlink() is "delete the file." Files (and shared-memory blocks) survive after readers close them โ€” someone has to delete them. Wrap the owner's close()/unlink() in a try/finally so a crash mid-computation still frees the block.

Manager: Shared Objects via a Server Process

A multiprocessing.Manager spins up a separate server process that holds real Python objects. Children get proxies โ€” objects that look like a dict, list, or Namespace but forward every operation to the server over IPC. This is the convenient option: you can share arbitrary, nested Python objects, not just C scalars and raw bytes.

import multiprocessing as mp

def worker(shared_dict, key):
    shared_dict[key] = key * key      # each op is an IPC round-trip to the server

if __name__ == "__main__":
    with mp.Manager() as manager:
        shared_dict = manager.dict()  # a PROXY backed by the server process

        workers = [
            mp.Process(target=worker, args=(shared_dict, n))
            for n in range(5)
        ]
        for w in workers:
            w.start()
        for w in workers:
            w.join()

        print(dict(shared_dict))      # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
Convenient but slow, and the nested-mutation trap: every read and write is an IPC round-trip โ€” orders of magnitude slower than Value/shared_memory. Worse, mutating a nested object doesn't propagate. d[k].append(x) mutates a local copy the proxy handed you; the server never hears about it. You must reassign so the proxy re-sends the whole value:
        # โŒ silently lost โ€” mutates a local copy, server never updated
        shared_dict[k].append(x)

        # โœ… reassign so the proxy ships the new value back to the server
        shared_dict[k] = shared_dict[k] + [x]

Which One? A Decision Table

ToolBest forSpeedLifecycleShares
Value / ArraySimple scalars & flat numeric arrays, hot countersโœ… Fastโœ… AutomaticC-typed primitives
shared_memoryBig raw / NumPy buffers, zero-copyโœ…โœ… FastestโŒ Manual (close + unlink)Raw bytes
ManagerArbitrary / nested Python objects, convenienceโŒ Slow (IPC per op)โœ… AutomaticAny picklable object

The Default You Should Reach For First

Key Insight: shared memory is an optimization, not a starting point. Default to message passing and returning results (Patterns 2, 3, 5) โ€” it's simpler, safer, and needs no locks. Reach for Value/shared_memory/Manager only when profiling shows the copy is the bottleneck. Shared mutable state buys you speed at the cost of every concurrency bug processes were supposed to spare you.
Real-world: the sweet spot for shared_memory is a large, mostly read-only dataset (a model, a lookup table, a feature matrix) that many CPU-bound workers scan in parallel โ€” no writes means no locks, and you skip gigabytes of copying. See Pattern 10 for measuring whether that copy is actually your bottleneck before you take on the manual lifecycle.