Runtime

Multiprocessing for CPU-Bound CLI Tasks

Use every core from a Python CLI with ProcessPoolExecutor: when threads will not help, picklable workers, chunking, start methods, Ctrl+C and testing.

Updated

Your command parses 5,000 log files, validates a large dataset, renders hundreds of templates or computes statistics over millions of rows — in pure Python — and it pins one CPU core at 100% for four minutes while the rest of the machine idles. You try a thread pool and it gets slightly slower. That is the global interpreter lock at work: in standard CPython only one thread executes Python bytecode at a time, so threads help with waiting but not with computing. To use every core, a CLI needs multiple processes. This guide shows when that is worth it, how to structure a ProcessPoolExecutor so it works identically on Linux, macOS and Windows, how to keep pickling costs from eating the gains, and how to stop cleanly on Ctrl+C. It is part of the concurrency and async topic.

Prerequisites

  • Python 3.10+ and a Typer or Click CLI installed as a package (with a console-script entry point).
  • Work that is genuinely CPU-bound in Python. Confirm it first: if --jobs 4 with threads is no faster than one thread, and the CPU is at 100% on one core, it is.
  • Work that splits into independent pieces — per file, per record batch, per template.

Why threads do not help here

A thread pool shines when each task mostly waits: during a network call or disk read, the thread releases the GIL and others run. Pure-Python computation never waits, so threads take turns holding the lock and the total time stays the same — plus the overhead of switching.

Four CPU-bound tasks, threads versus processes With threads, CPU-bound Python tasks take turns holding the interpreter lock; with processes, they run truly in parallel on separate cores. Four CPU-bound tasks, threads versus processes One at a time GIL serialises bytecode threads No speedup plus switching overhead ~4× Four cores busy separate interpreters processes Near-linear minus pickling cost ~1× free-threaded builds (3.13t+) change this, but default CPython still has the GIL If the work is numpy or compression in C, threads may already scale, because those release the GIL.

Two caveats before reaching for processes. First, many "CPU-heavy" libraries do their work in C and release the GIL — hashlib on large buffers, zlib, NumPy, Pillow's resizing, lxml parsing. For those, a thread pool may already scale; measure before switching. Second, free-threaded CPython builds (3.13t and later) remove the GIL entirely. They are promising but still opt-in and not what your users have installed, so for distributed tools processes remain the portable answer.

The recipe

The example command computes per-file statistics over a directory of log files: request counts, error counts and the slowest endpoints. The parsing is regular-expression and string work — pure Python, CPU-bound.

# src/mytool/stats.py
from __future__ import annotations

import os
import re
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path

LINE = re.compile(r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) .* (?P<ms>\d+)ms$')


@dataclass
class Stats:
    requests: int = 0
    errors: int = 0
    slow: Counter[str] = field(default_factory=Counter)

    def merge(self, other: Stats) -> Stats:
        self.requests += other.requests
        self.errors += other.errors
        self.slow.update(other.slow)
        return self


def analyse_file(path: str) -> Stats:
    """Runs in a worker process: must be importable and take/return picklable values."""
    stats = Stats()
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            m = LINE.search(line)
            if not m:
                continue
            stats.requests += 1
            if m["status"].startswith("5"):
                stats.errors += 1
            if int(m["ms"]) > 1000:
                stats.slow[m["path"]] += 1
    return stats


def default_jobs() -> int:
    try:
        return len(os.sched_getaffinity(0))   # respects container CPU limits on Linux
    except AttributeError:
        return os.cpu_count() or 1


