Перейти к содержанию

192 уроков, 14 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 1 из 20 Начальный 30 мин 100 XP

Зачем асинхронность: ожидание без блокировки

Три загрузки по секунде — три секунды простоя, если скрипт синхронный. Смотрим на честных замерах, где уходит время, и чем 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: он останавливает поток мёртвой хваткой, и изнутри этой паузы не работает ничего.

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: снаружи та же пауза, а изнутри — совершенно другой механизм.

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 скорость не меняет. Меняет её конкурентный запуск нескольких дел.

диверсия: time.sleep внутри async def
async def broken():
    time.sleep(5)          # блокирующая пауза в асинхронном мире
    return "готово"

# await broken()
# программа замрёт на 5 секунд ЦЕЛИКОМ: цикл не получил управления
Фрагмент для чтения, не для запуска: time.sleep здесь не пауза корутины, а блокировка всего потока. Такую строку в async-коде ищет питфолл ниже.

Кооперативная конкурентность: повар и кастрюли

Как устроена конкурентность в asyncio, удобно увидеть на кухне. Синхронный повар варит три кастрюли по очереди: встал над первой и стоит, помешивая, пока она не сварится, — потом вторая, потом третья. Асинхронный повар поступает иначе: поставил первую, завёл таймер, пошёл ставить вторую, завёл таймер, третью — и дальше ходит к той кастрюле, у которой как раз просигналил таймер. Три кастрюли готовы почти одновременно, потому что ожидание одной кастрюли повар тратит на работу с другими.

Слово «кооперативная» здесь ключевое: никто повара за локти не хватает. Каждая кастрюля сама сообщает «я закипела — займись мной позже», и переключение происходит только в тех местах, где корутина сама сказала await — «я подожду, управляй пока ты». Если корутина этого не сделала и захватила поток надолго (тем самым time.sleep или тяжёлым вычислением), весь цикл стоит: это оборотная сторона кооперации, о ней — в уроке 12.

ночь из жизни синхронного бота
# скелет синхронного чат-бота: 95% времени - ожидание
while True:
    updates = get_updates()        # заблокирован, ждём сервер
    for upd in updates:
        handle(upd)                # обработка - секунды из ожидания сети

# пока get_updates() ждёт, бот не печатает, не отвечает, не считает:
# один поток - одна точка ожидания за раз
Фрагмент для чтения, не для запуска: get_updates и handle здесь не определены. Смысл — в комментарии: пока синхронный код ждёт сеть, он не может делать ничего параллельно.

А теперь обещанный тизер. Те же три страницы по секунде, что открывали урок, — но в асинхронном исполнении. Не вчитывайся в синтаксис: 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())
Проверь себя
0 / 6

1. Сколько времени займут три сетевые загрузки по 1 секунде в синхронном скрипте?

2. Чем asyncio.sleep принципиально отличается от time.sleep?

3. Что произойдёт, если написать time.sleep(5) внутри async-функции?

4. Почему конкурентность в asyncio называется кооперативной?

5. Какую работу asyncio не ускорит?

6. Почему в тизере урока три загрузки заняли одну секунду?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери программу из двух последовательных шагов: корутина pause(label) ждёт одну секунду (asyncio.sleep — имитация сетевой задержки) и печатает «метка: готово на t=...» с таймстампом от общего старта. Запусти два шага — «шаг-1» и «шаг-2» — подряд и напечатай общее время в секундах.

practice.py
Вопросы и ответы по уроку

Что такое 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.