Skip to content

About

Async Tutorial

Resources

Stars

0 stars

Watchers

1 watching

Forks

Latest commit

 

History

26 Commits

Folders and files

Repository files navigation

asyncio Tutorial

In this tutorial, I used and modified examples from the Python documentation and this github page. I recommend exploring these pages/examples to get a better understanding of asyncio.

In traditional Python program, when a function f() calls another function g(), the Python interpreter will immediately pass execution to g(). Once g() completes, execution will return to f(). By using the asyncio library, we can allow a function f() to call two functions g() and h() so that the execution of g() and h() appears simultaneous (or concurrent). In reality, however, execution is not simultaneous. Instead, g() and h() are designed to implicitly pass execution between each other.

To explain this in more detail, we need some terminology. A coroutine is a function that can can pause its execution to let other coroutines execute. In the above paragraph, g() and h() are coroutines. Coroutines are designated in Python by async def. Whenever we call a coroutine f(), we must write await f() or out = await f() (if we want to save the coroutine output). The following example shows how coroutines can behave just like regular functions, albeit with extra keywords:

import asyncio
async def main():
print('hello')
await asyncio.sleep(1)
print('world')
if __name__ == "__main__":
asyncio.run(main())

Simply using they keywords async and await will not cause coroutines to pass execution to each other. In the following example, the two function calls to say_after do not appear to run simultaneously:

import asyncio
async def say_after(delay, what):
await asyncio.sleep(delay)
print(what)
async def main():
await say_after(2, 'hello')
await say_after(6, 'world')
if __name__ == "__main__":
asyncio.run(main())

We allow coroutines to pass off execution to each other by creating tasks for them. Behind the scene, Python runs an event loop that keeps a list of our tasks. When the coroutine of a task reaches a point where it pauses execution, the event loop takes over and runs the next task. Iteration through the list of tasks will continue in this way until we return to the original coroutine.

In the following example, we create two tasks. Execution in task1 is paused when we reach await asyncio.sleep(delay) in the say_after coroutine. This command allows the event loop to begin executing task2. In task2, once we reach await asyncio.sleep(delay) in the say_after coroutine, the event loop passes execution back to task1. If the desired delay amount has not yet elapsed within task1, control will once again be returned to the event loop, which will then return execution to task2. After many iterations, task1 will eventually satisfy the sleep requirement, and so execution of say_after will proceed and thereby terminate task1. The same will occur for task2.

import asyncio
import time
async def say_after(delay, what):
await asyncio.sleep(delay)
print(what)
async def main():
task1 = asyncio.create_task(say_after(3, 'hello'))
task2 = asyncio.create_task(say_after(3, 'world'))
await task1
await task2
if __name__ == "__main__":
asyncio.run(main())

Execution of task1 begins when await task1 is called within main. Execution in main is paused until task1 completes, but remarkably, task2 executes as well. That's because task2 = asyncio.create_task(say_after(3, 'world')) adds task2 to the event loop (without executing it). Once task1 pauses execution, task2 will immediately take over. When running on my local machine, I can see the output of task2 even if the await task2 line is removed. The await task2 command exists simply to ensure that your Python program doesn't end before task2 completes. It doesn't actually cause task2 to execute.

Here is another example that illustrates asyncio operates:

import asyncio
import time
async def count():
print("One")
time.sleep(1)
await asyncio.sleep(1)
print("Two")
async def main():
await asyncio.gather(count(), count(), count())
if __name__ == "__main__":
asyncio.run(main())

In the above example, we use asyncio.gather(...) to automatically create and execute three tasks for the count coroutine. The event loop hands off execution to the first task. This first task runs uninterrupted until it encounters await asyncio.sleep(1). It then passes execution to event loop which passes execution to the second task. This pattern continues until sleep requirements are satisified. Because time.sleep is not a coroutine, execution of coroutines is not passed to the event loop when this command executes.

Here is a brief description of some of the important commands needed to create and manage tasks in asyncio:

  • asyncio.run(...) runs a single coroutine to the exclusion of other coroutines.
  • asyncio.create_task(...) initiates a single task but doesn't automatically start the event loop.
  • asyncio.gather(...) initiates mutliple tasks and starts the event loop.

In Django, we use sync_to_async to make a traditional function (defined by def) appear as though its a coroutine (defined by async def). But if f(input) is a traditional function, then it has no await methods within it to pause execution. Thus sync_to_async(f)(input) will never return control to the event loop when it executes. We use sync_to_async when working with ASGI applications because such programs require all functions to be coroutines. A similar function is async_to_sync. This allows a coroutine to NOT pass control to the event loop when it executes.

The asyncio library is ideal in applications where there are potentially long delays due to communication. That's why asyncio is used with to create ASGI applications.

The next example is meant to simulate a processing scenario that deals with communcation delays. To model this, we need to understand queues. A queue is a data structure where items are added in a "first-in"/"first-out" basis. The asyncio.Queue allows coroutines to add and remove items from the queue. In the following example, we create tasks that call the consume and produce coroutines. We call these tasks "consumers" and "producers". A producer will wait a random length of time and then add a random letter to the queue. A consumer will wait a random length of time then take an item from the queue. We use infinite loops to make this behavior continue. You can stop execution of this program by typing CTL + C.

import asyncio
import random
import string
async def produce(name, queue):
while True:
await asyncio.sleep(random.uniform(1,5))
random_letter = random.choice(string.ascii_letters)
print("P" + str(name) + " produced " + str(random_letter))
await queue.put((name, random_letter))
async def consume(name, queue):
while True:
producer_name, random_letter = await queue.get()
print("C" + str(name) + " got " + str(random_letter) + " made by P" + str(producer_name))
await asyncio.sleep(random.uniform(1,5))
queue.task_done()
async def main():
q = asyncio.Queue()
producers = [asyncio.create_task(produce(n, q)) for n in range(3)]
consumers = [asyncio.create_task(consume(n, q)) for n in range(2)]
await asyncio.gather(*producers)
await q.join() # Implicitly awaits consumers, too
for c in consumers:
c.cancel()
if __name__ == "__main__":
asyncio.run(main())

About

Async Tutorial

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages