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

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

Начать обучение
Урок 7 из 10 Средний 40 мин 130 XP

Асинхронность в aiogram: asyncio для бота без страха

Разбираем на живом коде, зачем aiogram асинхронный: async и await, asyncio.sleep против time.sleep и gather для параллельных чатов.

Редакция Питоники

Урок, ради которого aiogram и выбрали вместо синхронных библиотек. Ваш бот общается не с одним Аней: утром сотня человек шлёт сообщения почти одновременно. Бот на обычном, синхронном коде отвечает им по очереди — и где-то в конце очереди люди ждут минуты. aiogram построен на asyncio, поэтому обслуживает всех сразу. В этом уроке вы своими руками увидите, почему бот замирает, как устроены async и await, как запустить задачи параллельно и что такое фоновые задачи.

Хорошая новость: asyncio — часть стандартного Python, и в песочнице этой страницы он работает по-настоящему. Каждый блок ниже — живой код с настоящими корутинами: запускайте, ломайте, меняйте имена. Порядок вывода в примерах скажет об асинхронности больше, чем любые диаграммы.

Почему бот замирает: синхронная очередь

Сначала боль. Обычная функция занимает всё внимание программы от первого оператора до последнего. Если внутри неё «долгая работа» — запрос к API, чтение файла — программа стоит и ждёт. Посмотрите на синхронного бота, у которого пришло два сообщения. Код ниже содержит настоящий time.sleep: при запуске вы почувствуете паузы сами.

синхронный бот: строго по очереди
import time

# Синхронный "бот": сообщения обрабатываются строго по очереди.
# Запустите - и почувствуйте паузы: пока Аня обслуживается,
# Борис просто ждёт своей очереди.

def handle(user):
    print(user + ": запрос получен")
    time.sleep(0.4)                 # "долгая" работа, весь бот замирает
    print(user + ": ответ отправлен")

handle("Аня")
handle("Борис")
Вывод
Аня: запрос получен
Аня: ответ отправлен
Борис: запрос получен
Борис: ответ отправлен
Порядок строк — как у любого последовательного кода, но заметьте время: между «получен» и «отправлен» пауза почти полсекунды, и всё это время бот недоступен никому.

Вывод скучный и в этом весь ужас: Аня полностью обслужена, только потом тронули Бориса. С time.sleep(0.4) это полсекунды, а с запросом к медленному API — три секунды на каждого человека. Сотня пользователей — последние ждут пять минут. Расширять команду до тысячи ядер бессмысленно: программа-то одна и она занята ожиданием.

А теперь главное наблюдение. Бот почти ничего не считает — он ждёт: ответ от серверов Telegram, запись на диск, ответ веб-сервиса. Пока идёт ожидание, процессор свободен. Значит, ждать можно сразу для многих: пока Аня ждёт ответ Telegram, Бориса уже можно обслуживать. Для этого программе нужно уметь ставить функцию на паузу и переключаться — и именно это делают async и await.

Что такое async, await и корутина

Слово async перед def создаёт не обычную функцию, а корутину — функцию с правом на паузу. Внутри корутины можно написать await: «здесь я жду, отдай управление другим». Вызывающая сторона обязана запустить корутину — сама по себе она не выполняется.

первая корутина: async def и asyncio.run
import asyncio

async def greet(name):
    print("Готовлю приветствие для", name)
    await asyncio.sleep(0)          # вежливая пауза: отдаём управление
    print("Привет,", name)

# без asyncio.run корутина - просто объект, она не выполняется
asyncio.run(greet("Аня"))
Вывод
Готовлю приветствие для Аня
Привет, Аня

Три вещи, которые здесь произошли. async def greet — объявление корутины. greet("Аня") — не выполнение, а создание корутинного объекта, как list() создаёт список, не печатая его. asyncio.run(...) — запуск цикла событий (event loop): диспетчера, который выполняет корутины и переключает их в точках await. await asyncio.sleep(0) — пауза ноль секунд, полезная ровно одним: она показывает циклу «пока я, можешь запустить кого-то ещё».

Чем asyncio.sleep отличается от time.sleep

Теперь два сообщения одновременно. Запустим обе корутины параллельно через gather и понаблюдаем порядок строк — он не совпадёт с синхронной версией:

gather: два чата обслуживаются вперемешку
import asyncio

async def chat(user):
    print(user + ": запрос получен")
    await asyncio.sleep(0.2)        # "запрос к Telegram": ждём, но не блокируем
    print(user + ": ответ отправлен")

async def main():
    # два сообщения пришли одновременно - обслуживаем оба
    await asyncio.gather(chat("Аня"), chat("Борис"))

asyncio.run(main())
Вывод
Аня: запрос получен
Борис: запрос получен
Аня: ответ отправлен
Борис: ответ отправлен

Сравните с первым блоком урока: строки те же, порядок другой. Обе корутины успели напечатать «запрос получен» до того, как закончилось ожидание первой — то есть Аня и Борис обслуживаются одновременно. Никакой многопоточности здесь нет: один поток, один цикл событий, который просто умно переключает корутины в точках await. Разница между двумя sleep'ами ровно в этом:

