Asyncio.Sleep() VS Time.Sleep()

asyncio.sleep() vs time.sleep()

You aren't seeing anything special because there's nothing much asynchronous work in your code. However, the main difference is that time.sleep(5) is blocking, and asyncio.sleep(5) is non-blocking.

When time.sleep(5) is called, it will block the entire execution of the script and it will be put on hold, just frozen, doing nothing. But when you call await asyncio.sleep(5), it will ask the event loop to run something else while your await statement finishes its execution.

Here's an improved example.

import asyncio

async def hello():
print('Hello ...')
await asyncio.sleep(1)
print('... World!')

async def main():
await asyncio.gather(hello(), hello())

asyncio.run(main())

Will output:

~$ python3.7 async.py
Hello ...
Hello ...
... World!
... World!

You can see that await asyncio.sleep(1) is not blocking the execution of the script.

In contrast, replacing the line await asyncio.sleep(1) with time.sleep(1), the output will be

Hello ...
... World!
Hello ...
... World!

because time.sleep is blocking and the first call of hello() has to finish first before the second call of hello() starts running.

How asyncio.sleep isn't blocking thread?

The function asyncio.sleep simply registers a future to be called in x seconds while time.sleep suspends the execution for x seconds.

You can test how both behave with this small example and see how asyncio.sleep(1) doesn't actually give you any clue on how long it will "sleep" because it's not what it really does:

import asyncio 
import time
from datetime import datetime

async def sleep_demo():
print("async sleep start 1s: ", datetime.now().time())
await asyncio.sleep(1)
print("async sleep end: ", datetime.now().time())


async def I_block_everyone():
print("regular sleep start 3s: ", datetime.now().time())
time.sleep(3)
print("regular sleep end: ", datetime.now().time())


asyncio.gather(*[sleep_demo(), I_block_everyone()])

This prints:

async sleep start 1s:        04:46:55
regular sleep start 3s: 04:46:55
regular sleep end: 04:46:58
async sleep end: 04:46:58

The blocking call time.sleep prevent the event loop from scheduling the future that resumes sleep_demo. In the end, it gains control back only after approximately 3 seconds (even though we explicitly requested a 1 second async sleep).

Now concerning "The time.sleep() function also releases the GIL.", this is not a contradiction as it will only allow another thread to execute (but the current thread will remain pending for x seconds). Somewhat both look a bit similar, in one case the GIL is released to make room for another thread, in asyncio.sleep, the event loop gains control back to schedule another task.

Is `asyncio.sleep(delay)` guarenteed to sleep for at least `delay` seconds?

I will not tell what asyncio guarantees, but based on the implementation it follows that the asyncio.sleep()(basically, call_later()) sleeps for the specified interval, but at least with an inaccuracy equal to the resolution of the system clock used in the implementation.

Let's figure it out. First, asyncio uses monotonic clocks, which have different resolutions on different platforms (both Python and OS resolutions). For example, for Windows this is as much as 15ms.

In terms of guarantees, pay attention to the comment to the function BaseEventLoop.time:

    def time(self):
"""Return the time according to the event loop's clock.
This is a float expressed in seconds since an epoch, but the
epoch, precision, accuracy and drift are unspecified and may
differ per event loop.
"""
return time.monotonic()

Now let's take a look at the asyncio event loop source code responsible for starting the scheduled timers:

        # Handle 'later' callbacks that are ready.
end_time = self.time() + self._clock_resolution
while self._scheduled:
handle = self._scheduled[0]
if handle._when >= end_time:
break
handle = heapq.heappop(self._scheduled)
handle._scheduled = False
self._ready.append(handle)

Line end_time = self.time() + self._clock_resolution shows that the callback may fire earlier than planned but within the clock resolution. Yuri Selivanov clearly stated about this here:

As I see it, currently we peek into the future time. Why don't we do

       end_time = self.time() - self._clock_resolution

to guarantee that timeouts will always be triggered after the requested time, not before? I don't see how the performance can become worse if we do this.

And really, let's run the next program (Python 3.8 on Windows 10):

import asyncio 
import time

async def main():
print("Timer resolution", time.get_clock_info('monotonic').resolution)
while True:
asyncio.create_task(asyncio.sleep(1))

t0 = time.monotonic()
await asyncio.sleep(0.1)
t1 = time.monotonic()

print(t1 - t0)
asyncio.run(main())

We see behaviour described above:

Timer resolution 0.015625
0.09299999987706542
0.0940000000409782
0.0940000000409782
0.10900000017136335
...

But at the beginning of the text, I said at least the clock resolution, because asyncio works in conditions of cooperative multitasking, and if there is a greedy coroutine (or many less greedy ones) that does not give control to the event loop too often, we have the following picture:

import asyncio 
import time

async def calc():
while True:
k = 0
for i in range(1*10**6):
k += i
await asyncio.sleep(0.1) # yield to event loop


async def main():
asyncio.create_task(calc()) # start greedy coroutine
print("Timer resolution", time.get_clock_info('monotonic').resolution)
while True:
asyncio.create_task(asyncio.sleep(1))

t0 = time.monotonic()
await asyncio.sleep(0.1)
t1 = time.monotonic()

print(t1 - t0)
asyncio.run(main())

The situation is unsurprisingly changing towards increasing latency:

0.17200000025331974
0.1559999999590218
0.14100000029429793
0.2190000000409782

Difference between `asyncio.wait([asyncio.sleep(5)])` and `asyncio.sleep(5)`

