Python Asyncio Awaitables Tasks and Futures
Links:
- 108 Python Index
- Python - Asyncio
Awaitables, Tasks and Futures¶
async defis used to declare an asynchronous coroutine function in the same way thatdefis used to define a normal synchronous function.- Asynchronous Python code can only be included inside a suitable context that allows it that means inside a coroutine function defined using
async def.
Difference between def and async def¶
- The Python
defkeyword creates a callable object with a name, when the object is called the code block of the function is run.- Eg:
r = example_function(1, 2, 3)causes the function code to be run immediately as a subroutine call, and its return value to be assigned tor
- Eg:
- The Python
async defkeyword creates a callable object with a name, when the object is called the code block of the function is NOT run.- Eg:
r = example_coroutine_function(1, 2, 3)this does not cause the function code block to be run. - Instead an object of class
Coroutineis created, and is assigned tor. - To make the code block actually run we would need
awaitorasyncio.gather
- Eg:
If we are using the typing library then the declaration of coroutine functions can be a little confusing at times.
- For
async def example_coroutine_function(A,B) -> CTyping definesexample_coroutine_functionas a callable that takes two parameters of types A and B and returns an object of typeCoroutine[Any, Any, C]. - The two
Anytype parameters in the above definition are related to the way that the event loop works.
Await and awaitables¶
awaitis a new keyword which can only be used inasync deffunctions and not anywhere outside of that.- Eg use of
await:r = await some_async_func()- The return value of the
awaitstatement is the value returned by the code block.
- The return value of the
- An object that can be used with
awaitis known as an awaitable object.- A coroutine object is
awaitable
- A coroutine object is
- Awaiting a Coroutine object is like calling a function.
- Only when a coroutine object is awaited a task for it is created in the event loop and its execution starts. Just getting the Coroutine object by calling the async function doesn't put it as a task.
- Await is a blocking call
- There are three types of objects that are awaitable:
- A Coroutine object.
- When awaited it will execute the code-block of the coroutine in the current Task.
- The
awaitstatement will return the value returned by the code block.
- Any object of class
asyncio.Futurewhich when awaited causes the current Task to be paused until a specific condition occurs. - An object which implements the magic method
__await__, in which case what happens when it is awaited is defined by that method.
- A Coroutine object.
How NOT to use await¶
- Example: Since
awaitis a blocking call, how NOT to useawaitimport asyncio from asyncio import Task import time urls = list(range(1, 6)) async def get_request(url: int) -> int: # send a request and assuming the the process of sending it is awaitable await asyncio.sleep(1) return url async def make_requests(): response_list = [] for url in urls: # the process of creating a task starts the function response = get_request(url) response_list.append(response) for i in range(len(response_list)): result = await response_list[i] print(result) async def main(): start_time = time.perf_counter() await make_requests() end_time = time.perf_counter() print(end_time - start_time) asyncio.run(main()) # OUTPUT # 1 2 3 4 5 # 5.00723379199917 - The above example is same as running synchronous code
Only when a coroutine object is awaited a task for it is created in the event loop and its execution starts.¶
import asyncio
from typing import Coroutine, Any
import time
urls = list(range(1, 6))
async def get_request(url: int) -> int:
# send a request and assuming the the process of sending it is awaitable
await asyncio.sleep(1)
return url
async def make_requests():
response_list: list[Coroutine[Any, Any, int]] = []
for url in urls:
response = get_request(
url
) # this hasn't started function execution (i.e. created a task)
response_list.append(response)
print(f"Number of tasks: {len(asyncio.all_tasks())}")
# yielding control back to the event loop but since there is only one task it doesn't matter
await asyncio.sleep(2)
results = await asyncio.gather(*response_list)
print(results)
async def main():
start_time = time.perf_counter()
await make_requests()
end_time = time.perf_counter()
print(end_time - start_time)
asyncio.run(main())
# OUTPUT
# Number of tasks: 1
# [1, 2, 3, 4, 5]
# 3.0037487500085263
- In the above example if the task was created at the time of
get_request()was called then the overall execution time should have been only 2s.- This can be achieved using
create_task()
- This can be achieved using
Tasks¶
- Tasks are used to schedule a coroutine to run in the event loop.
- The method
create_tasktakes a coroutine object as a parameter and returns aTaskobject, which inherits fromasyncio.Future. - The call creates the task inside the event loop for the current thread, and starts the task executing at the beginning of the coroutine’s code-block.
- The returned future will be marked as
done()only when the task has finished execution. - The return value of the coroutine’s code block is the
result()which will be stored in the future object when it is finished.
Difference between Coroutine and a Task
When we call create_task() it creates a Task which is a wrapper around a future.
When we call an async function it creates a Coroutine.
The distinction between a Coroutine and a Task/Future is that Coroutine’s code will not be executed until it is awaited.
Using create_task()¶
- Example: using
create_task()import asyncio from asyncio import Task urls = list(range(1, 6)) async def get_request(url: int) -> int: # send a request and assuming the the process of sending it is awaitable await asyncio.sleep(1) return url async def make_requests(): response_list: list[Task[int]] = [] for url in urls: # the process of creating a task starts the function response = asyncio.create_task(get_request(url)) response_list.append(response) print(f"Number of tasks: {len(asyncio.all_tasks())}") # yielding control back to the event loop await asyncio.sleep(2) results = await asyncio.gather(*response_list) print(results) async def main(): start_time = time.perf_counter() await make_requests() end_time = time.perf_counter() print(end_time - start_time) asyncio.run(main()) # OUTPUT # Number of tasks: 6 # [1, 2, 3, 4, 5] # 2.001688958000159 create_task()immediately creates a task in the event loop and executes it when control is given to it by the event loop unlikeawaitwhich creates a task when it is called.
Using synchronous code in asyncio¶
- One of the most important points to get across is that the currently executing Task (in the event loop, Reference Diagram) cannot be paused by any means other than awaiting a future.
- And that is something which can only happen inside asynchronous code.
- So any
awaitstatement might cause your current task to pause, but is not guaranteed to. awaitcan be thought of as a checkpoint where it's safe for asyncio to go to another coroutine.
- Conversely any statement which is not an
awaitstatement cannot cause the current Task of the event loop to be paused. > [!note]- This is a very important point since it proves that libraries that are synchronous (eg: Requests) provide no value when used with asyncio.- If we want to use synchronous libraries then we will have to use threading for concurrent programming.
Difference between threading and asyncio
- In case of threads the operating system decides when to context switch to another thread. Context can switch at any point of time.
- In case of asyncio the tasks themselves decide when to hand over the control using the
awaitkeyword.
No point using synchronous libraries with asyncio¶
import requests
import asyncio
import time
async def counter():
now = time.time()
print("Started counter")
for i in range(0, 10):
last = now
await asyncio.sleep(0.001)
now = time.time()
print(f"{i}: Was asleep for {now - last}s")
async def main():
t = asyncio.create_task(counter())
# switch the context to the counter task.
await asyncio.sleep(0)
print("Sending HTTP request")
r = requests.get('http://example.com')
print(f"Got HTTP response with status {r.status_code}")
await t
asyncio.run(main())
# OUTPUT
# Started counter
# Sending HTTP request
# Got HTTP response with status 200
# 0: Was asleep for 0.019963502883911133s
# 1: Was asleep for 0.0012884140014648438s
# 2: Was asleep for 0.0012254714965820312s
# 3: Was asleep for 0.0011649131774902344s
# 4: Was asleep for 0.0011239051818847656s
# 5: Was asleep for 0.0012202262878417969s
# 6: Was asleep for 0.0012269020080566406s
# 7: Was asleep for 0.001184701919555664s
# 8: Was asleep for 0.0011556148529052734s
# 9: Was asleep for 0.00115203857421875s
- As you can see the counter has paused during the whole time it takes to make the HTTP request.
- This is because the call
requests.getis an ordinary synchronous IO call, and does not return until the http request has been completed. - Since it is not asynchronous code the event loop can do nothing to interrupt it to let other tasks run, and so it "blocks the event loop" for as long as it runs.
- This is because the call
References¶
Last updated: 2022-11-11