time.sleepasyncio.sleep
Что делаетостанавливает весь потокставит на паузу только свою корутину
Цикл событийзаблокирован, бот молчит для всехживёт, обслуживает других
Где можнов обычном кодетолько внутри async def
В хендлере aiogramникогдавсегда

time.sleep останавливает весь цикл событий, а asyncio.sleep ставит на паузу только одну корутину. Одна буква в начале строки — а на другом конце разница между ботом и статуей.

gather: параллельные задачи и порядок результатов

asyncio.gather — главный способ запустить несколько корутин разом: передаёте их аргументами, он возвращает список результатов. У gather есть приятная неожиданность: корутины финишируют в порядке своих задержек, но список результатов идёт в порядке аргументов. Проверим на трёх задачах с разными паузами:

gather: финиш вразнобой, результаты по порядку
import asyncio

async def work(name, seconds):
    await asyncio.sleep(seconds)
    print("закончила:", name)
    return name + " готова"

async def main():
    results = await asyncio.gather(
        work("медленная", 0.3),
        work("быстрая", 0.1),
        work("средняя", 0.2),
    )
    # порядок результатов - как у аргументов, а не как у финиша
    print("gather вернул:", results)

asyncio.run(main())
Вывод
закончила: быстрая
закончила: средняя
закончила: медленная
gather вернул: ['медленная готова', 'быстрая готова', 'средняя готова']

Финишный порядок диктуют паузы: быстрая, средняя, медленная. А вот список results собран в порядке аргументов gather — медленная, быстрая, средняя. Это удобно: вы всегда знаете, какой индекс какому запросу принадлежит. В настоящем боте gather рассылает сообщение в несколько чатов или запрашивает данные у трёх сервисов одновременно — вместо трёх последовательных ожиданий одно, самое долгое.

create_task: задача, которая живёт сама

gather ждёт все задачи до конца — но не всякая работа должна кого-то держать. Боту часто нужна фоновая задача: напомнить через час, отправить отчёт в полночь, докачать файл после того, как хендлер уже ответил пользователю. Для этого у asyncio есть create_task: она запускает корутину прямо сейчас и никого не заставляет ждать её окончания:

create_task: фоновая задача без ожидания
import asyncio

async def reminder(text):
    await asyncio.sleep(0.05)
    print("Напоминаю:", text)

async def main():
    task = asyncio.create_task(reminder("чай готов"))   # уже работает
    print("Хендлер закончился, задача живёт дальше")
    await task                   # дожидаемся перед выходом
    print("Задача завершилась")

asyncio.run(main())
Вывод
Хендлер закончился, задача живёт дальше
Напоминаю: чай готов
Задача завершилась

Смотрите на порядок: «Хендлер закончился» напечаталось раньше напоминания — main не ждала задачу, напоминание крутилось само. В настоящем боте create_task запускает фоновые работы из хендлера: пользователь получил ответ мгновенно, а отправка отчёта уехала в фон. Одно правило гигиены: задачу нужно дождаться с await до завершения работы бота — брошенная задача умрёт вместе с циклом событий, и asyncio встретит вас предупреждением в логах.

time.sleep в хендлере: бот-статуя

Финальный эксперимент — тот же самый бот, но с ловушкой внутри. Сравните две версии рядом: asyncio.sleep и time.sleep с одинаковой задержкой. Запустите и посмотрите на разницу порядков:

один бот, две версии: asyncio.sleep против time.sleep
import asyncio
import time

async def chat(user, blocking):
    print(user + ": запрос получен")
    if blocking:
        time.sleep(0.2)             # блокирует ВЕСЬ цикл событий
    else:
        await asyncio.sleep(0.2)    # ждёт только эта корутина
    print(user + ": ответ отправлен")

async def main():
    print("--- asyncio.sleep: неблокирующе ---")
    await asyncio.gather(chat("Аня", False), chat("Борис", False))

    print("--- time.sleep: блокирующе ---")
    await asyncio.gather(chat("Аня", True), chat("Борис", True))

asyncio.run(main())
Вывод
--- asyncio.sleep: неблокирующе ---
Аня: запрос получен
Борис: запрос получен
Аня: ответ отправлен
Борис: ответ отправлен
--- time.sleep: блокирующе ---
Аня: запрос получен
Аня: ответ отправлен
Борис: запрос получен
Борис: ответ отправлен

Одинаковые паузы по 0.2 секунды — разная жизнь. Вверху корутины перемешались: бот обслуживал обоих. Внизу — синхронная картина: пока time.sleep спал, цикл событий был заперт, и Борис не мог даже начать. В настоящем боте time.sleep в хендлере выглядит так: один пользователь попросил «напомни через минуту», вы написали time.sleep(60) — и весь бот на минуту умирает для всех остальных, включая команды и кнопки.

  • time.sleep — любая пауза в хендлере: заменяется на await asyncio.sleep;
  • requests.get и любой синхронный HTTP — заменяется на aiohttp или httpx.AsyncClient с await;
  • тяжёлые вычисления — большой цикл, обработка файла: разбивать на куски с await asyncio.sleep(0) или выносить в отдельный поток;
  • синхронные драйверы баз данных с долгими запросами — брать асинхронные аналоги или держать запросы короткими.