TLDR: Do not use blocking calls such as time.sleep in a coroutine. Use asyncio.sleep to asynchronously pause, or use an event loop executor if blocking code must be run.


Using asyncio.wait([thing]) adds a level of indirection, executing thing in a new Future/Task. While a bare await asyncio.sleep(5) executes the sleep during coro1, the wrapped await asyncio.wait([asyncio.sleep(5)]) executes the sleep after all other currently scheduled coroutines.

async def coro1():
logger.info("coro1 start")
await asyncio.sleep(5) # started immediately
logger.info("coro1 finish")

async def coro1():
logger.info("coro1 start")
await asyncio.wait([ # started immediately
asyncio.sleep(5) # started in new task
])
logger.info("coro1 finish")

Since coro2 uses the blocking time.sleep(10), it disables the event loop and all other coroutines.

async def coro2():
logger.info("coro2 start")
time.sleep(10) # nothing happens for 10 seconds
logger.info("coro2 finish")

This prevents further Futures from being started - including new future from asyncio.wait - and from being resumed - including the bare asyncio.sleep(5). In the former case, that means the asyncio.sleep starts after the time.sleep is done - therefore taking 10 + 5 seconds to complete. In the latter case, that means the asyncio.sleep has already started, it just cannot complete before the 10 seconds are up - therefore taking max(10, 5) seconds to complete.


Consistently use asyncio.sleep to get the desired durations. If blocking code must be executed, have it run via an executor.

async def coro1w():
print("coro1w start", time.asctime())
await asyncio.wait([asyncio.sleep(5)])
print("coro1w finish", time.asctime())

async def coro1b():
print("coro1b start", time.asctime())
await asyncio.sleep(5)
print("coro1b finish", time.asctime())

async def coro2a():
print("coro2a start", time.asctime())
await asyncio.sleep(10) # asynchronous sleep
print("coro2a finish", time.asctime())

async def coro2t():
print("coro2t start", time.asctime())
loop = asyncio.get_running_loop() # threaded sleep
await loop.run_in_executor(None, lambda: time.sleep(10))
print("coro2t finish", time.asctime())

async def main():
await asyncio.gather(coro1w(), coro1b(), coro2a(), coro2t())

asyncio.run(main())

Is it good to use asyncio.sleep() in long running code to divide async function to multiple smaller parts of code?

TL;DR just use loop.run_in_executor to do blocking work


To understand why it doesn't help, let's first make a class that does something with the event loop. Like:

class CounterTask(object):
def __init__(self):
self.total = 0

async def count(self):
while True:
try:
self.total += 1
await asyncio.sleep(0.001) # Count ~1000 times a second
except asyncio.CancelledError:
return

This will simply count around 1000 times a second, if the event loop is completely open to it.

Naive

Just to demonstrate the worst way, let's start the counter task and naively run an expensive function without any thought to the consequences:

async def long_function1():
time.sleep(0.2) # some calculations

async def no_awaiting():
counter = CounterTask()
task = asyncio.create_task(counter.count())
await long_function1()
task.cancel()
print("Counted to:", counter.total)

asyncio.run(no_awaiting())

Output:

Counted to: 0

Well that didn't do any counting! Notice, we never awaited at all. This function is just doing synchronous blocking work. If the counter was able to run in the event loop by itself we should have counted to about 200 in that time. Hmm, so maybe if we split it up and leverage asyncio to give control back to the event loop it can count? Let's try that...

Splitting it up

async def long_function2():
time.sleep(0.1) # some calculations
await asyncio.sleep(0) # release event loop
time.sleep(0.1) # some another calculations
await asyncio.sleep(0) # release event loop

async def with_awaiting():
counter = CounterTask()
task = asyncio.create_task(counter.count())
await long_function2()
task.cancel()
print("Counted to:", counter.total)

asyncio.run(with_awaiting())

Output:

Counted to: 1

Well I guess that's technically better. But ultimately this shows the point: The asyncio event loop shouldn't do any blocking processing. It is not intended to solve those issues. The event loop is helplessly waiting for your next await. But the run_in_executor does provide a solution for this, while keeping our code in the asyncio style.

Executor

def long_function3():
time.sleep(0.2) # some calculations

async def in_executor():
counter = CounterTask()
task = asyncio.create_task(counter.count())
await asyncio.get_running_loop().run_in_executor(None, long_function3)
task.cancel()
print("Counted to:", counter.total)

asyncio.run(in_executor())

Output:

Counted to: 164

Much better! Our loop was able to continue going while our blocking function was doing things as well, by the good old-fashion way of threads.

Python coroutines don't run concurrently with time.sleep()?

I don't quite understand this; isn't the point of having concurrent tasks that they run in parallel, even if the tasks themselves contain synchronous code?

Concurrency != Parallelism. When writing asyncio code, the underlying routines still have to yield flow back to the eventloop in order to allow concurrency. And the GIL is still there, regardless.

Why does this example no longer work with time.sleep() instead of asyncio.sleep()?

asyncio.sleep suspends the current task, allowing other tasks to run. time.sleep does not, it's a blocking call ("blocking" meaning it blocks execution in the main thread, and programs using asyncio are still single-threaded).

Coroutines afford cooperative concurrency, not parallelism.

To achieve good concurrency through coroutines, any code called within asyncio.run must be written in a non-blocking way. In practice, it means that any code run within a task has the responsibility to signal when it is a good time to pause execution, e.g. "I'm not doing anything useful because I'm waiting on I/O..." - this is allowing another task to use the event loop.



Related Topics



Leave a reply



Submit