Isolated memory means every argument you send a worker and every result it returns is turned into bytes and back. That has a correctness cost and a speed cost.
Because each process has its own private memory, a worker can't just read a variable
the parent holds. So multiprocessing serializes every argument with
pickle, ships the bytes across the OS boundary, and rebuilds
(unpickles) an independent copy on the other side. The result travels back the same way. This
happens on every task submission and every return.
Pickle stores a reference to a top-level, importable name plus the object's state. Anything that can't be named that way, or that wraps a live OS resource, fails to travel.
AttributeError: Can't pickle local object or
_pickle.PicklingError), open file handles, sockets, database connections, threading
Locks, generators, and any object that captures one of those in a closure.
from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":
# โ A lambda has no importable name โ the parent can't pickle it to send it.
with ProcessPoolExecutor() as pool:
pool.map(lambda x: x * 2, range(4))
# _pickle.PicklingError: Can't pickle <function <lambda>>:
# attribute lookup <lambda> on __main__ failed
The same failure hits a function defined inside another function, or a partial that closed over
a socket. The rule of thumb: if you can't import it by a stable dotted path, pickle can't
either.
functools.partial instead of a lambda โ a
partial of a top-level function is picklable.
initializer=.
from concurrent.futures import ProcessPoolExecutor
from functools import partial
def scale(factor, x): # top-level, importable โ picklable
return x * factor
if __name__ == "__main__":
# (a) + (b): partial binds `factor`, replacing the un-picklable lambda.
double = partial(scale, 2)
with ProcessPoolExecutor() as pool:
print(list(pool.map(double, range(4)))) # [0, 2, 4, 6]
For connections, the fix is the initializer pattern: don't pickle a live
DB handle (you can't). Pickle the DSN, and let each worker build its own connection once and
stash it in a global for reuse.
from concurrent.futures import ProcessPoolExecutor
_CONN = None # per-worker connection, rebuilt in each worker
def init_worker(dsn):
global _CONN
_CONN = connect(dsn) # open ONCE per worker, not per task
def run_query(query):
return _CONN.execute(query) # reuse the per-worker connection
def connect(dsn):
return dsn # stand-in for a real driver
if __name__ == "__main__":
dsn = "postgresql://localhost/app" # a picklable STRING, not a handle
with ProcessPoolExecutor(initializer=init_worker, initargs=(dsn,)) as pool:
print(list(pool.map(run_query, ["SELECT 1", "SELECT 2"])))
See Pattern 6 for passing sync primitives through
initargs the same way (they can't be pickled as plain arguments either).
ProcessPoolExecutor comes out slower
than a plain sequential loop.
Rules to cut the tax:
shared_memory (Pattern 4) and pass the
name; workers attach to the same buffer with zero copy.from concurrent.futures import ProcessPoolExecutor
# โ Copies the whole big list into every task's pickle.
# def work(big_list, i): ...
def work(path_and_index): # โ
pass a tiny (path, index) tuple instead
path, i = path_and_index
with open(path) as f:
line = f.readlines()[i] # worker reads only what it needs
return len(line)
if __name__ == "__main__":
jobs = [("data.txt", i) for i in range(100)] # small, cheap to pickle
with ProcessPoolExecutor() as pool:
print(sum(pool.map(work, jobs)))
Under the spawn start method (Pattern 7) โ
the default on macOS and Windows โ pickling isn't optional plumbing you can dodge with
fork's inherited memory. spawn pickles the target function's arguments and
effectively requires your module to be importable so the child can rebuild the target by name.
So the picklability rules here aren't a corner case: on the two most common developer platforms they
govern whether your program starts at all.
To test quickly whether something can travel, try to dump it in a REPL โ if
pickle.dumps raises, so will passing it to a worker.
import pickle
pickle.dumps({"a": 1}) # ok โ bytes
pickle.dumps(lambda x: x) # raises PicklingError โ this would fail in a pool
There's also cloudpickle, a third-party serializer that can pickle lambdas and
locally-defined functions by capturing their bytecode. It's why joblib, Dask, and Ray let
you pass a lambda straight to a worker where the stdlib pool refuses. You don't get it for free with
multiprocessing โ those libraries swap the serializer in for you. More in
Common Libraries.
| Object kind | Picklable? | What to do instead |
|---|---|---|
| Top-level function | โ Yes | Use as target directly |
| Lambda / nested function | โ No | functools.partial of a top-level fn, or cloudpickle |
| dict / list / str / int / dataclass | โ Yes | Fine โ but watch the size (the tax) |
| Big array / DataFrame | โ Yes (but costly) | Pass a path/index, or use shared_memory |
| Open file / socket | โ No | Pass the path/params; open inside the worker |
| DB connection | โ No | Pass the DSN; build per-worker via initializer |
| Lock / Event / generator | โ No | Pass via initargs (Pattern 6) / re-create in worker |
ProcessPoolExecutor than with threads or a plain loop, suspect the serialization tax
first โ you're almost certainly shipping fat arguments or fat results. Shrink what crosses the
boundary before blaming the parallelism. The payoff, once the tax is under control, is in
Pattern 10.