๐Ÿงฐ Common Libraries Compared

Half a dozen libraries all promise "parallelism." They aren't rivals so much as layers โ€” some are thin sugar over the same primitive, some scale to a thousand-node cluster, and one doesn't parallelize Python at all. This is the map of which to reach for.

Same Goal, Different Layers & Scales

Every library below gets you "more than one thing at once," but they sit at very different altitudes. At the bottom is one primitive โ€” the stdlib multiprocessing module โ€” and almost everything else is either a friendlier wrapper over it or a bigger system that competes with it. Two axes separate them:

flowchart TD subgraph DIST["๐ŸŒ Distributed โ€” scale past one machine"] DASK["dask"] RAY["ray"] MPI["mpi4py"] end subgraph HIGH["๐ŸŽฏ High-level โ€” one clean call, single machine"] CF["concurrent.futures
(ProcessPoolExecutor)"] JOB["joblib
Parallel + delayed"] LOKY["loky
(reusable pool)"] end subgraph LOW["๐Ÿงฑ Low-level primitive โ€” stdlib foundation"] MP["multiprocessing
Process ยท Pool ยท Queue ยท shared_memory"] end SUB["subprocess
(runs EXTERNAL programs,
not Python functions)"] HIGH --> LOW JOB -.->|built on| LOKY LOKY -.->|built on| LOW DIST -.->|own workers /
own transport| LOW
Key Insight: read this page as a ladder, not a menu. Start at the lowest rung that solves your problem. concurrent.futures covers most single-machine work; you climb to Dask/Ray only when the data or compute genuinely outgrows one box, and you drop to raw multiprocessing only when you need a primitive the high-level API doesn't expose.

The stdlib Foundation: multiprocessing

multiprocessing is the primitive everything else stands on. It ships in the standard library (no install) and hands you the raw building blocks: Process to launch a worker, Pool for a batch of them, Queue/Pipe to pass messages, shared_memory for zero-copy buffers, and the full set of sync primitives (Lock, Semaphore, Event). It gives you maximum control at the cost of being the most verbose, and it moves data with the built-in pickle โ€” so arguments and results must be picklable.

import multiprocessing as mp

def square(x):
    return x * x

if __name__ == "__main__":                 # required on spawn platforms
    with mp.Pool(processes=4) as pool:      # classic worker pool
        results = pool.map(square, range(10))
    print(results)                          # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
When to choose it: when you need a primitive the high-level API doesn't surface โ€” explicit Queue pipelines, a long-lived Manager, a specific start method, or shared_memory buffers. For the ergonomic tour of the pool see multiprocessing.Pool & map. In everyday code you usually want the friendlier wrapper below, not this directly.

The Recommended Default: concurrent.futures

Also stdlib, but a layer up. concurrent.futures exposes one clean Executor abstraction with two backends โ€” ProcessPoolExecutor (real parallelism, separate processes) and ThreadPoolExecutor (shared memory, for I/O). You submit() work and get a Future back, or map() over an iterable. Results come out of .result(), and โ€” critically โ€” exceptions raised in a worker are re-raised there, so errors surface cleanly instead of vanishing into a child process.

from concurrent.futures import ProcessPoolExecutor

def square(x):
    return x * x

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:       # defaults to os.cpu_count() workers
        futures = [pool.submit(square, x) for x in range(10)]
        results = [f.result() for f in futures]   # re-raises worker exceptions here
    print(results)
Its superpower: swap parallelism models by changing one class name. ProcessPoolExecutor for CPU-bound work, ThreadPoolExecutor for I/O-bound โ€” the rest of your code is identical. That makes it trivial to A/B a workload. Full walkthrough in the ProcessPoolExecutor pattern.
Reach for this first. For the large majority of single-machine parallelism, concurrent.futures is the right answer: stdlib, tiny API, clean error handling, and no dependency to install.

The Scientific Favorite: joblib (and loky under it)

joblib is a third-party library (pip install joblib) beloved in the NumPy/scikit-learn world โ€” it's what powers n_jobs=-1 across sklearn. Its signature idiom is a one-liner list comprehension:

from joblib import Parallel, delayed

def square(x):
    return x * x

# n_jobs=-1 means "use every core"
results = Parallel(n_jobs=-1)(delayed(square)(x) for x in range(10))
print(results)

delayed(fn)(x) just packages the call for later; Parallel dispatches the batch. By default it runs on the loky backend โ€” a robust reusable process pool โ€” but you can switch to threading or the classic multiprocessing backend with one argument. Three features earn it its fans:

from joblib import Parallel, delayed

# closures & lambdas work here (cloudpickle) โ€” they would fail with stdlib pickle
factor = 3
results = Parallel(n_jobs=-1, backend="loky")(
    delayed(lambda x: x * factor)(x) for x in range(10)
)
When to choose it: embarrassingly-parallel numeric loops โ€” feature extraction, cross-validation folds, per-row transforms over arrays or DataFrames. If your code already lives in the NumPy/sklearn stack, joblib fits like a glove.

loky, briefly

loky is the standalone executor joblib is built on. It's a drop-in, concurrent.futures-style pool (get_reusable_executor()) that is more robust to worker crashes and reuses workers across batches instead of respawning them. You rarely import it directly โ€” you get its benefits for free through joblib โ€” but it's why joblib's pool tolerates segfaulting native code better than a plain ProcessPoolExecutor does.

The Odd One Out: subprocess

subprocess is stdlib and often confused with multiprocessing โ€” but it does something completely different. It does not parallelize Python. It launches an external program: a compiled binary, a shell command, ffmpeg, git, or another script. The "work" isn't a Python function you're fanning out across cores โ€” it's a separate executable you're driving from Python.

import subprocess

# run an EXTERNAL program and capture its output
result = subprocess.run(
    ["ffmpeg", "-i", "in.mov", "out.mp4"],
    capture_output=True,
    text=True,
    check=True,           # raise CalledProcessError on non-zero exit
)
print(result.stdout)
The distinction to keep straight: multiprocessing runs your Python function in another Python interpreter. subprocess runs some other program entirely and talks to it over stdin/stdout/exit codes. If the thing you want to parallelize is a Python function โ†’ multiprocessing / concurrent.futures. If it's an external command โ†’ subprocess. (You can, of course, fan out many subprocess calls from a thread pool โ€” the two compose.)

Crossing the Machine Boundary: dask & ray

When one machine's cores or RAM aren't enough, you climb to a distributed framework. Both are heavy third-party dependencies and both scale from a laptop to a cluster โ€” but they have different shapes.

dask โ€” parallel arrays, DataFrames, and lazy task graphs

dask mirrors familiar APIs: dask.array looks like NumPy and dask.dataframe looks like pandas, but the data is chunked and operations build a lazy task graph that only executes on .compute(). That enables out-of-core work โ€” datasets larger than RAM, processed chunk by chunk โ€” plus a general dask.delayed / futures API for arbitrary functions.

import dask

@dask.delayed
def square(x):
    return x * x

# builds a lazy graph; nothing runs until .compute()
graph = [square(x) for x in range(10)]
results = dask.compute(*graph)     # executes across threads / processes / a cluster
print(results)
When to choose it: your data or compute won't fit one machine, or you want NumPy/pandas semantics that transparently spill to disk and spread across a cluster.

ray โ€” distributed tasks and stateful actors

ray targets distributed compute for Python and ML. Decorate a function with @ray.remote to turn it into a task, or decorate a class to get a stateful actor running on some node in the cluster. Its object store keeps large shared objects in memory and hands out references, so data isn't re-pickled for every task that touches it.

import ray

ray.init()                          # connects to a local or remote cluster

@ray.remote
def square(x):
    return x * x

futures = [square.remote(x) for x in range(10)]   # returns ObjectRefs
results = ray.get(futures)                         # gather actual values
print(results)
When to choose it: distributed stateful actors, large-scale ML training or serving, or reinforcement-learning-style workloads where the object store's shared-memory model pays off.

The HPC Option: mpi4py

mpi4py is Python bindings for MPI (Message Passing Interface), the decades-old standard for high-performance computing across supercomputer nodes. The model is explicit: every process has a rank, and you send/receive messages over a communicator. You launch it with a job scheduler (mpirun/srun), not from inside a single Python program.

# run with:  mpirun -n 4 python squares.py
from mpi4py import MPI

comm = MPI.COMM_WORLD
rank = comm.Get_rank()          # which process am I?
size = comm.Get_size()          # how many are there?

result = rank * rank            # each rank squares its own id
gathered = comm.gather(result, root=0)   # rank 0 collects everything
if rank == 0:
    print(gathered)             # [0, 1, 4, 9]
Niche but important: reach for mpi4py only on an actual HPC cluster with a scheduler and a system MPI installed. For most people it's the wrong tool โ€” but if you're on a supercomputer, it's the tool.

The Primitive Underneath: multiprocessing.shared_memory

Worth calling out on its own: multiprocessing.shared_memory is a stdlib primitive that gives multiple processes a single, zero-copy block of RAM โ€” no pickling, no copying. It's the mechanism several of the libraries above lean on (joblib's array memory-mapping, Ray's object store solve the same "don't copy the big buffer" problem). You'll usually meet it through a higher-level library, but when you're hand-rolling multiprocessing and a large NumPy array is the bottleneck, it's the escape hatch. Deep dive in Pattern 4: Shared Memory.

Same Task, Many Ways

The clearest way to feel the ergonomic differences: one identical embarrassingly-parallel task โ€” square(x) over a list โ€” expressed in four libraries. The work is the same every time; only the wrapper changes.

(a) concurrent.futures.ProcessPoolExecutor โ€” stdlib default

from concurrent.futures import ProcessPoolExecutor

def square(x):
    return x * x

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        results = list(pool.map(square, range(10)))

(b) joblib.Parallel + delayed โ€” the numeric one-liner

from joblib import Parallel, delayed

def square(x):
    return x * x

results = Parallel(n_jobs=-1)(delayed(square)(x) for x in range(10))

(c) dask.delayed โ€” same call, but a lazy graph that can span a cluster

import dask

@dask.delayed
def square(x):
    return x * x

results = dask.compute(*[square(x) for x in range(10)])

(d) ray โ€” .remote tasks, gathered via the object store

import ray
ray.init()

@ray.remote
def square(x):
    return x * x

results = ray.get([square.remote(x) for x in range(10)])
What to notice: the high-level trio all read like "run this function over these inputs." joblib is the tersest; concurrent.futures is dependency-free and needs the __main__ guard; dask/ray look almost identical for a toy task but carry a whole distributed runtime behind them โ€” you don't pay that weight for ten integers, you pay it when the ten becomes ten billion across a cluster.

The Big Comparison

Library Layer Best for API style Cluster? Serialization stdlib? Learning curve
multiprocessing Low Full control, custom IPC Process/Pool/Queue โŒ pickle โœ… Medium
concurrent.futures High Most single-machine work Executor + Future โŒ pickle โœ… Low
joblib High NumPy/sklearn numeric loops Parallel+delayed โ–ณ (with backend) cloudpickle โŒ Low
loky High Crash-robust reusable pool Executor-style โŒ cloudpickle โŒ Low
subprocess โ€” Running external programs run([...]) โŒ bytes / stdio โœ… Low
dask Distributed Out-of-core arrays/DataFrames lazy graph + delayed โœ… cloudpickle โŒ Medium
ray Distributed Distributed actors / ML @ray.remote โœ… Arrow object store โŒ Medium/High
mpi4py Distributed HPC clusters rank + communicator โœ… MPI messages โŒ High

Which One? โ€” The Decision Flowchart

flowchart TD START["What are you trying
to parallelize?"] --> EXT{"Is the work an
EXTERNAL program
(binary / shell cmd)?"} EXT -->|Yes| SUB["subprocess"] EXT -->|"No โ€” a Python function"| FITS{"Does it fit on
ONE machine?"} FITS -->|Yes| NUMERIC{"NumPy / sklearn
numeric loop?"} NUMERIC -->|Yes| JOB["joblib
(loky backend)"] NUMERIC -->|"No โ€” general work"| CF["concurrent.futures
ProcessPoolExecutor"] FITS -->|"No โ€” needs a cluster"| HPC{"HPC cluster with
MPI + a scheduler?"} HPC -->|Yes| MPI["mpi4py"] HPC -->|"No โ€” general distributed"| DR["dask (data-parallel)
or ray (actors / ML)"]
Read it top-down:
  • External program? โ†’ subprocess. It's not parallelizing Python at all; it's driving another executable.
  • Python function, single machine, numeric? โ†’ joblib. It handles array memory-mapping and cloudpickle for you.
  • Python function, single machine, general? โ†’ concurrent.futures. The stdlib default.
  • Bigger than one machine? โ†’ dask for data-parallel NumPy/pandas work, ray for distributed actors/ML โ€” and mpi4py only on a real HPC cluster.

The Takeaway

Key Insight: for roughly 90% of single-machine cases, concurrent.futures is the right answer โ€” or joblib when the work is numeric. Distributed frameworks (Dask, Ray, mpi4py) are powerful, but they add real operational weight: a cluster to run, a runtime to manage, more ways to fail. Don't jump to a distributed framework prematurely. Reach for one only when you've genuinely outgrown a single machine's cores or RAM, not because it sounds more serious. Start at the lowest rung of the ladder that solves your problem.

Installation Reality

Real-world: the layer split shows up in your requirements.txt too. multiprocessing, concurrent.futures, and subprocess are stdlib โ€” nothing to install, available on any Python. The rest are pip install third-party: joblib/loky are light and painless; dask and ray are heavy dependencies that pull in large dependency trees; and mpi4py won't even build without a system MPI (OpenMPI/MPICH) installed first. If a stdlib option covers your need, it's one fewer thing to install, pin, and debug in CI.

For the broader "should I even be using processes?" question โ€” the serialization tax, when threads or async beat processes โ€” see Processes vs Threads vs Async, and start from the top with Processes 101.