Python Module 15 – Async programming with asyncio
Mixing blocking code with asyncio
Move blocking calls off the event loop with to_thread or an executor, run async code from sync code with asyncio.Runner, and find stalls with debug mode.
What you will learn
- Run blocking functions with asyncio.to_thread or loop.run_in_executor
- Run async code from synchronous programs with asyncio.run and asyncio.Runner
- Detect blocking calls with debug mode and a loop-lag monitor
- Schedule work on the event loop from other threads with the thread-safe APIs
- Size the executor that blocking calls share, from the number of calls and their duration
Before you start
What you’ll learn · Before you start
What you will learn
- Run blocking functions with asyncio.to_thread or loop.run_in_executor
- Run async code from synchronous programs with asyncio.run and asyncio.Runner
- Detect blocking calls with debug mode and a loop-lag monitor
- Schedule work on the event loop from other threads with the thread-safe APIs
- Size the executor that blocking calls share, from the number of calls and their duration
Before you start
On this page
A coroutine that hashes 512 MiB of data stops a 10-millisecond ticker for the whole run: not one tick gets through,
as the first program below measures. Async code and blocking code meet in two directions. Inside a coroutine, a
blocking call: A call that holds its thread until it finishes, such as time.sleep() or a network client not written for asyncio. Inside a coroutine it stops the event loop, and with it every other task, for as long as it runs. More in “Async Python: the event loop mental model”, such as a synchronous database driver, an HTTP client that is not async,
or a long computation, freezes the event loop and every task on it until the call returns.
await asyncio.to_thread(fn, *args) moves such a call to a worker thread, so the loop keeps serving other tasks while
it runs; loop.run_in_executor(executor, fn, *args) does the same with an executor you choose, such as a process pool
for heavy computing.
In the other direction, synchronous code runs async code with asyncio.run(), once per program, or with
asyncio.Runner when it needs several calls on the same event loop. debug mode (asyncio): A setting of the asyncio event loop, turned on with asyncio.run(main(), debug=True), under which the loop adds checks and logs every step that holds it for more than 100 milliseconds., asyncio.run(main(), debug=True),
finds the blocking calls you missed by logging every step that holds the loop for more than 100 milliseconds.
A blocking call freezes every task until you move it to a thread
A ticker task records the time every 10 milliseconds while the program hashes 512 MiB of data, first by calling the
hash function directly inside a coroutine and then through asyncio.to_thread():
import asyncio
import hashlib
import time
DATA = bytes(range(256)) * (64 * 1024) # 16 MiB, hashed 32 times: 512 MiB of work
def checksum(data):
"""Blocking work, like hashing a big file: it keeps one thread busy for a while."""
h = hashlib.sha256()
for _ in range(32):
h.update(data)
return h.hexdigest()
async def ticker(ticks, stop):
while not stop.is_set():
ticks.append(time.perf_counter())
await asyncio.sleep(0.01)
async def measure(run_checksum):
ticks, stop = [], asyncio.Event()
task = asyncio.create_task(ticker(ticks, stop))
await asyncio.sleep(0.05) # the ticker is running
start = time.perf_counter()
digest = await run_checksum()
took = time.perf_counter() - start
stop.set()
await task
during = sum(start < t < start + took for t in ticks)
return digest, took, during
async def main():
async def on_the_loop():
return checksum(DATA) # called directly inside a coroutine
async def in_a_thread():
return await asyncio.to_thread(checksum, DATA)
d1, took1, ticks1 = await measure(on_the_loop)
d2, took2, ticks2 = await measure(in_a_thread)
print(f"same digest both ways: {d1 == d2}")
print(f"called directly: {ticks1} ticks of a 10 ms ticker during the hash")
print(f"with to_thread(): the ticker kept ticking during the hash: {ticks2 >= 0.5 * took2 / 0.01}")
asyncio.run(main()) Output
same digest both ways: True called directly: 0 ticks of a 10 ms ticker during the hash with to_thread(): the ticker kept ticking during the hash: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ticker_hash.py
The second run hands the call to a worker thread, and the loop goes on with its other tasks:
to_thread() moves the blocking call off the event loop's thread
Text description of the diagram
The diagram shows two threads.
1. In the event loop's thread, task A awaits asyncio.to_thread(checksum, data). The call is handed over to a worker thread of the loop's default executor, and task A waits without blocking the loop. 2. While checksum(data) runs in the worker thread, blocking only that thread, the event loop keeps running the other tasks: requests, timers and a ticker. 3. When checksum() returns, its result wakes task A, which resumes in the event loop's thread with the digest.
Called directly, the hash held the event loop’s thread for its whole run, and the ticker did not get a single turn:
in a server, every other request would have waited the same way. With to_thread() the digest was the same, and the
ticker kept its rhythm, because the call ran in a worker thread of the loop’s
default executor: The thread pool that an asyncio event loop lends to asyncio.to_thread(): a ThreadPoolExecutor, unless loop.set_default_executor() sets another. Every call of to_thread() in the program shares it. while the awaiting task simply waited. There is a second reason the
ticker could run: hashlib releases the
The global interpreter lock: in CPython's default build, a lock that lets only one thread run Python code at a time. C code such as hashlib can release it while it works.
while it hashes more than 2,047 bytes at once, so the worker thread did not compete with the loop for the interpreter. Pure-Python code holds
the GIL, which makes threads fine for waiting and for C code that releases the GIL, but not for Python code that
computes. Step through both runs, one move at a time:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The ticker runs. The ticker task records the time every 10 ms. For 0.05 s it ticks on time while measure() sleeps.
- The hash on the loop. measure() first calls checksum(DATA) directly inside the coroutine. Hashing 512 MiB holds the event loop's thread for the whole run.
- No turn for the ticker. The ticker's sleep has long ended, but the loop cannot resume it while the hash runs. The program counts 0 ticks of the 10 ms ticker during the hash.
- The same hash in a thread. The second run awaits asyncio.to_thread(checksum, DATA). The call goes to a worker thread of the loop's default executor, and the waiting task gives the loop back.
- The ticker keeps time. While the worker hashes, the loop runs the ticker on time. hashlib releases the GIL while it hashes more than 2,047 bytes at once, so the two threads do not compete for the interpreter.
- The digest wakes the task. checksum() returns in the worker thread, and its result wakes the waiting task. Both runs gave the same SHA-256 digest, and only the second kept the ticker ticking.
The calls to look for are the ones that wait or compute for more than a few milliseconds: synchronous HTTP clients
and database drivers, reading or writing large files, time.sleep(), starting a program and waiting for it, parsing
a very large document, and log handlers that send records over the network. Each one is harmless in a script and a
stall for every user in a server. Debug mode finds the ones you miss: each step it reports held the loop for more
than 100 milliseconds before giving it back at an await.
A blocking call inside a coroutine
report = load_report() in an async def holds the loop for as long as load_report() runs, so every other request
waits with it. Write report = await asyncio.to_thread(load_report), or call an async version of the library.
Checkpoint
Check: A blocking call stops the loop
Choose the bridge by whether the call waits or computes
Where a blocking call should go depends on what it does. Answer the three questions about a call of your own: the table marks the bridges that fit and says why the others do not.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Bridge | Blocking I/O | Python that computes | Context variables | Extra cost per call | A pool of its own |
|---|---|---|---|---|---|
| asyncio.to_thread() Runs the call in: the loop's default thread pool | Good the task waits and the loop runs on | Poor Python code holds the GIL, so the loop slows | Good copied into the thread | Good a hand-off to a thread | Poor always the default pool, shared by every to_thread() call |
| run_in_executor(), threads Runs the call in: the thread pool you pass | Good the same, with a pool size of your choice | Poor Python code holds the GIL, so the loop slows | Mixed not copied: pass what the call needs | Good a hand-off to a thread | Good the pool you pass and size |
| run_in_executor(), processes Runs the call in: another process, from the pool you pass | Mixed works, but pickling and a process cost more than a thread | Good its own interpreter and GIL, so it computes beside the loop | Poor not copied; the arguments are pickled | Mixed pickling and a hand-off to a process | Good the process pool you pass and size |
| An async library Runs the call in: the event loop itself | Good network and disk I/O with no thread at all | Poor no help: computing still holds the loop | Good kept | Good none | Good needs none: it waits on the loop |
How the answers decide
- “What does the call spend its time on?” Computing in Python code: rules out asyncio.to_thread(), run_in_executor(), threads and An async library. Python code that computes holds the GIL in a thread and holds the loop in a coroutine; only another process runs it beside the loop.
- “What does the call spend its time on?” Waiting for a network, a disk or a driver: adds a caveat to run_in_executor(), processes. A process works for a call that only waits, but pickling and a process cost more than a thread does.
- “What does the call spend its time on?” Waiting for a network, a disk or a driver and “Is there an async library that does the same job, in code you may change?” Yes: adds a caveat to asyncio.to_thread() and run_in_executor(), threads. An async library needs no thread at all, so it is the better fix where it exists; a thread is the bridge where it does not.
- “Is there an async library that does the same job, in code you may change?” No, or the code is not yours: rules out An async library. There is no async library to call, or the code is not yours to change.
- “Must these calls have a pool of their own, apart from every other blocking call?” Yes, with a size of their own: rules out asyncio.to_thread(). to_thread() always uses the loop's shared default pool; run_in_executor() takes a pool of your own, with its own size.
The best fix is an async library, an async HTTP client or database driver, because it needs no threads at all. When
none exists, or the code is not yours, to_thread() is the default bridge. A few more tools matter once real traffic
arrives. A time limit around to_thread() stops the wait, never the thread. loop.call_soon_threadsafe() lets
another thread schedule a callback on the loop. asyncio.run_coroutine_threadsafe() lets another thread run a
coroutine on the loop and wait for its result. loop.set_default_executor() changes the pool that to_thread() uses.
In short: an async library where one exists, to_thread() for the other calls that wait, a thread pool of your own
for calls that need their own limit, and a process pool for Python code that computes.
Checkpoint
Check: Choosing where a blocking call runs
asyncio.run() makes a new loop each time, and a Runner keeps one
A synchronous program, a script, a test or a command-line tool, enters async code through a runner. The client below holds an asyncio queue, as connection pools and rate limiters do:
import asyncio
import re
class Client:
"""A client with an asyncio queue inside, as connection pools and rate limiters have."""
def __init__(self):
self.ready = asyncio.Queue()
self.loops = set()
async def call(self, n):
self.loops.add(id(asyncio.get_running_loop()))
consumer = asyncio.create_task(self.ready.get()) # waits, so the queue binds to this loop
await asyncio.sleep(0)
self.ready.put_nowait(f"answer {n}")
return await consumer
try:
asyncio.get_event_loop()
except RuntimeError as e:
print("asyncio.get_event_loop():", e)
client = Client()
print("asyncio.run():", asyncio.run(client.call(1)))
try:
print(asyncio.run(client.call(2))) # a new loop, the same client
except RuntimeError as e:
print("asyncio.run() again:", re.sub(r" at 0x[0-9a-f]+", "", str(e)))
client = Client()
with asyncio.Runner() as runner: # one loop for every call
print("Runner:", runner.run(client.call(1)))
print("Runner:", runner.run(client.call(2)))
print("the Runner's calls ran on", len(client.loops), "loop") Output
asyncio.get_event_loop(): There is no current event loop in thread 'MainThread'. asyncio.run(): answer 1 asyncio.run() again: <Queue maxsize=0 tasks=1> is bound to a different event loop Runner: answer 1 Runner: answer 2 the Runner's calls ran on 1 loop
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 runner_calls.py
Version note
New in Python 3.14, as the run’s first line shows: asyncio.get_event_loop() raises RuntimeError when there is no
current event loop, instead of quietly creating one, so old code that started with it must move to a runner.
The first asyncio.run() worked. The second failed, because each asyncio.run() creates a new event loop, runs one
coroutine and closes the loop, and the queue had become bound to the first loop when a task waited on it.
asyncio.Runner keeps one loop, and one context, for all of its run() calls, so both calls succeeded and ran on the
same loop. Use asyncio.run() for a program’s single entry point and a Runner when synchronous code needs several
async calls that share objects. Neither works where a loop is already running in the same thread, as in a notebook:
both raise RuntimeError there, and the coroutine is simply awaited instead. Step through the run:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- No loop yet. In Python 3.14, asyncio.get_event_loop() raises RuntimeError when there is no current event loop, instead of quietly creating one, so code that started with it must move to a runner.
- The first asyncio.run(). asyncio.run() creates an event loop, runs one coroutine on it, and will close the loop when the coroutine returns.
- The queue is bound. Inside call(1) a task waits on client.ready, and from that moment the queue belongs to loop 1. The call returns answer 1.
- Loop 1 is closed. asyncio.run() closes loop 1 as it returns. The client object lives on, with its queue still bound to the closed loop.
- The second asyncio.run(). The second asyncio.run() creates loop 2. A task there waits on the same queue, which is bound to a different event loop, so the call fails with RuntimeError.
- One Runner, one loop. A new client goes through asyncio.Runner, which keeps one loop, and one context, for all of its run() calls. Both calls succeed, one after the other.
- Both calls on one loop. The client recorded the loop of each call, and the Runner's calls ran on 1 loop. Use asyncio.run() for a program's single entry point, and a Runner when sync code makes several async calls that share objects.
Checkpoint
Check: asyncio.run() and asyncio.Runner
Check yourself
One question at a time on blocking calls, the four bridges and the two runners, the weakest idea first.
Practice that adapts to you
Practise this lesson
8 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Finding blocking calls, and what a time limit cannot stop, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A blocking call rarely announces itself: it hides in a helper, a logging handler or a library. This part shows the log that debug mode writes, measures loop lag with a monitor task you can keep in production, and reproduces what happens when a time limit fires around a thread.
In this part:
- Debug mode names the slow step, with the log it writes
- A lag monitor that measures how long the loop was blocked
- Blocking calls that hide in plain sight, and their replacements
- A time limit stops the wait, not the thread, reproduced
1 diagram · 3 runnable examples (Python) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Crossing between threads and the loop, and sizing the pool, with numbers Pro: opens with a Pro, Premium or Ultimate pass
Threads that call back into async code need their own rules, and every blocking call shares one pool. This part reproduces the late wake-up of a non-thread-safe call, measures how pool size decides waiting time, sends computing work to processes and ends with an exercise.
In this part:
- Callbacks from another thread: a late wake-up reproduced
- How many threads: pool size decides the waves, measured
- Computing in a process pool, and what can cross the boundary
- Which bridge, by direction and workload: the trade-offs in one table
- Exercise: turn a blocking fetch loop into async code
- Turn a blocking fetch loop into async code
1 diagram · 3 runnable examples (Python) · 1 exercise with sample tests · about 10 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What does asyncio.to_thread() do, and what does it not do?
asyncio.to_thread(fn, *args) runs a blocking function in a worker thread of the event loop's default executor and returns a coroutine: awaiting it suspends the calling task without blocking the loop, so other tasks keep running, and it copies the caller's context variables into the thread. It suits blocking I/O and C code that releases the GIL. It does not make Python code that computes run in parallel, because that code holds the GIL in the default build. It does not make the function cancellable: a time limit or cancel() stops only the waiting task, while the thread runs on. And it adds no capacity, because every call shares one pool of min(32, CPU count + 4) threads unless you set another executor.
-
Mid to senior
After a release, the p99 latency of every endpoint of an async API rose from 40 ms to 900 ms, although only one new endpoint was added. That endpoint calls a synchronous storage client. Explain the link, how you would prove it, and the fix.
-
Senior to staff
A service wraps a slow blocking report call in asyncio.timeout(2) around asyncio.to_thread(). Callers now get fast time-outs, but after an hour of a slow back end, every to_thread() call in the service waits seconds before it even starts. Explain what happened and fix it.
-
Senior to staff
A large synchronous web application with worker threads must use a library that only has an async API, about 200 calls per request. Calling asyncio.run() for each call is slow and breaks the library's connection pool. Design the bridge.
Key takeaways
- A blocking call inside a coroutine stops every task on the loop until it returns.
await asyncio.to_thread(fn, *args)runs it in a worker thread; a process pool throughrun_in_executor()suits Python code that computes.asyncio.run()makes a new event loop on every call; use oneasyncio.Runnerwhen synchronous code makes several async calls.- Python 3.14’s
asyncio.get_event_loop()raisesRuntimeErrorwhen there is no current event loop.
Terms in this lesson
- debug mode (asyncio)
- A setting of the asyncio event loop, turned on with asyncio.run(main(), debug=True), under which the loop adds checks and logs every step that holds it for more than 100 milliseconds.
- default executor
- The thread pool that an asyncio event loop lends to asyncio.to_thread(): a ThreadPoolExecutor, unless loop.set_default_executor() sets another. Every call of to_thread() in the program shares it.
References
- asyncio: running in threads (asyncio.to_thread) (Python Software Foundation)
- asyncio: Runners (asyncio.run and asyncio.Runner) (Python Software Foundation)
- Developing with asyncio: debug mode, concurrency and multithreading (Python Software Foundation)
- asyncio event loop: executing code in thread or process pools (Python Software Foundation)
- asyncio event loop: loop.slow_callback_duration (Python Software Foundation)
- asyncio: run_coroutine_threadsafe (Python Software Foundation)
- threading: Event objects (Python Software Foundation)
- concurrent.futures: ThreadPoolExecutor and ProcessPoolExecutor (Python Software Foundation)
- concurrent.interpreters: multiple interpreters in the same process (Python Software Foundation)
- asyncio event loop: get_event_loop() (Python Software Foundation)
- hashlib: hash algorithms and the GIL (Python Software Foundation)
- Logging Cookbook: dealing with handlers that block (Python Software Foundation)
- asyncio: Subprocesses (Python Software Foundation)
- What's New In Python 3.14: asyncio (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress