2025, Nov 28 13:00
How to call asyncio coroutines from synchronous code safely: avoid Thread.join deadlocks, use run_in_executor and run_coroutine_threadsafe
Call asyncio coroutines from sync code safely: avoid Thread.join deadlocks that block the event loop; use run_in_executor and run_coroutine_threadsafe.
Calling an asyncio coroutine from a synchronous function without turning that function into async is a common need in mixed codebases. A tempting approach is to jump to threads and coordinate with run_coroutine_threadsafe. But if you pair that with traditional Thread.join, the whole thing can freeze. Here’s why that happens and how to fix it correctly.
Problem statement
You want to run an async coroutine from a regular function, without declaring the function async and awaiting it. The first idea is to spin up a thread and use run_coroutine_threadsafe, but the program deadlocks. Starting a new thread doesn’t help when you still block the event loop with a join.
Repro: the blocking version
import asyncio
from threading import Thread
async def job_b():
print("starting job_b...")
await asyncio.sleep(1)
print("finished job_b.")
return "done"
def sync_task(hub=None):
print("running sync_task...")
if not hub:
hub = asyncio.get_running_loop()
assert hub
fut = asyncio.run_coroutine_threadsafe(
job_b(),
hub)
outcome = fut.result()
print(f"{outcome=}")
print("done sync_task...")
async def job_a():
print("starting job_a...")
await asyncio.sleep(1)
loop = asyncio.get_running_loop()
t = Thread(target=sync_task, args=(loop,))
t.start()
t.join()
print("finished job_a.")
if __name__ == '__main__':
asyncio.run(job_a())
Why it deadlocks
Python threading synchronization primitives such as Thread.join suspend the current thread. If you call join from within a coroutine running on the event loop thread, you block the event loop. A blocked loop cannot drive the coroutine you scheduled via run_coroutine_threadsafe, so the Future never completes, and the program stalls. Not using a thread at all but blocking in the loop leads to the same outcome for the same reason.
The correct approach: use run_in_executor
Instead of blocking the event loop with a join, offload the synchronous function to a thread using loop.run_in_executor. That way, the event loop stays responsive while the thread calls run_coroutine_threadsafe and waits for the result. The Future can progress because the loop is alive.
import asyncio
async def job_b():
print("starting job_b...")
await asyncio.sleep(1)
print("finished job_b.")
return "done"
def sync_task(hub: asyncio.AbstractEventLoop):
print("running sync_task...")
fut = asyncio.run_coroutine_threadsafe(
job_b(),
hub)
outcome = fut.result()
print(f"{outcome=}")
print("done sync_task...")
async def job_a():
print("starting job_a...")
await asyncio.sleep(1)
loop = asyncio.get_running_loop()
task = loop.run_in_executor(None, sync_task, loop)
await task # the event loop keeps running
print("finished job_a.")
if __name__ == '__main__':
asyncio.run(job_a())
Each event loop has a default ThreadPoolExecutor used when you pass None as the executor. It has a limited number of workers and is intended for computational and non-blocking work, while the pattern above uses it for blocking work. You can increase the worker count with loop.set_default_executor, or assign different ThreadPoolExecutor instances to different subsystems so that one area doesn’t hog all threads. Threads are created lazily.
Alternative you can consider
You can also use asyncio.run to create a new event loop in the current thread and run the coroutine there. Only call it from a thread that doesn’t already have a running event loop, otherwise it will raise an exception. Sending the coroutine back to the original loop, as shown above, is a good and performant option. Another approach is to maintain a shared daemon thread with its own event loop and dispatch coroutines to it.
Why this matters
Understanding how blocking calls interact with asyncio is crucial for reliability and throughput. Accidentally blocking the event loop disables scheduling, timers, and I/O processing, which then cascades into deadlocks and timeouts. Using run_in_executor preserves responsiveness while still letting legacy or synchronous code interoperate with async code safely.
Takeaways
Don’t block the event loop with Thread.join or other blocking primitives when trying to run coroutines from synchronous code. Delegate the synchronous entry point to a separate thread using loop.run_in_executor, then communicate with the running loop via run_coroutine_threadsafe. If you need more control over concurrency, adjust the default executor or use dedicated ThreadPoolExecutor instances. In cases where it fits better, create a new event loop on a separate thread with asyncio.run, ensuring that thread has no loop running. Keep the loop unblocked, and the rest falls into place.