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