Перейти к содержанию
Educora
Продвинутый22 мин21 / 42

Асинхронный Python: async и await

Выполняй задачи с ожиданием конкурентно: корутины, `await`, цикл событий, `asyncio.run`, `gather`, `create_task`, `TaskGroup`, тайм-ауты, `async for` и опасность блокировки цикла.

Проверь себя
В этом уроке ты узнаешь
  • Объяснять понятия корутины, await и цикла событий
  • Запускать корутины конкурентно с помощью gather, create_task и TaskGroup
  • Задавать тайм-ауты через wait_for и использовать async for
  • Понимать, когда asyncio полезен и что блокирует цикл событий

Программе нужно скачать из интернета 100 страниц. Каждый запрос около 0,3 с просто ждёт сеть — процессор в это время ничего не делает. Если выполнять запросы по очереди, получится 30 секунд почти пустого ожидания. Вспомни официанта в чайхане: он не стоит у двери кухни, пока готовится один заказ, а тем временем принимает заказ у следующего столика. Асинхронное программирование позволяет одному потоку работать так же: пока одна операция ждёт, другие продвигаются.

Корутины, await и цикл событий

Определение
Корутина (сопрограмма)

Функция, определённая через async def, — это корутинная функция. Её вызов не выполняет тело: как и функция-генератор, она возвращает объект корутины, который нужно дождаться через await.

await выполняет корутину и ждёт её результата. На каждом await, где операция ещё не завершена, корутина приостанавливается и возвращает управление циклу событий (event loop) — планировщику, который решает, какая из приостановленных корутин продолжит работу. Ничего не выполняется параллельно: поток один, а корутины добровольно уступают друг другу очередь. Это называется кооперативной многозадачностью.

Python
import asyncio

async def greet(name, delay):
    await asyncio.sleep(delay)
    print(f'Hello, {name}!')
    return name.upper()

coro = greet('Aysel', 0.1)
print(type(coro).__name__)
result = await coro
print(result)
▸ Ожидаемый результат
coroutine
Hello, Aysel!
AYSEL
В браузере этот код выполняется внутри уже работающего цикла событий, поэтому await можно писать прямо на верхнем уровне.

В обычном файле .py await вне функции — синтаксическая ошибка. Там у программы есть одна входная корутина, обычно main(), а цикл событий запускается один раз, в самом конце скрипта, вызовом **asyncio.run(main())**:

Python
import asyncio

async def fetch(name, seconds):
    await asyncio.sleep(seconds)
    return f'{name} ({seconds} s)'

async def main():
    results = await asyncio.gather(fetch('rates', 0.3), fetch('news', 0.1))
    print(results)

if __name__ == '__main__':
    asyncio.run(main())
Ожидаемый результат
['rates (0.3 s)', 'news (0.1 s)']
Структура обычного скрипта: запусти его на своём компьютере командой python app.py.

Конкурентный запуск через gather

asyncio.gather(*coroutines) запускает несколько корутин сразу и ждёт, пока все они завершатся. Посмотри на порядок строк: все три стартуют немедленно и заканчивают в порядке времени ожидания, а не в порядке записи. А вот результаты возвращаются в порядке аргументов:

Python
import asyncio

async def fetch(name, seconds):
    print(f'start {name}')
    await asyncio.sleep(seconds)
    print(f'done  {name}')
    return f'{name} ({seconds} s)'

results = await asyncio.gather(
    fetch('rates', 0.3),
    fetch('weather', 0.2),
    fetch('news', 0.1),
)
print(results)
▸ Ожидаемый результат
start rates
start weather
start news
done  news
done  weather
done  rates
['rates (0.3 s)', 'weather (0.2 s)', 'news (0.1 s)']

Теперь измерим. Если дожидаться корутин по очереди, ожидания складываются; с gather они накладываются друг на друга, поэтому общее время равно самому долгому ожиданию:

Python
import asyncio
import time

async def fetch(seconds):
    await asyncio.sleep(seconds)

start = time.perf_counter()
for s in (0.3, 0.2, 0.1):
    await fetch(s)
print(f'one after another: {time.perf_counter() - start:.2f} s')

start = time.perf_counter()
await asyncio.gather(fetch(0.3), fetch(0.2), fetch(0.1))
print(f'with gather:       {time.perf_counter() - start:.2f} s')
Ожидай примерно 0,60 и 0,30 с; точные числа при каждом запуске немного отличаются.

Задачи, TaskGroup и тайм-ауты

