I am trying to understand how async / await works from a high-level, semi-abstract perspective.
There are quite a few long and complicated explanations of async / await online, some of which appear excellent for what they set out to do (this might include some of the answers and links in this and this SO question, for example). But I also suspect that there is a simpler mechanistic explanation using stacks or queues for "scientists and engineers" who just want a sense of how asyncio prioritizes various chunks of code as it runs over an async function.
To keep the question concrete, I'll focus on a relatively simple example in which an asynchronous library like aiohttp or xhttp is used to make a series of get requests for a collection of URLs. Something like:
import time
import asyncio
import aiohttp
async def fetch_url(session, url):
async with session.get(url) as response:
return await response.text()
async def fetch_urls(session, url):
print(url + ' fetch 1 started')
text_response = await fetch_url(session, url)
print(url + ' fetch 1 ended')
await asyncio.sleep(4)
print(url + ' fetch 2 started')
text_response = await fetch_url(session, url)
print(url + ' fetch 2 ended')
return text_response
async def main():
start_time = time.time()
urls = [
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/3',
]
async with aiohttp.ClientSession() as session:
tasks = [fetch_urls(session, url) for url in urls]
results = await asyncio.gather(*tasks)
for i, result in enumerate(results):
print(f'Response {i+1} received with {len(result)} chars')
print(f'Time taken: {time.time() - start_time:.2f} seconds')
if __name__ == '__main__':
asyncio.run(main())
The above code could have many more URLs of different sorts in urls, and many more repetitions of asyncio.sleep and fetch_url in the fetch_urls function. Additionally, the response delays of the URLs could be highly variable and we could implement highly variable periods of asyncio.sleep as well.
In an effort to speak precisely, I'll now define a few terms.
Let a "chunk" of code refer to a sequence of commands that are executed one after another such that the execution of each command in the chunk must finish before execution of the next command can begin. A non-async function would be a single chunk of code.
In the above code, it seems loosely like an async before a def tells Python that the code of the corresponding async function (more accurately, "coroutine") should not be treated as a single chunk but rather a series of chunks, with the break points between the chunks indicated by the await commands. For example, fetch_urls above has 4 chunks which we could describe as print/fetch, print/sleep, print/fetch, and print/return. Between each sequential pair of chunks there is typically a delay of some sort, often of unknown length.
Is this an acceptable abstraction so far?
Assuming so, each executed instance of fetch_urls seems to be called a "task". Thus, we could represent the first task as an ordered sequence: t0 = (c00, d00, c01, d01, c02, d02, c03), where t represents "task", c represents "chunk", and d represents "delay".
It then seems that asyncio starts the run of main by directing Python to execute the first chunk of each task in the order that the tasks are provided in main. Once the execution of a chunk completes, the delay between it and the next chunk begins, and once this delay ends, the next chunk of the task "arrives" for prioritization, and seems to be prioritized in something like a FIFO manner. But what specifically is this scheme of prioritization? For example, suppose that the execution of chunk c03 is currently underway and chunk c13 arrives and then chunk c22 arrives. Is c13 or c22 executed first after the execution of c03 concludes?
UPDATE: Below is a simple example of code that seems to suggest that at least some part of the queuing prioritization of asyncio is FIFO rather than, for example, ordering by activation time.
from time import perf_counter as pc
import asyncio
pc0 = pc()
async def sleep_then_sum(k, d, n):
print(f'sleep_then_sum {k} started at {pc()-pc0} sec')
print(f'sleep_then_sum {k} falls asleep at {pc()-pc0} sec')
await asyncio.sleep(d)
print(f'sleep_then_sum {k} starts computing sum at {pc()-pc0} sec')
sn = sum(range(n))
print(f'sleep_then_sum {k} done at {pc()-pc0} sec')
return sn
async def main():
m = 3
task_list = m * [None]
delay = [1, 3, 2]
n_to_sum = [2*10**8, 10**8, 10**8]
async with asyncio.TaskGroup() as tg:
for k in range(m):
task_list[k] = tg.create_task(sleep_then_sum(k, delay[k], n_to_sum[k]))
await asyncio.sleep(0.2)
sn_list = [task.result() for task in task_list]
return sn_list
sn_list = asyncio.run(main())
print(sn_list)
print(f'Total time taken: {pc()-pc0} sec')
yield from-awaitis justyield fromunder the hood, andasync defis implemented as a generator.)