Python's asyncio library lets programs run many I/O‑bound tasks concurrently within a single thread. Under the hood, an event loop schedules coroutines — functions declared with async def — and gives them time to run until they pause at an await. Because the loop runs on one thread, calling a traditional blocking function (for example, time.sleep() or an API from a library that isn't written for asyncio) will halt everything. This article explores two tools for solving that problem: asyncio.to_thread() and asyncio.gather(). We'll look at simple examples and discuss when each tool makes sense.

A quick primer on asyncio concurrency

When you await an asyncio‑compatible function, the coroutine yields control to the event loop, allowing other tasks to run. And asyncio.gather() runs awaitable objects in the aws sequence concurrently and returns a list of results in the same order. If any of the awaitables raises an exception, the first exception is propagated and other coroutines continue running. This behavior makes asyncio.gather() useful for starting several coroutines at the same time and waiting until they all finish.

However, blocking functions pose a problem. Fortunately, asyncio.to_thread() returns a coroutine that asynchronously runs a function in a separate thread. It offloads the given function to a thread from the event loop's default thread pool so the loop isn't blocked. Due to Python's Global Interpreter Lock, this is intended primarily for I/O‑bound functions. With those ideas in mind, let's look at how to solve our problem.

Using asyncio.to_thread() to offload blocking calls

When you need to run a blocking I/O operation in an asyncio program — such as reading from disk or calling a synchronous library — asyncio.to_thread() is often the simplest option. This function internally calls loop.run_in_executor(), effectively running the blocking function in the event loop's default thread pool .

How to_thread() works

asyncio.to_thread() takes a function and its arguments, runs it in a separate thread and returns a coroutine that yields the function's result when awaited. The function call does not start until the coroutine is awaited. The Context of the event loop (context variables) is propagated to the new thread. Because the call uses the event loop's thread pool, you don't need to manage any threads yourself.

asyncio.to_thread() allows the event loop to treat a blocking function call as a coroutine and execute it asynchronously. It is specifically designed for blocking I/O, not CPU‑bound computations.

Example: wrapping a blocking function

Suppose you have a blocking function that sleeps for three seconds. If you call it directly inside an async function, it will block the event loop. Here's how to offload it using asyncio.to_thread():

import asyncio
import time

# A synchronous blocking function (e.g. file read or time.sleep).
def blocking_operation() -> None:
    print("Start blocking operation")
    time.sleep(3)  # Simulate a long I/O call.
    print("Blocking operation done")

# A non‑blocking async function.
async def non_blocking_operation() -> None:
    await asyncio.sleep(3)  # Yields control to the event loop.

async def main() -> None:
    # Schedule the blocking function in a thread.
    blocking_coro = asyncio.to_thread(blocking_operation)
    # Schedule the non‑blocking coroutine.
    non_blocking_coro = non_blocking_operation()
    # Run both concurrently and wait for completion.
    await asyncio.gather(blocking_coro, non_blocking_coro)

asyncio.run(main())

Both tasks start at the same time and finish after roughly three seconds. Without using asyncio.to_thread(), the blocking call would prevent the other coroutine from running until it completed.

When to use asyncio.to_thread()

Use asyncio.to_thread() when you have a synchronous function that performs I/O‑bound work and you need to integrate it into an asyncio application. Typical examples include:

  • File I/O (reading/writing large files).
  • Network requests using synchronous libraries (e.g., requests).
  • CPU‑light operations from third‑party libraries that block the event loop.

Because asyncio.to_thread() uses threads, it carries the usual thread‑safety caveats. Don't share mutable data between threads without proper locking. Also, be mindful that thread‑pool concurrency is limited; the event loop's default thread pool has a finite number of threads, so heavy or numerous blocking calls may still starve other tasks.

For CPU‑bound functions — intensive number crunching or large data processing — use concurrent.futures.ProcessPoolExecutor through asyncio.run_in_executor(), which offloads work to separate processes and bypasses the GIL.

By understanding these tools and using them appropriately, you can write asyncio applications that gracefully integrate existing synchronous code and run multiple tasks concurrently with clear, readable Python.