We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Concurrency Without Regret step 26 of 38
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 callsfn()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/doc—functools.wrapsdid its job. -
outcome/message— an exception raised in the worker thread propagates with its original type and message. -
values—gather_blockingresults in input order, even withlimitfar below the number of callables. -
leftover_threads—0. 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…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.