asyncio на практике: задачи, таймауты, семафоры

⏱ 40 мин · 🧩 заданий: 5

TaskGroup — структурная конкурентность

С Python 3.11 рекомендуемый способ запускать группу задач — asyncio.TaskGroup:

import asyncio

async def fetch(name, delay, fail=False):
    await asyncio.sleep(delay)
    if fail:
        raise ConnectionError(f"{name}: сервер недоступен")
    return f"{name}: ok"

async def main():
    async with asyncio.TaskGroup() as tg:                   # все задачи живут внутри блока
        users = tg.create_task(fetch("users", 0.1))
        orders = tg.create_task(fetch("orders", 0.2))
    print(users.result(), "|", orders.result())             # после блока все завершены

    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fetch("users", 0.1, fail=True))
            slow = tg.create_task(fetch("reports", 1.0))    # будет отменена
    except* ConnectionError as eg:
        print("ошибки:", [str(e) for e in eg.exceptions])
    print("reports отменена:", slow.cancelled())

asyncio.run(main())

Гарантии TaskGroup: при выходе из блока все задачи завершены; если одна упала — остальные отменяются, а ошибки приходят как ExceptionGroup (ловится через except*).

gather и ошибки

import asyncio

async def ok(x):
    await asyncio.sleep(0.01)
    return x

async def boom():
    await asyncio.sleep(0.01)
    raise ValueError("сломалось")

async def main():
    try:
        await asyncio.gather(ok(1), boom(), ok(3))        # первое исключение пробрасывается
    except ValueError as e:
        print("gather бросил:", e)

    results = await asyncio.gather(ok(1), boom(), ok(3), return_exceptions=True)
    print(results)                                        # исключения — значениями
    good = [r for r in results if not isinstance(r, Exception)]
    print("успешные:", good)

asyncio.run(main())

Отмена и таймауты

import asyncio

async def long_job():
    try:
        print("  работа началась")
        await asyncio.sleep(10)
        return "результат"
    except asyncio.CancelledError:
        print("  отменили — освобождаю ресурсы")
        raise                                     # CancelledError нужно пробрасывать!

async def main():
    task = asyncio.create_task(long_job())
    await asyncio.sleep(0.05)
    task.cancel()                                 # попросить задачу остановиться
    try:
        await task
    except asyncio.CancelledError:
        print("задача отменена:", task.cancelled())

    try:
        async with asyncio.timeout(0.1):          # 3.11+: таймаут на блок кода
            await long_job()
    except TimeoutError:
        print("таймаут через asyncio.timeout")

    try:
        await asyncio.wait_for(long_job(), timeout=0.1)   # таймаут на одну корутину
    except TimeoutError:
        print("таймаут через wait_for")

asyncio.run(main())

Не глушите CancelledError

except Exception: в Python 3.8+ не ловит CancelledError (он наследует BaseException), но except BaseException: или голый except: поймают — и задача не сможет отмениться. Если перехватили для очистки — пробросьте дальше (raise).

Каждый сетевой вызов должен иметь таймаут: без него зависший сервер подвесит вашу задачу навсегда.

as_completed — по мере готовности

import asyncio, random

async def fetch(i):
    delay = random.Random(i).uniform(0.05, 0.3)
    await asyncio.sleep(delay)
    return i, round(delay, 2)

async def main():
    for coro in asyncio.as_completed([fetch(i) for i in range(5)]):
        i, delay = await coro
        print(f"  готов запрос {i} за {delay} с")

asyncio.run(main())

Semaphore — ограничить конкурентность

10 000 одновременных запросов к одному API — верный способ получить бан или положить сервис. Semaphore(n) пропускает не более n задач одновременно:

import asyncio, time

async def call_api(i, sem, log):
    async with sem:                         # не больше 3 одновременно
        log.append(f"+{i}")
        await asyncio.sleep(0.1)
        log.append(f"-{i}")
        return i

async def main():
    sem = asyncio.Semaphore(3)
    log = []
    start = time.perf_counter()
    results = await asyncio.gather(*(call_api(i, sem, log) for i in range(9)))
    print(results, f"{time.perf_counter() - start:.1f} с")   # 9 задач по 3 → ≈0.3 с
    print(" ".join(log))

asyncio.run(main())

Queue — пул воркеров

Для потока задач (краулер, обработка сообщений) — asyncio.Queue и фиксированное число воркеров:

import asyncio

async def worker(name, queue, results):
    while True:
        url = await queue.get()
        try:
            await asyncio.sleep(0.05)           # «скачиваем»
            results.append((name, url))
        finally:
            queue.task_done()

async def main():
    queue = asyncio.Queue()
    for i in range(10):
        queue.put_nowait(f"/page/{i}")
    results = []
    workers = [asyncio.create_task(worker(f"w{n}", queue, results)) for n in range(3)]
    await queue.join()                          # дождаться обработки всех элементов
    for w in workers:
        w.cancel()                              # остановить бесконечные воркеры
    await asyncio.gather(*workers, return_exceptions=True)
    print(len(results), "страниц;", sorted({name for name, _ in results}))

asyncio.run(main())

Синхронизация в asyncio

Гонки в asyncio бывают — между await:

import asyncio

balance = 100

async def withdraw(amount, lock=None):
    global balance
    async def critical():
        global balance
        current = balance
        await asyncio.sleep(0.01)          # переключение посреди «прочитать–изменить–записать»
        if current >= amount:
            balance = current - amount
            return True
        return False
    if lock:
        async with lock:
            return await critical()
    return await critical()

