Understanding Asyncio

A recent article by Jason Goldstein expressed the author’s difficulty understanding and using Asyncio, especially in a Flask context. Asyncio in a Flask context is the exact experience I have with Quart, so I hope I can add something to the conversation this author started.

Asyncio provides another tool for concurrent programming in Python, that is more lightweight than threads or multiprocessing. In a very simple sense it does this by having an event loop execute a collection of tasks, with a key difference being that each task chooses when to yield control back to the event loop.

With Asyncio the yield must be explicitly coded, by using the await keyword to await something that is asynchronous from within something that is asynchronous. Additionally anything that is asynchronous must also be explicitly marked as such, using the async keyword. This causes an immediate headache as most existing code does not do this, and hence Asyncio does not work with this code. It then causes a second headache in that now everything bar the main function must be asynchronous if anything is asynchronous.

The reason to switch to Asyncio accepting the additional complication is hinted in its name, IO operations. Synchronous IO operations block execution until the IO is complete whereas asynchronous IO operations yield to the event loop allowing something else to be executed.

Feedparser and Sans-IO

The Feedparser library that Goldstein wished to use has no asynchronous code, so Goldstein wrapped the synchronous calls in asynchronous functions. This allowed Asyncio to be used, but offers only complication as the IO operations still blocked execution.

Feedparser like many Python libraries tries to be as helpful as it can be by combining the IO and parsing. Yet with the advent of Asyncio, libraries can be more helpful by being Sans-IO, i.e. leaving the IO as a choice of the library user. As it turns out Feedparser can do this,

async def fetch_feed(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
raw_feed = await response.text()
return feedparser.parse(raw_feed)

The key, to reiterate, is that the response (text) is awaited yielding this execution to the event loop thereby allowing other requests to be made concurrently. The event loop returns to this specific execution when the relevant IO operation has completed.

Yielding to the event loop

The yield in the above example, and in general for Asyncio is explicit and therefore clear to reason about, equally it is hopefully now clear that something as simple as

async def hello_world():
print("Hello World!")

does not yield, and will not run concurrently. What is less clear is that this example

async def outer():
await inner()
async def inner():

will also not yield, and hence not run concurrently. I think the pre Python3.5 syntax makes this clear

def outer():
yield from inner()
def inner():

as there is clearly no yield. A good rule of thumb is that only the Asyncio primitives actually yield, and the established practice is to await asyncio.sleep(0) if you want to ensure a yield.

Context management

Goldstein’s also questioned why async with session.get(url) as response: is required to make a request with Aiohttp. Specifically I think he is asking why async with rather than just a pain with. As is the common theme of this article, Python is explicit regarding asynchronous calls, and hence if the try, finally combination the with replaces has an await it must be an async with. For example if the session must be opened or finalised via an await,

response = await session.get(url)
await response.release()

a synchronous with cannot be used.

Creating the event loop

The final aspect of Goldstein’s article relates to creating the event loop itself within a Flask view function. As Flask does a lot of IO (every request/response at least), I think Flask should handle the event loop, which is what Quart does. So instead of the Flask example he gave, I propose a Quart example