Зачем асинхронность: ожидание без блокировки
Три загрузки по секунде — три секунды простоя, если скрипт синхронный. Смотрим на честных замерах, где уходит время, и чем asyncio.sleep отличается от time.sleep.
Редакция Питоники
Парсер скачал страницу — и стоит: сеть отвечает за секунду, а всё это время скрипт не делает ничего. Чат-бот ждёт сообщение от сервера — и стоит. Скрипт отчётов тянет данные из трёх источников — и стоит трижды, по очереди. Ввод-вывод — это сеть, файлы, базы, таймеры — занимает в жизни таких программ львиную долю времени, и почти весь этот запас съедает простой: процессор свободен, а код ждать умеет только одним способом — встать и не двигаться.
В этом уроке мы сначала честно замерим, сколько времени синхронный скрипт тратит на простой, потом разберём, чем asyncio.sleep принципиально отличается от time.sleep, и на словах объясним, откуда берётся слово «кооперативная» в кооперативной конкурентности. Финал — тизер: те же три загрузки, но втрое быстрее. Механику задач разберём в уроках 6 и 7, сегодня важна идея.
Синхронный скрипт: три ожидания подряд
Сценарий из жизни: скрипту нужны три страницы — «Главная», «Каталог» и «Контакты». Настоящей сети в песочнице нет, поэтому каждую загрузку имитирует time.sleep(1) — честная секунда ожидания, ровно как если бы ждала сеть. Таймстампы покажут, когда какая страница доехала.
import time
START = time.monotonic()
def load(name):
time.sleep(1) # имитация сетевой задержки
t = round(time.monotonic() - START)
print(f"{name}: готово на t={t}")
load("Главная")
load("Каталог")
load("Контакты")
print("Всего секунд:", round(time.monotonic() - START))
Главная: готово на t=1 Каталог: готово на t=2 Контакты: готово на t=3 Всего секунд: 3
Три загрузки — три секунды, и каждая секунда целиком состоит из ожидания: процессор не считает, не печатает, не готовит следующую страницу. Он спит вместе со скриптом, потому что time.sleep не оставляет потоку ничего другого, кроме как стоять. Так устроен любой синхронный код на вводе-выводе: сетевой запрос, чтение файла, ожидание ответа базы — поток занят тем, что ждёт.
Заметь, чего в этом расписании нет: работы. Каждая строка — «дождался, запустил следующую», а между запусками — только ожидание. С настоящей сетью картина была бы ещё выразительнее: реальные страницы отвечают от долей секунды до десятков секунд, и скрипт не знает, какой придётся ждать конкретную, — он просто ждёт каждую, сколько дадут. Базы данных и внешние API ведут себя так же: любой ввод-вывод для синхронного кода — это остановка, длительность которой решает не ты.
| Момент | Что делает скрипт | Сколько уже ждёт |
|---|---|---|
| t=1 | дождался «Главной», запустил «Каталог» | 1 секунду |
| t=2 | дождался «Каталога», запустил «Контакты» | 2 секунды |
| t=3 | дождался «Контактов», закончил работу | 3 секунды |
time.sleep против asyncio.sleep
На поверхности это близнецы: оба делают паузу. Разница — в том, кто управляет паузой. Посмотрим сначала на time.sleep: он останавливает поток мёртвой хваткой, и изнутри этой паузы не работает ничего.
import time
START = time.monotonic()
print("до паузы: t =", round(time.monotonic() - START))
time.sleep(1) # поток заблокирован целиком
print("после паузы: t =", round(time.monotonic() - START))
до паузы: t = 0 после паузы: t = 1
Секунда прошла, напечатались оба таймстампа — и ни в какой момент между ними поток не мог сделать ничего другого. time.sleep не знает ни про какие циклы и задачи: он просто кладёт операционную систему ждать за этот поток и через секунду будит. Если в этот момент где-то была работа, которую можно сделать, — она подождёт, потому что ждать больше некому.
Теперь asyncio.sleep: снаружи та же пауза, а изнутри — совершенно другой механизм.
import asyncio
import time
START = time.monotonic()
async def main():
print("до паузы: t =", round(time.monotonic() - START))
await asyncio.sleep(1) # пауза, но с уступкой управления
print("после паузы: t =", round(time.monotonic() - START))
asyncio.run(main())
до паузы: t = 0 после паузы: t = 1
Вывод один в один, разница в подписи: строка с await asyncio.sleep(1) — это не «стоять секунду», а «сказать циклу: разбуди меня через секунду, а сам пока распоряжайся временем». Корутина на этой строке приостанавливается, управление уходит событийному циклу, и он волен запустить кто-то ещё. В этом примере «кто-то ещё» не нашёлся — других корутин нет, — но крючок уже заброшен: стоит добавить соседей, и пауза одного станет рабочим временем другого. Соседи появятся в уроках 6 и 7.
import asyncio
import time
async def main():
start = time.monotonic()
await asyncio.sleep(1)
elapsed = round(time.monotonic() - start)
print("Прошло не меньше секунды:", elapsed >= 1)
print("Пауза длилась, секунд:", elapsed)
asyncio.run(main())
Прошло не меньше секунды: True Пауза длилась, секунд: 1
И сразу оговорка, которая спасёт от ложных ожиданий: паузы с await подряд по-прежнему складываются. asyncio.sleep даёт циклу право занять паузу — но не гарантирует, что в паузе есть кто-то, кого можно занять.
import asyncio
import time
async def main():
start = time.monotonic()
await asyncio.sleep(1)
await asyncio.sleep(2)
print("Две паузы:", round(time.monotonic() - start), "сек")
asyncio.run(main())
Две паузы: 3 сек
Три секунды за паузы в одну и две: await'ы шли по очереди, и соседей, которые могли бы влезть в чужие паузы, в программе не было. Как запускать корутины одновременно — задачи и gather из уроков 6 и 7; сейчас важно другое: сам по себе переход на asyncio.sleep скорость не меняет. Меняет её конкурентный запуск нескольких дел.
async def broken():
time.sleep(5) # блокирующая пауза в асинхронном мире
return "готово"
# await broken()
# программа замрёт на 5 секунд ЦЕЛИКОМ: цикл не получил управления
Кооперативная конкурентность: повар и кастрюли
Как устроена конкурентность в asyncio, удобно увидеть на кухне. Синхронный повар варит три кастрюли по очереди: встал над первой и стоит, помешивая, пока она не сварится, — потом вторая, потом третья. Асинхронный повар поступает иначе: поставил первую, завёл таймер, пошёл ставить вторую, завёл таймер, третью — и дальше ходит к той кастрюле, у которой как раз просигналил таймер. Три кастрюли готовы почти одновременно, потому что ожидание одной кастрюли повар тратит на работу с другими.
Слово «кооперативная» здесь ключевое: никто повара за локти не хватает. Каждая кастрюля сама сообщает «я закипела — займись мной позже», и переключение происходит только в тех местах, где корутина сама сказала await — «я подожду, управляй пока ты». Если корутина этого не сделала и захватила поток надолго (тем самым time.sleep или тяжёлым вычислением), весь цикл стоит: это оборотная сторона кооперации, о ней — в уроке 12.
# скелет синхронного чат-бота: 95% времени - ожидание
while True:
updates = get_updates() # заблокирован, ждём сервер
for upd in updates:
handle(upd) # обработка - секунды из ожидания сети
# пока get_updates() ждёт, бот не печатает, не отвечает, не считает:
# один поток - одна точка ожидания за раз
А теперь обещанный тизер. Те же три страницы по секунде, что открывали урок, — но в асинхронном исполнении. Не вчитывайся в синтаксис: async, await и gather разберём по одному в уроках 2, 3 и 7. Сегодня смотри только на таймстампы и на итоговую цифру.
import asyncio
import time
START = time.monotonic()
async def load(name):
await asyncio.sleep(1) # имитация сетевой задержки
return f"{name}: готово"
async def main():
results = await asyncio.gather( # механика пачки - в уроке 7
load("Главная"),
load("Каталог"),
load("Контакты"),
)
for r in results:
print(r)
print("Всего секунд:", round(time.monotonic() - START))
asyncio.run(main())
Главная: готово Каталог: готово Контакты: готово Всего секунд: 1
Три секунды превратились в одну. Никакой магии ускорения не произошло: сеть как занимала по секунде на страницу, так и занимает. Просто три ожидания перестали идти по очереди и наложились друг на друга — повар поставил три кастрюли, а не стоял над каждой. И эта экономия растёт с каждой новой страницей: десять загрузок по секунде синхронно — десять секунд, асинхронно — по-прежнему около одной. Как именно цикл накладывает ожидания друг на друга — сюжет уроков 2 и 3 (корутины и цикл), а практические инструменты пачки — уроки 6 и 7.
Отсюда простое правило выбора: чем больше одновременных ожиданий, тем выгоднее asyncio. Десяток вкладок браузера, триста клиентов чата, сотня URL в парсере, толпа апдейтов у бота — везде, где соединений много, а вычислений мало, кооперативная конкурентность превращает очередь в наложение. Один сервер на asyncio спокойно держит тысячи открытых соединений — не потому, что быстрее считает, а потому, что не стоит над каждой кастрюлей.
Что дальше
Сегодня — картина целиком: синхронный код платит ожиданием за каждый запрос, time.sleep — стоп-кран, asyncio.sleep — пауза, которой распоряжается цикл, а конкурентность появляется только там, где корутины сами уступают управление. Дальше по курсу: первая корутина — что именно делают async def и await и почему вызов async-функции не запускает её тело; событийный цикл — что asyncio.run делает под капотом. А когда фундамент встанет, вся эта механика окажется внутри настоящих ботов: раздел aiogram построен на asyncio целиком.
Три загрузки синхронно — это три ожидания подряд: время складывается, а скрипт всё это время просто стоит.
Сначала предскажи ответ в голове — это главный навык программиста.
import time
START = time.monotonic()
time.sleep(1)
print("первый:", round(time.monotonic() - START))
time.sleep(2)
print("второй:", round(time.monotonic() - START))
import asyncio
async def tick():
print("тик")
async def main():
await asyncio.sleep(1)
await tick()
asyncio.run(main())
import asyncio
import time
async def job(name, delay):
await asyncio.sleep(delay)
return name
async def main():
start = time.monotonic()
await asyncio.sleep(1)
await asyncio.sleep(2)
print("секунд:", round(time.monotonic() - start))
asyncio.run(main())
1. Сколько времени займут три сетевые загрузки по 1 секунде в синхронном скрипте?
2. Чем asyncio.sleep принципиально отличается от time.sleep?
3. Что произойдёт, если написать time.sleep(5) внутри async-функции?
4. Почему конкурентность в asyncio называется кооперативной?
5. Какую работу asyncio не ускорит?
6. Почему в тизере урока три загрузки заняли одну секунду?
Собери программу из двух последовательных шагов: корутина pause(label) ждёт одну секунду (asyncio.sleep — имитация сетевой задержки) и печатает «метка: готово на t=...» с таймстампом от общего старта. Запусти два шага — «шаг-1» и «шаг-2» — подряд и напечатай общее время в секундах.
Что такое asyncio в Python простыми словами?
Это встроенный модуль для конкурентного кода: пока одна операция ждёт сеть или таймер, событийный цикл запускает другие корутины. Ожидания перестают складываться и начинают накладываться: десять загрузок по секунде занимают около секунды, а не десять. Слово «кооперативная» означает, что корутины сами уступают управление в точках await.
Чем asyncio.sleep отличается от time.sleep?
Оба делают паузу, но time.sleep блокирует поток целиком: в асинхронном коде он замораживает весь событийный цикл со всеми корутинами. asyncio.sleep уступает управление циклу — он «будит» корутину через заданное время, а в паузе цикл занимается другими задачами. Правило: внутри async-функций — только asyncio.sleep.
Ускоряет ли asyncio вычисления?
Нет. asyncio спасает от простоя на вводе-выводе: сеть, файлы, таймеры, базы. Чистые вычисления (изображения, переборы, тяжёлая математика) процессор и так занимает целиком, ожиданий в них нет — ускоряться нечем. Для вычислительной нагрузки используют многопроцессность.
Можно ли выучить asyncio без настоящей сети?
Да, и этот курс так устроен: «загрузки» имитируются asyncio.sleep с честными пометками в комментариях. Механика корутин, задач, таймаутов и отмены при этом абсолютно настоящая — тот же код с заменой sleep на запросы работает на сервере или в боте один в один.
Понравился урок? Сошлитесь на него
«Три загрузки синхронно — это три ожидания подряд: время складывается, а скрипт всё это время просто стоит.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
asyncio · Урок 2
Первая корутина: async def и await
async def — функция с паузами, await — «дождись результата». Звучит просто, пока не выясняется, что вызов async-функции не выполняет её тело. Разбираем оба ключевых слова на живом коде.
asyncio · Урок 3
asyncio.run: событийный цикл под капотом
Одна строка asyncio.run(main()) превращает обычный скрипт в асинхронную программу. Разбираем три работы этой строки: создать цикл, раскрутить корутину, убрать за собой.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
aiogram · Урок 7
Асинхронность в aiogram: asyncio для бота без страха
Разбираем на живом коде, зачем aiogram асинхронный: async и await, asyncio.sleep против time.sleep и gather для параллельных чатов.
asyncio для начинающихasyncio.sleep
Pandas · Урок 1
Что такое Pandas и как установить через pip: первые Series и DataFrame
Первая таблица DataFrame в библиотеке pandas, умный столбец Series и быстрый осмотр данных через head, info и describe — старт сквозного анализа данных интернет-магазина.
pandas для начинающихpandas самоучитель с нуля
asyncio · Урок 12
Блокирующий вызов останавливает всё
Одна строка time.sleep внутри async-функции — и весь событийный цикл мёртв: никакие задачи не работают, таймеры не тикают. Смотрим на таймстампах и формулируем правило.
asyncio блокировка цикла time.sleeptime.sleep в async функции