Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: A blocking call freezes every task until you move it to a thread · Choose the bridge by whether the call waits or computes · asyncio.run() makes a new loop each time, and a Runner keeps one · Check yourself. With a pass: 2 in-depth parts and 3 interview answers, about 13 minutes.

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():

A ticker while a big hash runs, two ways Python · ticker_hash.py
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:

Task A hands a blocking call to a worker thread with to_thread(); the loop keeps running tasks B and C; the result wakes task A.Task AEvent loop:other tasksWorkerthreadto_thread(checksum, data)await: A waitsruns tasks B and C,timers, a tickerchecksum(data) blocksonly this threadthe digest wakes task A

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:

Interactive · message by message A ticker while 512 MiB are hashed, two ways Step through ticker_hash.py. The ticker on the left should run every 10 ms; watch who holds the event loop's thread.
A ticker while 512 MiB are hashed, two ways, step 6 of 6. Messages so far: measure() to worker thread: to_thread(checksum, DATA); worker thread to measure(): the same digest.ticker taskticking every 10 msmeasure()same digest both waysworker threadhashing 512 MiBto_thread(checksum, DATA)the same digest

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The ticker runs. The ticker task records the time every 10 ms. For 0.05 s it ticks on time while measure() sleeps.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 In ticker_hash.py, how many ticks of the 10 ms ticker happened while the hash ran directly in the coroutine?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 0 ticks

    The hash held the event loop's thread for its whole run, so the ticker did not get a single turn: called directly, 0 ticks of a 10 ms ticker during the hash.

  2. Question 2 of 2 checksum() is replaced by a pure-Python loop that computes for one second. Does asyncio.to_thread() keep the ticker on time now?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No; Python code holds the GIL in the default build, so the worker competes with the loop and the ticker slows

    Threads are fine for waiting and for C code that releases the GIL, but not for Python code that computes; that code goes to a process pool.

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.

Interactive · answer and compare Where should this blocking call run? Answer three questions about the call. The table marks the bridges that fit and says why the others do not.
What does the call spend its time on?
Is there an async library that does the same job, in code you may change?
Must these calls have a pool of their own, apart from every other blocking call?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four places for a blocking call, against the five things that decide between them
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

  1. Question 1 of 2 A coroutine must call a storage SDK that has no async version. Which bridge is the default?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: await asyncio.to_thread(sdk.upload, data)

    The best fix is an async library. When none exists, or the code is not yours, to_thread() is the default bridge.

  2. Question 2 of 2 Report calls that block for seconds must not take the threads that every other to_thread() call shares. Which bridge fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: loop.run_in_executor() with a ThreadPoolExecutor of their own

    run_in_executor() takes the executor you pass, so calls that need their own pool or limit get one, and loop.set_default_executor() changes the pool that to_thread() uses.

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:

asyncio.run() twice, then one Runner Python · runner_calls.py
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:

Interactive · step by step asyncio.run() twice, then one Runner Step through runner_calls.py. Your synchronous code is on the left; each box on the right is one way into async code.
asyncio.run() twice, then one Runner, step 7 of 7. Showing your sync code (Runner calls: 1 loop), client.ready (bound to the Runner's loop), asyncio.run(), first (answer 1, loop closed), asyncio.run(), second (RuntimeError), asyncio.Runner() (answer 1, answer 2).call(1)call(2)your sync codeRunner calls: 1 loopclient.readybound to the Runner's loopasyncio.run(), firstanswer 1, loop closedasyncio.run(), secondRuntimeErrorasyncio.Runner()answer 1, answer 2

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. The first asyncio.run(). asyncio.run() creates an event loop, runs one coroutine on it, and will close the loop when the coroutine returns.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 Why did the second asyncio.run() in runner_calls.py fail?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Each asyncio.run() creates a new event loop, and the client's queue was already bound to the first one

    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.

  2. Question 2 of 2 Code in a notebook, where an event loop is already running in the same thread, calls asyncio.run(main()). What happens, and what is the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: RuntimeError; await main() instead

    Both runners raise RuntimeError where a loop is already running in the same thread, as in a notebook, and the coroutine is awaited instead.

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.

  1. Question 1 of 8 What does asyncio's debug mode log by default when a task step runs for 0.3 seconds?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: A warning about that step and how long it took, because it is over the 100 ms limit

    With debug mode on, asyncio.run(main(), debug=True), the event loop logs every step that holds it for more than 100 milliseconds, which finds the blocking calls you missed.

  2. Question 2 of 8 Which of these blocks the event loop when it runs inside a coroutine?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: time.sleep(1)

    A call that waits or computes without awaiting, such as time.sleep(), freezes the event loop and every task on it until it returns.

  3. Question 3 of 8 What does ticker_hash.py print?

    What does this program print? Choose one answer.

    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())
    Show the answer: Question 3 of 8

    Answer: it prints

    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

    Both ways compute the same SHA-256 digest. Called directly, the hash holds the event loop's thread, so the ticker task cannot run once until it ends. With to_thread(), the hash runs in a worker thread, and because hashlib releases the GIL while hashing large buffers, the ticker keeps its 10 ms rhythm.

  4. Question 4 of 8 Which bridge copies the caller's context variables into the thread that runs the call?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: asyncio.to_thread()

    to_thread() copies the context variables into the thread; run_in_executor() does not, and a process pool gets only what it pickles.

  5. Question 5 of 8 An async web service must resize uploaded images with a pure-Python function that computes for about a second. Where should the call run?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: In a process pool, through loop.run_in_executor(pool, resize, data)

    Called directly, the computation blocks the event loop for a second. A thread of the default build would hold the GIL while running Python code, so the loop would still be slowed. A process has its own interpreter and GIL, so the computation runs beside the loop; the function and its arguments must be picklable.

  6. Question 6 of 8 What does asyncio.run(main()) do?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: It creates a new event loop, runs main() to its end and closes the loop

    asyncio.run() creates an event loop, runs one coroutine and closes the loop; use it for a program's single entry point.

  7. Question 7 of 8 A synchronous command-line tool calls two coroutines in turn, and both use one client object that holds an asyncio queue. Why is asyncio.Runner a better fit than calling asyncio.run() twice?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: A Runner runs both calls on one event loop, while each asyncio.run() creates a new loop and the queue is bound to the first

    asyncio.run() creates an event loop, runs one coroutine and closes the loop. Objects that waited on the first loop are bound to it, so the second call fails with "bound to a different event loop". A Runner keeps one loop and one context for all of its run() calls, and still runs them one after another.

  8. Question 8 of 8 Synchronous test code calls three async functions that share one client with a connection pool inside. What do the tests use?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: One asyncio.Runner for the three calls

    Use asyncio.run() for a single entry point and a Runner when synchronous code makes several async calls that share objects, such as a client with a pool or a queue.

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.

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

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

  4. 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.

    Model answer, Pro. First step and follow-ups

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 through run_in_executor() suits Python code that computes.
  • asyncio.run() makes a new event loop on every call; use one asyncio.Runner when synchronous code makes several async calls.
  • Python 3.14’s asyncio.get_event_loop() raises RuntimeError when 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.