def analyse(paths: list[Path], jobs: int) -> Stats:
    total = Stats()
    if jobs == 1:
        for p in paths:
            total.merge(analyse_file(str(p)))
        return total
    chunksize = max(1, len(paths) // (jobs * 4))
    with ProcessPoolExecutor(max_workers=jobs) as pool:
        try:
            for stats in pool.map(analyse_file, map(str, paths), chunksize=chunksize):
                total.merge(stats)
        except KeyboardInterrupt:
            pool.shutdown(wait=False, cancel_futures=True)
            raise
    return total
# src/mytool/cli.py
from pathlib import Path

import typer

from mytool.stats import analyse, default_jobs

app = typer.Typer()


@app.callback()
def main() -> None:
    """Log analysis."""


@app.command()
def stats(
    directory: Path = typer.Argument(..., exists=True, file_okay=False),
    jobs: int = typer.Option(0, "--jobs", "-j", min=0, help="Worker processes (0 = one per core)."),
) -> None:
    """Summarise every *.log file under DIRECTORY."""
    paths = sorted(directory.rglob("*.log"))
    try:
        total = analyse(paths, jobs or default_jobs())
    except KeyboardInterrupt:
        typer.echo("interrupted", err=True)
        raise typer.Exit(130)
    typer.echo(f"{len(paths)} files, {total.requests} requests, {total.errors} server errors")
    for path, count in total.slow.most_common(5):
        typer.echo(f"  {count:>6} slow  {path}")


if __name__ == "__main__":
    app()

The rules that make it portable

Worker functions live at module level. The pool sends work to other processes by pickling a reference to the function — its module and name — plus the arguments. Lambdas, nested functions and methods of unpicklable objects cannot be sent. analyse_file is a top-level function in an importable module, so any worker can find it.

What crosses the process boundary The parent pickles the function reference and arguments, sends them to a worker process, which unpickles, runs the function and pickles the result back. What crosses the process boundary CLI (parent) Pipe Worker process pickle(fn name, args) import module, run fn pickle(result) unpickle Functions must be importable at module level, and big arguments cost time to copy both ways.

Arguments and results are small and simple. Everything crosses the boundary by pickling, so send a path, not the file's contents, and return a compact summary, not every parsed row. Here each worker reads its own file and returns a few integers and a counter. If you find yourself pickling megabytes per task, the copying can cost more than the computation saves.

Chunk the work. With thousands of small items, one round trip per item is dominated by overhead. pool.map(..., chunksize=n) sends items in batches; roughly len(items) / (jobs * 4) keeps workers busy while still balancing load. With submit/as_completed, batch items yourself.

Guard the entry point. On macOS and Windows, and on Linux from Python 3.14, workers are started with spawn or forkserver: a fresh interpreter that imports your main module. If that module runs the CLI at import time, every worker would start the CLI again. The if __name__ == "__main__": guard prevents it, and console-script entry points generated by pip or uv already call your function from a guarded wrapper, so an installed CLI is safe by construction.

Process start methods The fork, spawn and forkserver start methods compared by platform default, speed and safety. Process start methods Method Default on Trade-off fork Linux before 3.14 fast; unsafe with threads spawn macOS, Windows slow start; always safe forkserver Linux from 3.14 safe; modest start cost Write code that works under spawn and it will work everywhere: guard the entry point, import at module level.

Keep --jobs 1 sequential. Running in-process for one job makes debugging normal — breakpoints work, tracebacks are local — and avoids process start-up for tiny inputs.

UX considerations

  • Default to the cores the process may actually use. os.sched_getaffinity(0) respects CPU pinning and container limits on Linux, where os.cpu_count() would report the host's 64 cores to a job limited to 2.
  • Expect a start-up cost. Spawning workers takes tens to hundreds of milliseconds, since each re-imports your package. For a handful of small files, sequential is faster; consider switching automatically below a size threshold.
  • Show progress by completed chunks. With as_completed over batches, a Rich progress bar advanced per batch gives honest progress; pool.map yields in order, so a slow first file stalls the display.
  • Memory multiplies. Each worker is a full interpreter with your imports loaded. Eight workers each holding a 500 MB dataset is 4 GB; let --jobs bring it down on smaller machines, and say so in the help text.
  • Errors in workers arrive re-raised in the parent, with the original traceback attached as a cause. Catch the expected ones inside the worker and return them as data so one malformed file does not abort the run.

Testing the behaviour

The key tests prove that parallel and sequential runs produce identical results, and that the worker function behaves on its own. Running with jobs=2 in tests exercises the real pickling path; keep the inputs small so the suite stays fast:

# tests/test_stats.py
from pathlib import Path

from mytool.stats import analyse, analyse_file

SAMPLE = [
    '1.2.3.4 - - [18/Sep/2026:10:00:00] "GET /api/users HTTP/1.1" 200 512 12ms',
    '1.2.3.4 - - [18/Sep/2026:10:00:01] "GET /api/report HTTP/1.1" 500 80 2400ms',
    '1.2.3.4 - - [18/Sep/2026:10:00:02] "POST /api/report HTTP/1.1" 200 64 1800ms',
    "garbage line",
]


def write_logs(tmp_path: Path, n: int) -> list[Path]:
    paths = []
    for i in range(n):
        p = tmp_path / f"app-{i}.log"
        p.write_text("\n".join(SAMPLE) + "\n", encoding="utf-8")
        paths.append(p)
    return paths


def test_worker_function(tmp_path):
    [path] = write_logs(tmp_path, 1)
    s = analyse_file(str(path))
    assert (s.requests, s.errors, s.slow["/api/report"]) == (3, 1, 2)


def test_parallel_matches_sequential(tmp_path):
    paths = write_logs(tmp_path, 12)
    seq = analyse(paths, jobs=1)
    par = analyse(paths, jobs=2)
    assert (par.requests, par.errors, par.slow) == (seq.requests, seq.errors, seq.slow)
    assert par.requests == 36

Tests that spawn processes are slower than ordinary unit tests — each pool start re-imports your package — so keep one or two of them and test the logic through the worker function directly. If you use pytest-xdist, process pools inside tests work fine, but avoid combining huge --jobs values with a parallel test runner on small CI machines.

Conclusion

When a CLI is CPU-bound in pure Python, processes are the portable way to use every core. Keep worker functions at module level, send small arguments and return small summaries, chunk many small tasks, guard the entry point, and keep a sequential path for --jobs 1. Default to the CPUs the process is allowed to use, watch memory, and prove with a test that parallel and sequential runs agree. For work that mostly waits rather than computes, a thread pool is simpler and just as fast.

Frequently asked questions

Should I use multiprocessing.Pool or ProcessPoolExecutor?

ProcessPoolExecutor for new code: it shares the futures API with ThreadPoolExecutor, so switching between threads and processes is a one-word change, and it handles worker crashes by raising BrokenProcessPool instead of hanging. multiprocessing.Pool offers a few extras such as imap_unordered with chunking.

Why does my CLI print its banner once per worker?

Something runs at import time of your main module — a print, a config load with output, or an unguarded call to the app. Workers started with spawn import that module. Move side effects into functions called from the guarded entry point.

Can workers share a large read-only dataset?

Under fork, children inherit the parent's memory copy-on-write, but that start method is no longer the default on most platforms. Portable options are to have each worker load the data in an initializer, or to put it in multiprocessing.shared_memory for large numeric arrays.

Is it worth trying the free-threaded build?

For internal tools where you control the Python version, experimenting with a 3.13t or 3.14t build can let a plain thread pool use every core with no pickling. Check that your dependencies support it first; C extensions built without free-threading support re-enable the GIL.