Вопрос: Как работает asyncio.sleep?

Лучший ответ:
Идея простая.
Асинхронные приложения - это набор пар "операция - обработчик", где операция - это некоторый длительный процесс (скажем, вводы/вывод), который не требует постоянного внимания со стороны кода. Когда операция завершается, выполняется её обработчик. Этот обработчик может планировать другие операции, и так далее.

Корутины - это такие функции, которые умеют сохранять своё состояние, и восстанавливать его. Т.е. когда корутина делает await-вызов, она начинает длительную операцию, а обработчик этой операции просто восстанавливает контекст корутины и продолжает её выполнение.

Соответственно, await-вызов сохраняет текущий контекст корутины, и планирует новую длительную операцию. Какую - зависит от того, что идёт после await. Эта операция представлена тем или иными awiatable значением (Future, Task и т.п.).

Рабочий цикл (loop, его также называют реактор) просто проверяет текущие операции. Как только одна из них завершилась, он выполняет её обработчик. А потом продолжает проверять остальные активные операции.

Ключевой момент - выполняет обработчик, а только потом проверяет остальные операции. Т.е. если обработчик планирует операцию через await - это не проблема, она будет выполняться наравне с остальными. А вот если обработчик просто делает что-то длительное, он не возвращает управление циклу, и не даёт выполняться остальным операциям.

asyncio.sleep() планирует ничего не делающую операцию, которая завершится успехом через заданное время. Поэтому её выполнение не мешает циклу-реактору. Просто ещё один await вызов, который приостанавливает текущую корутину и возвращает управление реактору.

time.sleep() ничего не знает о реакторе и асинхронных операциях. Она приостанавливает выполнение обработчика, не прерывая его и не отдавая контроль реактору. Поэтому на время time.sleep() (или любой длительной синхронной операции) спит вся программа.

Худший ответ:
Есть такой системный вызов select (а также подобные ему poll, epoll итд), суть которого - передать массив файловых дескрипторов (частный случай - сетевых соединений) и затем при получении событий ввода-вывода получить список тех дескрипторов, в которых произошёл ввод-вывод. Важно понимать, что при это программа "засыпает", передаёт управление ОС и не тратит ресурсов. ОС сама разбудит программу при наступлении нужных событий (записался файл, пришли новые сетевые байтики итд). Высокопроизводительные сетевые приложения (типа nginx, haproxy итд) используют подобный подход для того, чтобы эффективно обрабатывать большое количество сетевого трафика одновременно.

asyncio работает как раз примерно по тому же принципу. Когда случается ввод-вывод, нужная функция "засыпает", а управление передаётся потоку событий. Соответственно, он либо находит задачу, которая ожидает выполнения и передаёт ей управление, либо видит, что все задачи уже одидают какого-нибудь ввода-вывода и запускает select на все ожидающие дескрипторы (возможно, в реальности используется не select, а какой-то из его аналогов, но это для нас сейчас непринципиально). Как только приходит событие, программа просыпается, поток событий находит нужное событие и передаёт управление соответствующей задаче, которая его ожидала. Это позволяет очень эффективно в один поток работать с задачами, которые много ожидают ввода-вывода, но мало выполняют реальной процессорной работы.

Обычный ввод/вывод является блокирующим: пока не будет выполнена операция (чтение/запись/передача/приём), программа приостанавливает свою работу в ожидании. В asyncio весь ввод-вывод является неблокирующим: операции ввода-вывода не приостанавливают работу программы, а позволяют перейти к другим ожидающим задачам.

Обычный вызов sleep приводит к приостановке и засыпанию программы на указанное время (с передачей управления ОС), и в ней в это время ничего не выполняется. Как только время истечёт, ОС вернёт управление программе. Всё это время программа не работает, события ввода-вывода не обрабатывает.

В то же время asyncio.sleep возвращает управление потоку событий, а не ОС, что позволяет переключиться на выполнение других задач, обработать новые события итд итп. Программа не останавливается и управление ОС не передаёт (ну, кроме сна в процессе исполнения select), поэтому asyncio.sleep приводит к неблокирующему засыпанию, не мешающему выполнять задачи, которым ждать окончания сна одной конкретной задачи не нужно. Когда истечёт не менее чем указанное в asyncio.sleep время, поток событий вернёт управление приостановленной задаче.

Важно отметить, что и sleep, и asyncio.sleep не гарантируют, что функция возобновит работу через указанное число секунд, а не позже, но при этом sleep делает это значительно точнее. Потому что возврат в вызвавшую asyncio.sleep функцию может произойти только из потока событий и только при условии, что поток событий не вернёт управление какой-то другой задаче.

Например, пусть есть функция, которая делает asyncio.sleep(1), затем три секунды работает числомолотилка без ввода-вывода. Тогда если у нас выполняются подряд с интервалами 0.1 с три таких функции, то первая задержит на 2.9 секунд возврат управления второй, а вторая - на 5.8 с третьей.

Это общий принцип, разумеется, там много нюансов и особенностей реализации.