asyncio на практике: задачи, таймауты, семафоры
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
Ограничьте конкурентность: 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, "Загрузки должны идти параллельно (до лимита), а не по одной"
import asyncio
async def download(url, state):
state["active"] += 1
state["max"] = max(state["max"], state["active"])
await asyncio.sleep(0.01)
state["active"] -= 1
return url.upper()
async def download_all(urls, limit):
state = {"active": 0, "max": 0}
semaphore = asyncio.Semaphore(limit)
async def limited(url):
async with semaphore:
return await download(url, state)
results = await asyncio.gather(*(limited(u) for u in urls))
return results, state["max"]
Напишите 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 не нужно повторять"
import asyncio
async def retry(coro_factory, attempts=3, base_delay=0.01):
for attempt in range(attempts):
try:
return await coro_factory()
except ConnectionError:
if attempt == attempts - 1:
raise
await asyncio.sleep(base_delay * 2 ** attempt)
Напишите 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, "Нужно прервать ожидание по таймауту"
import asyncio
async def first_or_timeout(coro, seconds, default=None):
try:
async with asyncio.timeout(seconds):
return await coro
except TimeoutError:
return default
Что делает asyncio.gather(a(), b(), return_exceptions=True), если b() бросила исключение?
Чем asyncio.TaskGroup лучше набора create_task + gather?
Войдите, чтобы отмечать пройденные уроки.