Skip to content

← True Parallelism and the Runtime step 6 of 18

Easy Primitives

Process Is a Typing Black Hole — Prefer Executors

Teams adopt --strict, watch it go green, and conclude their concurrency code is checked. It is not. The most error-prone call site in the whole program — marshalling arguments across a process boundary — is entirely unchecked.

Given def work(n: int) -> str, this produces zero errors:

mp.Process(target=work, args=("wrong type", 1, 2, 3))

Wrong type, wrong arity, both invisible. Process.__init__ is annotated target: Callable[..., object] and args: Iterable[Any], so there is nothing to check against. You find out at runtime, in another process, where the traceback goes to a stream nobody is reading.

By contrast:

ex.submit(work, 1)      # reveals Future[str]
ex.submit(work, "x")    # error: Argument 2 has incompatible type "str"; expected "int"

Executor.submit is overloaded with ParamSpec, so the call site is checked against the callable’s real signature and the result is a Future[str], not a Future[Any].

This is the main argument for the executor API, ahead of any performance consideration. The secondary argument is uniformity: the identical submit / Future / as_completed surface covers ThreadPoolExecutor, ProcessPoolExecutor and — since 3.14 — InterpreterPoolExecutor. Changing your parallelism substrate becomes a one-line change instead of a rewrite.

Your task

Write the two wrappers that restore type checking, then use one of them.

def typed_submit[**P, R](
    ex: Executor, fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs
) -> Future[R]: ...

def typed_process[**P](
    fn: Callable[P, None], *args: P.args, **kwargs: P.kwargs
) -> BaseProcess: ...

typed_process deliberately accepts only callables returning None, because Process discards the return value — a function whose result you meant to use is a bug the wrapper can catch at the type level.

Then:

def solve(*, items: list[int], width: int, prefix: str) -> tuple[tuple[str, ...], bool]:

Fan items out over a pool of width workers using typed_submit, and return the results in submission order, plus a flag confirming that a typed_process you constructed but never started has exitcode is None.

A note on why the pool here is threads: the grader execs your submission into a namespace, so it has no importable module name — which means spawn cannot find your functions at all. That is not a limitation of the exercise, it is lesson 10.1 arriving early, and it is exactly why a notebook cell cannot use a process pool either. The submit API you are typing is identical for all three executor kinds.

Reading an exit code

You will need this the first time a worker dies:

  • 0 — clean exit.
  • 1 — an uncaught exception in the child.
  • -N — killed by signal N. -9 is the OOM killer (check dmesg); -11 is a segfault, which in practice means a C extension, not your Python.

exitcode is None while the process has not been started or is still running — which is what the second half of your return value asserts.

Loading visualization…