Running blocking code: to_thread

asyncio · Practical · 10 min

Real programs mix in sync libraries; offloading keeps the event loop responsive.

When you must call blocking code from async code (a sync SDK, file I/O, time.sleep, a C library), don't call it directly: it stalls every coroutine. Instead:

result = await asyncio.to_thread(func, *args, **kwargs) runs func in the loop's default ThreadPoolExecutor and awaits the result. Context variables are propagated. Because blocking I/O releases the GIL, many such calls can overlap.

For CPU-bound Python code, threads don't help on a standard (GIL) build. Use a process pool:

loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
    r = await loop.run_in_executor(pool, cpu_heavy, data)

run_in_executor(None, func, *args) uses the default thread pool and accepts only positional args (wrap with functools.partial for keywords).

The opposite direction, calling async code from another thread, uses asyncio.run_coroutine_threadsafe(coro, loop), which returns a concurrent.futures.Future, and loop.call_soon_threadsafe(cb) for plain callbacks. Never touch loop objects from other threads without these.

The default executor has a limited number of worker threads (min(32, os.process_cpu_count() + 4) in 3.13), so thousands of slow blocking calls will queue up; for that scale, find an async library.

Example

import asyncio, time

def blocking_io(n):
    time.sleep(0.5)                     # a sync library call
    return n * 10

async def heartbeat():
    for _ in range(3):
        print("tick")
        await asyncio.sleep(0.2)

async def main():
    t0 = time.perf_counter()
    results = await asyncio.gather(
        asyncio.to_thread(blocking_io, 1),
        asyncio.to_thread(blocking_io, 2),
        heartbeat(),                    # keeps ticking meanwhile
    )
    print(results[:2], f"{time.perf_counter() - t0:.1f}s")

asyncio.run(main())
# tick / tick / tick, then: [10, 20] 0.6s

Remember

  • asyncio.to_thread(func, ...) awaits a blocking call without freezing the loop
  • Use run_in_executor with a ProcessPoolExecutor for CPU-bound work
  • run_coroutine_threadsafe submits coroutines from other threads

Watch out for

  • Using to_thread for CPU-heavy pure-Python work and expecting a speedup on a GIL build.

Check yourself

  1. A sync SDK call takes 2 s and blocks. How do you call it from a coroutine without freezing other tasks?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/asyncio-task.html#running-in-threads