Skip to content

← Concurrency Without Regret step 7 of 38

Medium Primitives

run_workers: results, errors and a join deadline

Give threads the two things threading.Thread does not give you: a result and an error.

def run_workers[T](fns: Sequence[Callable[[], T]], timeout: float) -> list[T]:
  • Run each callable in its own non-daemon thread.
  • If the join window of timeout seconds expires with any thread still alive, raise TimeoutError.
  • Otherwise, if any worker raised, re-raise the exception from the lowest-indexed failing worker, with its traceback intact.
  • Otherwise return one result per callable, in fns order.

The precedence matters and is deliberate: a hang outranks a failure, because you cannot honestly report results you have not finished collecting. “Lowest-indexed” rather than “first to fail” is what makes the outcome reproducible — whichever thread the OS happened to schedule first, the same input produces the same exception.

Why this exists

Thread(target=..., args=...) has no return channel. When run() raises, the exception goes to threading.excepthook, which prints to stderr and returns; the thread dies and your program carries on believing the work happened. join() returns normally either way — join means “finished”, not “succeeded”. That is the bug where a background refresher stops working and nobody notices for three weeks.

What the report proves

  • values — results in input order. The successful workers sleep longest-first, so completion order is the reverse of input order. Appending results as they finish fails here.
  • outcome / message — the right exception type and the worker’s own message, not a re-wrapped string.
  • traceback_ok — the propagated traceback still contains the frame _explode that actually raised. raise ValueError(str(original)) loses that and fails this check; re-raising the caught exception object keeps it.
  • any_daemon — must be False. A daemon thread is stopped abruptly at interpreter exit: no finally, no __exit__, no flush.
  • on_main / threads — every callable really ran on its own thread, never the caller’s.

Where the type system earns its keep

run_workers is generic in T, so run_workers(fns_returning_int, 1.0) is a list[int]. Compare that to Thread(target=fn, args=(...)), where args is effectively untyped — nothing checks that the tuple matches the target’s signature, and nothing describes what the target returns, because there is nowhere for a return value to go.

Collecting results is where --strict pushes back: results: list[T] = [None] * n does not type-check, and it should not, because None is not a T. A dict[int, T] filled by index and read back with a comprehension is the clean answer, and it is also what makes “one result per callable, in order” a statically visible property rather than a comment.

Loading visualization…