We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← True Parallelism and the Runtime step 14 of 18
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:
- 8 threads x 500 increments must sum to exactly 4000. Anything less means your lock does not span the read and the write.
-
Batching three keys costs 2 round trips, not 6.
4 x 100batched 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. -
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…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.