Skip to content

← Concurrency Without Regret step 26 of 38

Medium Primitives

asyncify: a ParamSpec wrapper that does not block the loop

Write the two functions every async codebase ends up needing, and type them properly.

def asyncify[**P, T](fn: Callable[P, T]) -> Callable[P, Coroutine[Any, Any, T]]

async def gather_blocking[T](fns: Sequence[Callable[[], T]], *, limit: int) -> list[T]

asyncify turns a blocking callable into a coroutine function. The parameter list, the return type, __name__ and __doc__ all survive.

gather_blocking runs blocking callables on a dedicated pool of at most limit threads, returns results in input order, and shuts the pool down on every exit path. Give it thread_name_prefix="gb" — the report counts threads with that prefix to prove the shutdown happened.

Why a dedicated pool

asyncio.to_thread uses the loop’s default executor: created lazily, with a max_workers you cannot set from the call site, shared by everything in the process. A burst of slow work queues behind whatever else happens to be using it, and you have built a global bottleneck nobody can see. A known-slow workload gets its own pool.

What the report proves

  • ticks — a heartbeat task ticks five times and then releases the blocking calls. The blocking functions wait for that signal. If your implementation calls fn() on the loop thread, the signal never arrives in time and the value comes back as "stalled". That is the whole “does-not-block-the-loop” assertion, expressed without measuring anything.
  • value — keyword and positional arguments reach the wrapped function.
  • name / docfunctools.wraps did its job.
  • outcome / message — an exception raised in the worker thread propagates with its original type and message.
  • valuesgather_blocking results in input order, even with limit far below the number of callables.
  • leftover_threads0. A pool that is never shut down leaves its idle workers parked forever.

Where the type system earns its keep

This is the problem where Callable[..., Any] is a real failure and not a style note. It erases the parameters and the return type, so every call through the wrapper type-checks — including the ones with the arguments in the wrong order — and every result is Any, contaminating everything downstream. --strict will not object, because explicit Any is precisely what --strict does not catch.

[**P, T] (PEP 612) is the fix: P captures the whole parameter list and replays it on the wrapper, T carries the return type through, and the *args: P.args, **kwargs: P.kwargs pairing is required and checked.

Note the return is Coroutine[Any, Any, T], not Awaitable[T]. Wide in parameters, narrow in returns: Coroutine tells the caller they have a real coroutine object, which is what TaskGroup.create_task demands.

Loading visualization…