async def main():
    global balance
    print(await asyncio.gather(withdraw(80), withdraw(80)), "баланс", balance)   # обе «успешны»!
    balance = 100
    lock = asyncio.Lock()
    print(await asyncio.gather(withdraw(80, lock), withdraw(80, lock)), "баланс", balance)

asyncio.run(main())

asyncio.Lock, Event, Condition, Semaphore — аналоги примитивов threading, но для корутин (и не потокобезопасны).

Реальный HTTP

import asyncio
import httpx

async def fetch_user(client: httpx.AsyncClient, user_id: int) -> dict:
    response = await client.get(f"/users/{user_id}")
    response.raise_for_status()
    return response.json()

async def main():
    limits = httpx.Limits(max_connections=20)
    async with httpx.AsyncClient(base_url="https://api.example.com", timeout=10, limits=limits) as client:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(fetch_user(client, i)) for i in range(1, 101)]
    users = [t.result() for t in tasks]

asyncio.run(main())

Один клиент на всё приложение (пул соединений), таймауты, ограничение соединений.

Типичные ошибки

Ошибка Симптом Исправление
забыли await RuntimeWarning: coroutine was never awaited, ничего не происходит await coro()
await подряд в цикле нет ускорения gather / TaskGroup
блокирующий вызов всё «замирает» async-библиотека / to_thread
нет таймаутов задачи висят вечно asyncio.timeout
неограниченная конкурентность 429, исчерпаны соединения Semaphore, лимиты клиента
потерянная задача задача исчезает или ошибка не видна хранить ссылки, TaskGroup
asyncio.run внутри работающего цикла RuntimeError await вместо run

Вопрос на собеседовании

«Как ограничить количество одновременных запросов в asyncio?» — asyncio.Semaphore(n) и async with sem: вокруг запроса, или пул из n воркеров с asyncio.Queue, плюс ограничение пула соединений в HTTP-клиенте. Обязательно таймауты и обработка ошибок (TaskGroup/return_exceptions).

Итог

  • TaskGroup — группа задач с отменой остальных при ошибке и ExceptionGroup.
  • gather(..., return_exceptions=True) — собрать и результаты, и ошибки.
  • task.cancel(), CancelledError пробрасывать; таймауты — asyncio.timeout, wait_for.
  • Semaphore ограничивает конкурентность, Queue + воркеры — поток задач.
  • Гонки бывают между await — asyncio.Lock.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Ограничьте конкурентность: download_all(urls, limit) должна «скачать» все адреса через готовую корутину download(url, state), но так, чтобы одновременно выполнялось не больше limit загрузок (используйте asyncio.Semaphore). Верните список результатов в порядке urls. Проверка следит за максимальным числом одновременных загрузок.

Какие тесты проверяют код (1)
import asyncio
results, peak = asyncio.run(download_all([f"u{i}" for i in range(20)], 3))
assert results == [f"U{i}" for i in range(20)]
assert peak <= 3, f"Одновременно выполнялось {peak} загрузок, а лимит 3"
assert peak == 3, "Загрузки должны идти параллельно (до лимита), а не по одной"

Задание 2 Программа на Python

Напишите retry(coro_factory, attempts, base_delay): вызывает await coro_factory(); при исключении ConnectionError ждёт base_delay * 2**попытка (0, 1, 2… — экспоненциальная задержка) и пробует снова. После attempts неудачных попыток пробрасывает последнее исключение. Другие исключения не перехватываются.

Какие тесты проверяют код (3)
import asyncio
calls = []
async def flaky():
    calls.append(1)
    if len(calls) < 3:
        raise ConnectionError("нет сети")
    return "ok"
assert asyncio.run(retry(flaky, attempts=3, base_delay=0.001)) == "ok" and len(calls) == 3

import asyncio
async def always_fail():
    raise ConnectionError("нет сети")
try:
    asyncio.run(retry(always_fail, attempts=2, base_delay=0.001))
    ok = False
except ConnectionError:
    ok = True
assert ok, "После всех попыток исключение должно пробрасываться"

import asyncio
n = []
async def bad():
    n.append(1)
    raise ValueError("ошибка логики")
try:
    asyncio.run(retry(bad, attempts=5, base_delay=0.001))
except ValueError:
    pass
assert len(n) == 1, "ValueError не нужно повторять"

Задание 3 Программа на Python

Напишите first_or_timeout(coro, seconds, default): вернуть результат корутины, а если она не завершилась за seconds — вернуть default (корутина при этом отменяется). Используйте asyncio.timeout (Python 3.11+) или asyncio.wait_for.

Какие тесты проверяют код (2)
import asyncio
async def fast():
    await asyncio.sleep(0.01)
    return "готово"
assert asyncio.run(first_or_timeout(fast(), 1)) == "готово"

import asyncio, time
async def slow():
    await asyncio.sleep(2)
    return "поздно"
start = time.perf_counter()
assert asyncio.run(first_or_timeout(slow(), 0.05, "таймаут")) == "таймаут"
assert time.perf_counter() - start < 1, "Нужно прервать ожидание по таймауту"

Задание 4 Один вариант ответа

Что делает asyncio.gather(a(), b(), return_exceptions=True), если b() бросила исключение?

Задание 5 Один вариант ответа

Чем asyncio.TaskGroup лучше набора create_task + gather?

Войдите, чтобы отмечать пройденные уроки.