Skip to content

← True Parallelism and the Runtime step 14 of 18

Medium Framework

Manager and Proxies: Convenience at 275x the Price

multiprocessing.Manager() starts a server process and hands you proxies. A DictProxy looks exactly like a dict — it has __getitem__, __setitem__, keys(), update(). That resemblance is the trap: every one of those calls is a remote procedure call to another process.

Measured, on the same machine:

Operation 2000 iterations
plain dict.__setitem__ 91.2 microseconds total
DictProxy.__setitem__ 25.2 ms total — 12.6 microseconds each

That is 275x. Put a proxy in an inner loop and you will benchmark multiprocessing as “slower than serial” — correctly, because you replaced a 46 ns dict write with a 12.6 microsecond RPC.

The correctness trap, which is worse

d = manager.dict()
d["k"] = []
d["k"].append(1)
print(d["k"])      # []

d["k"] returns a copy of the list across the process boundary. .append mutates the copy. The copy is discarded. Nothing raises, nothing warns, and the bug is invisible until a count comes out low.

The fix is read-modify-write:

value = d["k"]
value.append(1)
d["k"] = value

…which is also not atomic. Between the read and the write, another process can interleave and its update is lost. You need the manager’s own Lock() around the pair. Two round trips plus lock acquisition, per increment.

Right for: a stop flag, a config blob, a once-a-second progress counter. Wrong for: anything in an inner loop. If you need high-frequency shared counters, use multiprocessing.Value with its built-in lock, or aggregate locally and merge once at the end.

Your task

Build the facade that makes the expensive thing correct and the correct thing cheap. Proxy is given — it counts every round trip and deliberately yields the GIL inside read and write, so an unlocked read-modify-write really does lose updates.

class SharedCounter:
    def incr(self, key: str, by: int = 1) -> int: ...
    def incr_many(self, amounts: Mapping[str, int]) -> None: ...
    def get(self, key: str) -> int: ...
    def snapshot(self) -> dict[str, int]: ...
def solve(
    *, threads: int, per_thread: int, keys: list[str], batched: bool
) -> tuple[dict[str, int], int, bool]:

Run threads workers, each performing per_thread rounds over keys — one incr per key, or a single incr_many if batched. Return the final counts, the total number of proxy round trips, and a flag confirming that the dict returned by snapshot() is detached from shared state.

Three assertions are doing real work:

  1. 8 threads x 500 increments must sum to exactly 4000. Anything less means your lock does not span the read and the write.
  2. Batching three keys costs 2 round trips, not 6. 4 x 100 batched rounds is 800 calls; the same work unbatched is 2400. On a real manager that is the difference between 10 ms and 30 ms of pure IPC.
  3. snapshot() returns a plain dict. Mutating it must not be visible on the next read — which is what makes it safe to hand to a caller.

The typing lesson

Annotate the proxy explicitly:

counts: DictProxy[str, int] = manager.dict()

Inference gives you DictProxy[Any, Any], and --disallow-any-generics does not catch an Any that arrived by inference from a call — only one you wrote in an annotation. So the flag you turned on to prevent exactly this problem is silent here, and the discipline has to be yours.

Note also that incr_many takes a Mapping, not a dict: the facade reads the argument and never stores it, so the read-only type is both more honest and more permissive.

Loading visualization…