Sync calls in asyncio.

Tags: Software, Python, Asyncio, Intermediate Python

Asyncio in Python is a double edged sword, if you’re making lots of calls out to IO with very little actual compute happening locally within the app it’s a godsend for performance. Honestly though, is that something that you’re doing regularly? If you are great, keep doing your thing, if not then using vanilla sync python is probably going to make your life easier.

I’ve inherited quite a bit of async code recently that doesn’t really need to be async. In a couple of places I’ve seen code like this:

import time

from fastapi import FastAPI

app = FastAPI()

def slow_function():
	time.sleep(1)

@app.get("/slow")
async def slow():
	slow_function()
    return "OK"

Which has caused me to raise a few eyebrows. Maybe it’s the first-steps docs in fastapi showing async function calls that cause people to do this? What’s happening in the above code is fastapi is mounting the slow function into the router as an async function, this means calls to the endpoint are done on the asyncio event loop. ANY1 blocking call within this event loop needs to use asyncio else you will block the entire eventloop. Asyncio is just a way for scheduling callbacks, it is not a magic wand for python to be a concurrent language.

To prove this we can add another endpoint:

@app.get("/fast")
async def fast():
    return "OK"

If we call the /slow in one tab and then /fast in another, the expected behaviour would be that the fast HTTP call will return first, then after 1 second the slow call will return. What you’ll actually see is the slow call will return after 1 second, and then the fast call will immediately return after. This is as a result of the event loop being blocked.

I don’t think it’s the fault of the fastapi docs, the second tab is on how async works. People are lazy and just copy paste without thinking, I know I have been guilt of it. There’s a real worked example on github if you need to see it with your own eyes. The solution to all of this is either the hard one where you refactor your call stack to use asyncio all the way through the stack, or you can do this:

def slow_function():
	time.sleep(1)

@app.get("/slow-sync")
def slow_sync():
	slow_function()
    return "OK"

Now FastAPI mounts your slow_sync function on the router but schedules calls to it on a ThreadPool and blocking calls in one function won’t affect another. Sure you’re going to lose some performance, but honestly you probably don’t need it today anyway and if you do need it you’ll know. If you don’t believe me, take the advice of David Beazley (4:30 timestamp), hell he even says we can quote him on it…


  1. If you do need to do sync calls within asyncio you can, you just need to pass it off the event loop. This can be done using loop.run_in_executor. The python docs have some great examples of this. ↩︎