Python Asyncio Awaitables Tasks and Futures
Links:
- 108 Python Index
- Python - Asyncio
Awaitables, Tasks and Futures¶
async def
is used to declare an asynchronous coroutine function in the same way thatdef
is 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
def
keyword 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 def
keyword 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
Coroutine
is created, and is assigned tor
. - To make the code block actually run we would need
await
orasyncio.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) -> C
Typing definesexample_coroutine_function
as a callable that takes two parameters of types A and B and returns an object of typeCoroutine[Any, Any, C]
. - The two
Any
type parameters in the above definition are related to the way that the event loop works.
Await
and awaitables¶
await
is a new keyword which can only be used inasync def
functions and not anywhere outside of that.- Eg use of
await
:r = await some_async_func()
- The return value of the
await
statement is the value returned by the code block.
- The return value of the
- An object that can be used with
await
is 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
await
statement will return the value returned by the code block.
- Any object of class
asyncio.Future
which 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
await
is a blocking call, how NOT to useawait
import 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_task
takes a coroutine object as a parameter and returns aTask
object, 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 unlikeawait
which 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
await
statement might cause your current task to pause, but is not guaranteed to. await
can be thought of as a checkpoint where it's safe for asyncio to go to another coroutine.
- Conversely any statement which is not an
await
statement 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
await
keyword.
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.get
is 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