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…
-
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. ↩︎