skip to content

Inside a running asyncio loop, how can a sync function get a coroutine's result?

level: middleimportance: must knowfreq 50%

answer

  1. A loop cannot drive itself
  2. The thread is already inside it
  3. Two spellings of the same RuntimeError
  4. Blocking must happen off the loop thread
  5. Colour the caller, or move the frame

basics

~20 s

Not by starting another loop: asyncio.run() raises RuntimeError when a loop is already running in that thread. Either make the caller async and await, or move the sync function onto a worker thread and bridge back with asyncio.run_coroutine_threadsafe().

solid answer

~40 s

A loop cannot be re-entered. If a coroutine calls a synchronous helper and that helper calls `asyncio.run()`, you get `RuntimeError: asyncio.run() cannot be called from a running event loop`; the older `run_until_complete()` path reports `RuntimeError: This event loop is already running`. Both mean the same thing: this thread is already inside the loop, so it cannot also drive it. There are three honest fixes. Make the helper a coroutine and `await` - propagate async upward, which is almost always right. Or, if it must stay synchronous, run *the whole helper* on a worker thread with `await asyncio.to_thread(helper, ...)`, and inside that thread use `asyncio.run_coroutine_threadsafe(coro, loop).result(timeout)` against the loop captured earlier. Or give the sync world its own loop in a dedicated thread. Monkey-patching the loop to allow re-entry is not a fix.

code

pycon · 11 lines
pycon
>>> import asyncio
>>> async def inner():
...     return 42
...
>>> async def outer():
...     return asyncio.run(inner())
...
>>> asyncio.run(outer())
Traceback (most recent call last):
  ...
RuntimeError: asyncio.run() cannot be called from a running event loop

go deeper

for a junior

Recognise the RuntimeError about a loop already running and know that it means you tried to start a loop inside one. The fix you should reach for first is making the caller a coroutine and awaiting.

for a middle

Explain why a loop cannot be re-entered, the difference between the asyncio.run() and run_until_complete() messages, and why spinning up a second loop in the same thread breaks objects bound to the first.

for a senior

Show you can pick a fix under constraints: propagate async, bridge from a worker thread with a timeout, or host a dedicated loop thread - and state each option's cost in pool workers, cancellation semantics and deadlock exposure.

for a principal

Own the boundary policy. Decide whether the codebase is async-core or sync-core, forbid sync-over-async in service code including nested-loop shims, and plan the migration so the colour boundary sits at one reviewable edge.

## Why re-entry is impossible An event loop is a `while` loop over ready callbacks running on one thread. Being inside a coroutine means that loop's frame is already on this thread's stack, part-way through a callback. Starting another iteration of the same loop from inside that callback would re-enter the scheduler with half-finished state; asyncio refuses instead. The refusals differ by entry point but not in meaning: * `asyncio.run(coro)` checks for a running loop first and raises `RuntimeError: asyncio.run() cannot be called from a running event loop`. * `loop.run_until_complete(coro)` on the loop you are already inside raises `RuntimeError: This event loop is already running`. Creating a *fresh* loop in the same thread and running it does not help either: it sets a second loop as current, and any object created under the outer loop - a task, a future, a lock, a connection pool - is bound to the wrong loop and will misbehave or raise about attachment to a different loop. ## Where the situation comes from Almost always from a boundary that was never converted. A synchronous helper - a cache lookup, an audit hook, an ORM-style lazy attribute, a serializer callback - is called deep inside async code, and it needs something only an async client can give it. The temptation is to "just run" the coroutine right there. That is *sync over async*, and it is the everyday production hang: either the immediate `RuntimeError`, or, if someone gets past it, a thread deadlocked waiting on a loop it is itself blocking. ## Fix 1: make the caller async The function that needs an `await` becomes `async def`, its callers become `async def`, and the change stops at the outermost entry point that already runs the loop. This is the fix that removes the problem rather than moving it. The objection is always that the colour change ripples; that ripple is information, showing exactly how much of the codebase sits on the I/O path. ## Fix 2: push the sync code onto a thread and bridge back When the helper genuinely cannot change - a callback signature you do not own, a third-party extension point - do not run the loop from the loop thread. Move the helper off it: ```python loop = asyncio.get_running_loop() result = await asyncio.to_thread(helper, loop, arg) ``` and inside `helper`, which is now on a worker thread, block on the loop legitimately: ```python value = asyncio.run_coroutine_threadsafe(fetch(arg), loop).result(10) ``` This works because the blocking wait happens on a thread that is *not* the loop thread, so the loop stays free to run `fetch`. Its costs are real: one pool worker is parked for the whole call, so a burst of such calls can exhaust the default executor - and if the loop thread is ever blocked, every bridged worker blocks with it. Always pass a timeout to `.result()`, and remember cancellation does not cross the bridge in either direction. ## Fix 3: give the sync world its own loop For a mostly-synchronous program that needs async pieces, start a loop with `run_forever()` on a dedicated daemon thread and submit everything to it with `run_coroutine_threadsafe`. The blocking code never sees a loop on its own thread, so re-entry never arises. That is the shape the standard bridge helpers implement; libraries like `anyio` package it as a portal, and the ASGI-side `asgiref` package exposes it as `async_to_sync`/`sync_to_async` pairs with thread-affinity control. Under all of them are the same two primitives, and their caveats travel too. ## What not to do Monkey-patching the loop so it can be re-entered - the popular nested-loop shim - makes the exception disappear without making re-entry safe. Ordering guarantees break, tasks resume inside other tasks, and libraries that assume one activation per loop misbehave in ways that surface as impossible interleavings much later. It is acceptable in a notebook, never in a service. Equally wrong is `time.sleep()` or a blocking `.result()` on the loop thread "just until the future is done". The loop is the thing that would complete that future; blocking it guarantees the wait never ends. ## The diagnostic habit When you see the `RuntimeError`, read the traceback for the *synchronous* frame between two async frames. That frame is the boundary, and the decision is only ever: move the boundary up (make it async), or move the frame off the loop thread (bridge it). Anything else is a workaround with a hang attached.

  • Why is creating a brand-new event loop in the same thread not a valid workaround?
    Because objects are bound to the loop that created them. Tasks, futures, locks and connection pools made under the outer loop cannot be driven by a second one, so you get errors about a future attached to a different loop - or, worse, silent misbehaviour. It also does nothing about the outer loop, which stays blocked on the stack frame below you the entire time.
  • What are the costs of the run-it-on-a-thread-and-bridge-back pattern?
    Each bridged call parks one pool worker for its full duration, so a burst can exhaust the shared executor and stall unrelated offloads. Cancellation does not cross the bridge: cancelling the awaiting task leaves both the thread and the submitted coroutine running. And the pattern only stays deadlock-free while the loop thread itself never blocks, which is a property of the whole service rather than of this call site.
  • Someone proposes a library that patches the loop to allow nested asyncio.run() calls. What is your position?
    Fine for exploratory notebook work, not for a service. Re-entering a loop resumes tasks inside other tasks, breaks the ordering guarantees libraries rely on, and turns a loud RuntimeError into interleavings that surface as corruption much later. The supported answers are to propagate async upward or to bridge from a thread that is not the loop's.

saying these in an interview costs you the question

  • Calling asyncio.run() inside a coroutine to await something
  • Creating a second event loop in the same thread
  • Blocking on a future from the loop thread until it completes
  • Reaching for a nested-loop monkey-patch in production code
  • Believing the error means the loop is broken rather than re-entered
  • Treating a sync-over-async hang as a slow dependency

context