asyncio.create_task(coro) сразу планирует выполнение корутины в фоне и возвращает задачу (task). Тем временем можно заниматься другой работой, а результат получить позже через await задачи. Начиная с Python 3.11 **asyncio.TaskGroup** даёт структурированную конкурентность: блок async with не завершается, пока не закончатся все его задачи, а если одна из них падает, остальные автоматически отменяются:

Python
import asyncio

async def worker(name, seconds):
    await asyncio.sleep(seconds)
    print(f'{name} finished')
    return seconds

async def main():
    task = asyncio.create_task(worker('backup', 0.2))
    print('task created, doing other work...')
    await asyncio.sleep(0.1)
    print('other work done, task done?', task.done())
    print('result:', await task)

    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(worker('A', 0.2))
        t2 = tg.create_task(worker('B', 0.1))
    print('group results:', t1.result(), t2.result())

await main()
▸ Ожидаемый результат
task created, doing other work...
other work done, task done? False
backup finished
result: 0.2
B finished
A finished
group results: 0.2 0.1

Сетевые сервисы иногда вообще не отвечают. asyncio.wait_for(coro, timeout) отменяет операцию через заданное число секунд и возбуждает TimeoutError (начиная с Python 3.11 asyncio.TimeoutError — тот же класс):

Python
import asyncio

async def slow_api():
    await asyncio.sleep(1)
    return 'data'

try:
    result = await asyncio.wait_for(slow_api(), timeout=0.2)
except TimeoutError:
    print('the API did not answer in 0.2 s')
▸ Ожидаемый результат
the API did not answer in 0.2 s

У других конструкций языка тоже есть асинхронные версии: async with — для контекстных менеджеров, подготовка или очистка которых требует ожидания (например, сетевое соединение), и async for — для асинхронных итераторов. Асинхронный генератор — это функция async def, в которой есть yield:

Python
import asyncio

async def ticker(n, delay):
    for i in range(1, n + 1):
        await asyncio.sleep(delay)
        yield i

async for value in ticker(3, 0.05):
    print('tick', value)

squares = [v * v async for v in ticker(4, 0.01)]
print(squares)
▸ Ожидаемый результат
tick 1
tick 2
tick 3
[1, 4, 9, 16]

Ловушки и когда выбирать asyncio

ПодходДля чего подходитПример
asyncioмного одновременных ожиданий в одном потоке (сеть, сокеты)чат-сервер, веб-краулер, FastAPI
потоки (threading)блокирующие библиотеки, которые нельзя ждать через awaitнесколько загрузок через requests
процессы (multiprocessing)тяжёлые вычисления на всех ядрах процессораобработка изображений, моделирование
Задание

Приготовь три блюда одновременно с помощью asyncio.gather и выведи список результатов. Обрати внимание: строки «ready» появляются в порядке времени готовки, а список сохраняет порядок меню.

Задание · Python
import asyncio

async def cook(dish, seconds):
    await asyncio.sleep(seconds)
    print('ready:', dish)
    return dish

menu = [('plov', 0.3), ('salad', 0.1), ('soup', 0.2)]
# cook all dishes at the same time with asyncio.gather and print the results
▸ Ожидаемый результат
ready: salad
ready: soup
ready: plov
['plov', 'salad', 'soup']
Задание

Напиши корутину fetch_with_limit: она запускает fetch с ограничением времени limit секунд и возвращает результат, а если время вышло — строку '<name>: timeout'.

Задание · Python
import asyncio

async def fetch(name, seconds):
    await asyncio.sleep(seconds)
    return f'{name}: ok'

async def fetch_with_limit(name, seconds, limit):
    # return fetch's result, or f'{name}: timeout' if it takes longer than limit
    ...

results = await asyncio.gather(
    fetch_with_limit('fast', 0.05, 0.2),
    fetch_with_limit('slow', 0.5, 0.2),
)
print(results)
▸ Ожидаемый результат
['fast: ok', 'slow: timeout']

Главное

  • async def определяет корутину; её вызов возвращает объект корутины, а await выполняет его.
  • На каждом await корутина приостанавливается, и цикл событий запускает другую — один поток, кооперативная многозадачность.
  • Скрипт запускается через asyncio.run(main()); в браузере и Jupyter пишут await напрямую.
  • gather, create_task и TaskGroup выполняют корутины конкурентно; общее время ≈ самое долгое ожидание.
  • Не блокируй цикл через time.sleep или тяжёлые вычисления; asyncio — для задач с ожиданием ввода-вывода.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Определена async def load(): .... Что возвращает вызов load()?