Как это выглядит в настоящем aiogram

В aiogram всё из этого урока уже спрятано в каркас бота: цикл событий запускает start_polling, каждый апдейт — корутина, поэтому хендлеры пишутся с async def и каждым обращением к API с await. Вот честный пример «напоминания»: бот ждёт пять секунд — но через await asyncio.sleep, поэтому остальные пользователи за эти пять секунд работают как ни в чём не бывало:

неблокирующая пауза в хендлере (настоящий aiogram)
import asyncio

from aiogram import Router
from aiogram.types import Message

router = Router()

@router.message(Command("tea"))
async def tea_time(message: Message, bot) -> None:
    await message.answer("Завариваю чай... осталось 5 секунд")
    await asyncio.sleep(5)                  # ждёт только этот хендлер
    await bot.send_message(message.chat.id, "Чай готов!")
Настоящий aiogram: нужен токен и сеть, в песочнице не запускается. Пока эта корутина ждёт на asyncio.sleep, бот обслуживает остальных — механику перемешивания вы запускали выше.
как делать не надо: time.sleep в хендлере
@router.message(Command("tea"))
async def tea_time_bad(message: Message, bot) -> None:
    await message.answer("Завариваю чай... осталось 5 секунд")
    time.sleep(5)                           # КАТАСТРОФА: стоит весь бот
    await bot.send_message(message.chat.id, "Чай готов!")
Антипример: пять секунд бот не отвечает никому - ни командам, ни кнопкам, ни другим пользователям. Лечение — одна буква: await asyncio.sleep(5).

Асинхронность — не украшение aiogram, а его двигатель: polling, middleware и ваши хендлеры крутятся в одном цикле событий. Дальше по курсу этот механизм станет осязаемым: в уроке про middleware код будет вставляться в сам поток апдейтов, и без понимания await там делать нечего. Фоновые задачи из этого урока там же превратятся в троттлинг — ограничение частоты сообщений. А если хотите увидеть тот же async/await в другой роли — второй урок курса FastAPI показывает асинхронный веб-сервер uvicorn, который живёт на том же цикле событий.

Асинхронный бот не быстрее считает — он умнее ждёт: в каждом ожидании он успевает обслужить кого-то ещё.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import asyncio

async def task(name):
    print(name, "старт")
    await asyncio.sleep(0)
    print(name, "финиш")

async def main():
    await asyncio.gather(task("А"), task("Б"))

asyncio.run(main())
def work(name):
    print(name, "начал")
    print(name, "закончил")

work("X")
work("Y")
import asyncio

async def slow():
    await asyncio.sleep(0.05)
    return "медленно"

async def fast():
    await asyncio.sleep(0.01)
    return "быстро"

async def main():
    print(await asyncio.gather(slow(), fast()))

asyncio.run(main())
Проверь себя
0 / 5

1. Что делает asyncio.run(greet("Аня"))?

2. Чем asyncio.sleep отличается от time.sleep?

3. Корутины в gather закончились в порядке Б, В, А. В каком порядке gather вернёт их результаты?

4. В хендлере написали bot.send_message(chat_id, text) без await. Что произойдёт?

5. Почему time.sleep(5) внутри хендлера aiogram — катастрофа?

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

Соберите мини-стенд параллельного бота: корутина chat(user) печатает «получила сообщение», делает await asyncio.sleep(0), затем печатает «ответила». Запустите chat("Аня") и chat("Борис") одновременно через gather в main() и запустите main через asyncio.run.

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

Зачем aiogram асинхронный?

Бот почти никогда не считает — он ждёт ответы сети. Асинхронность позволяет ждать сразу для тысяч пользователей: пока один хендлер стоит на await, цикл событий обслуживает остальных. Синхронный бот обрабатывает сообщения по очереди, и при десятках одновременных пользователей очередь становится ощутимой.

Чем asyncio.sleep отличается от time.sleep простыми словами?

time.sleep останавливает всю программу: пока он спит, бот не отвечает никому. asyncio.sleep ставит на паузу только одну корутину и вежливо отдаёт управление остальным. В хендлерах aiogram допустим только asyncio.sleep и только с await.

Что делать, если боту нужен блокирующий код: requests или тяжёлые вычисления?

Заменить на асинхронные аналоги: aiohttp или httpx.AsyncClient вместо requests с await на каждом запросе. Тяжёлые вычисления разбивают на куски с await asyncio.sleep(0) между ними или выносят в отдельный поток через loop.run_in_executor. Держать долгий синхронный вызов прямо в хендлере нельзя — он заморозит бот для всех.

Что такое корутина простыми словами?

Функция с правом на паузу: в отмеченных местах (await) она может прерваться и дать поработать другим, а потом продолжиться с того же места. Вызов корутины не выполняет её, а создаёт объект; запускают корутины asyncio.run, await или gather. В aiogram каждый хендлер — корутина, поэтому хендлеры пишут через async def.

Понравился урок? Сошлитесь на него

«time.sleep останавливает весь цикл событий, а asyncio.sleep ставит на паузу только одну корутину.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

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

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