Асинхронное программирование на Python: практические приёмы asyncio

Раздел: Продвинутый Python -> Async

Введение в asyncio: ключевые концепции

Библиотека asyncio предоставляет инфраструктуру для написания асинхронного кода с помощью синтаксиса async/await. Основные элементы: корутины (coroutines), задачи (tasks) и цикл событий (event loop). Корутина объявляется с async def и выполняется, только если её ожидать через await. Задача создаётся с помощью asyncio.create_task() и планирует выполнение корутины в фоне. Цикл событий управляет всеми асинхронными операциями.

Как организовать эффективную асинхронную загрузку данных из нескольких источников?

Наиболее оптимальный подход - использовать asyncio.gather() с пулом соединений через aiohttp. Этот метод запускает несколько корутин параллельно и собирает результаты в том же порядке, что и переданные аргументы. Рассмотрим пример загрузки трёх веб-страниц.

import asyncio
import aiohttp

async def fetch(session, url):
async with session.get(url) as response:
return await response.text()

async def main():
urls = [
'https://example.com',
'https://httpbin.org/get',
'https://jsonplaceholder.typicode.com/posts/1'
]
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
for url, result in zip(urls, results):
print(f'{url}: {len(result)} символов')

if __name__ == '__main__':
asyncio.run(main())

Python asyncio (asyncio в python)

Пояснение шагов:

  • Создаём корутину fetch, которая выполняет GET-запрос и возвращает содержимое.
  • В main открываем сессию aiohttp.ClientSession (это контекстный менеджер).
  • Формируем список задач, каждую из которых нужно ожидать.
  • asyncio.gather запускает все задачи одновременно и дожидается их завершения.
  • Результаты собираются в список в исходном порядке.

Типичные проблемы и решения:

  • Забытый await: если не написать await перед корутиной, она превратится в объект корутины, а не выполнится. Используйте asyncio.gather() или create_task() для запуска.
  • Блокирующие вызовы: внутри асинхронных функций нельзя использовать синхронные блокирующие операции (например, time.sleep()). Заменяйте на asyncio.sleep().
  • Исключения в задачах: если одна из задач выбрасывает исключение, gather по умолчанию бросает его сразу. Используйте return_exceptions=True, чтобы обработать все результаты.

Как запустить несколько задач и обработать их результаты по мере завершения?

Используйте asyncio.as_completed(). Этот генератор возвращает корутины в порядке завершения, а не в порядке отправки. Полезно, когда нужна потоковая обработка первых полученных данных.

import asyncio

async def work(n):
await asyncio.sleep(n)
return f'Task {n} finished'

async def main():
tasks = [work(3), work(1), work(2)]
for coro in asyncio.as_completed(tasks):
result = await coro
print(result)

if __name__ == '__main__':
asyncio.run(main())
# Вывод: Task 1 finished, Task 2 finished, Task 3 finished

Async file python (асинхронная работа с файлами)

Цель:

когда необходимо реагировать на результаты немедленно, не дожидаясь всех задач.

Проблема: as_completed ожидает каждую корутину по очереди, что может быть медленнее, чем gather при сборе всех результатов. Решение: выбирать по ситуации - для обработки по готовности, для сбора всех - gather.

Как ограничить количество одновременно выполняемых задач (например, при загрузке файлов)?

Применяется asyncio.Semaphore. Он ограничивает число корутин, которые могут одновременно входить в критическую секцию.

import asyncio

async def fetch(sem, url):
async with sem:
print(f'Загрузка {url}')
await asyncio.sleep(1)
return f'Done {url}'

async def main():
sem = asyncio.Semaphore(2)
urls = ['url1', 'url2', 'url3', 'url4']
tasks = [fetch(sem, url) for url in urls]
results = await asyncio.gather(*tasks)
print(results)

if __name__ == '__main__':
asyncio.run(main())

Цель: предотвращение перегрузки внешнего сервиса или исчерпания системных ресурсов.

Ошибка: использование семафора вне асинхронного контекста - ошибка времени выполнения. Всегда применяйте async with sem.

Как передавать данные между корутинами или организовать producer-consumer?

Используйте asyncio.Queue. Это асинхронная очередь, поддерживающая await put и await get. Отлично подходит для сценариев, где один процесс создаёт задачи, а другой их обрабатывает.

import asyncio

async def producer(queue):
for i in range(5):
await queue.put(i)
print(f'Produced {i}')
await asyncio.sleep(0.5)
await queue.put(None) # сигнал завершения

async def consumer(queue):
while True:
item = await queue.get()
if item is None:
break
print(f'Consumed {item}')

async def main():
q = asyncio.Queue()
await asyncio.gather(producer(q), consumer(q))

if __name__ == '__main__':
asyncio.run(main())

Цель: разделение на производителя и потребителя для балансировки нагрузки или обработки потоков данных.

Проблема: если очереди слишком большая, потребление памяти растёт. При бесконечном добавлении без потребления - утечка. Решение: использовать maxsize и блокировку.

Как обрабатывать исключения в отдельных задачах, не прерывая остальные?

Помимо return_exceptions=True в gather, можно вручную обернуть корутину в try/except или использовать asyncio.wait() с FIRST_EXCEPTION.

import asyncio

async def may_fail(n):
await asyncio.sleep(n)
if n == 2:
raise ValueError('Ошибка в задаче 2')
return n

async def main():
tasks = [may_fail(1), may_fail(2), may_fail(3)]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
for task in done:
if task.exception() is not None:
print(f'Поймано исключение: {task.exception()}')
else:
print(f'Результат: {task.result()}')
# отмена оставшихся задач, если нужно
for task in pending:
task.cancel()

if __name__ == '__main__':
asyncio.run(main())

Цель: тонкое управление жизненным циклом задач и изоляция ошибок.

Ошибка: игнорирование исключения, которое не было получено через .result() или .exception() - оно будет подавлено и не выведет traceback. Всегда проверяйте задачи после ожидания.

Как интегрировать асинхронный код с синхронными блокирующими операциями (например, вызов внешней библиотеки)?

Используйте loop.run_in_executor. Он запускает синхронную функцию в отдельном потоке (или процессе) и возвращает asyncio.Future.

import asyncio
import time

def blocking_io():
time.sleep(1)
return 'sync result'

async def main():
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_io)
print(result)

if __name__ == '__main__':
asyncio.run(main())

Цель: не блокировать цикл событий при вызове синхронных функций.

Проблема: чрезмерное количество потоков может привести к GIL-задержкам. Для CPU-интенсивных задач лучше использовать ProcessPoolExecutor.

Расширенные примеры использования asyncio

Пример 1: Отмена задачи через таймаут

Пример
import asyncio

async def long_running():
try:
await asyncio.sleep(10)
return 'Done'
except asyncio.CancelledError:
print('Задача отменена')
raise

async def main():
task = asyncio.create_task(long_running())
try:
await asyncio.wait_for(task, timeout=2)
except asyncio.TimeoutError:
print('Таймаут, задача отменяется')
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass

if __name__ == '__main__':
asyncio.run(main())
Таймаут, задача отменяется
Задача отменена

Здесь wait_for поднимает TimeoutError по истечении лимита. В блоке except явно отменяем задачу и обрабатываем CancelledError внутри самой корутины для корректной очистки.

Пример 2: Защита критической секции от отмены с asyncio.shield

Пример
import asyncio

async def critical_operation():
print('Начало критической операции')
await asyncio.sleep(2)
print('Конец критической операции')
return 'success'

async def main():
shielded = asyncio.shield(critical_operation())
try:
await asyncio.wait_for(shielded, timeout=1)
except asyncio.TimeoutError:
print('Истёк таймаут, но операция продолжается')
# Операция не отменена, можем дождаться её позже
result = await shielded
print('Результат защищённой операции:', result)

if __name__ == '__main__':
asyncio.run(main())
Начало критической операции
Истёк таймаут, но операция продолжается
Конец критической операции
Результат защищённой операции: success

shield оборачивает корутину так, что её нельзя отменить извне. Полезно для завершения обязательных действий, например записи в базу данных.

Пример 3: Асинхронные итераторы и генераторы

Пример
import asyncio

class AsyncCountdown:
def __init__(self, start):
self.count = start

def __aiter__(self):
return self

async def __anext__(self):
if self.count <= 0:
raise StopAsyncIteration
await asyncio.sleep(0.5)
self.count -= 1
return self.count + 1

async def main():
async for number in AsyncCountdown(3):
print(number)

if __name__ == '__main__':
asyncio.run(main())
3
2
1

Асинхронный итератор реализует методы __aiter__ и __anext__. Используется с async for.

Пример 4: Использование asyncio.Queue с приоритетом

Пример
import asyncio
import random

class PriorityQueue(asyncio.PriorityQueue):
pass

async def producer(queue):
for i in range(5):
priority = random.randint(1, 5)
await queue.put((priority, f'item {i}'))
await asyncio.sleep(0.3)
await queue.put((0, None))

async def consumer(queue):
while True:
priority, item = await queue.get()
if item is None:
break
print(f'Обработан {item} с приоритетом {priority}')

async def main():
pq = PriorityQueue()
await asyncio.gather(producer(pq), consumer(pq))

if __name__ == '__main__':
asyncio.run(main())
Обработан item ... с приоритетом ... (порядок по возрастанию приоритета)

asyncio.PriorityQueue извлекает элементы с наименьшим приоритетом (число). Позволяет реализовать справедливую обработку.

Пример 5: Комбинация asyncio и многопоточности через run_in_executor с ProcessPoolExecutor

Пример
import asyncio
from concurrent.futures import ProcessPoolExecutor
import math

def heavy_cpu(x):
return math.factorial(x)

async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
tasks = [loop.run_in_executor(pool, heavy_cpu, i) for i in range(1000, 1010)]
results = await asyncio.gather(*tasks)
print(results)

if __name__ == '__main__':
asyncio.run(main())

Для CPU-затратных задач используем ProcessPoolExecutor, чтобы обойти GIL. Результат - вычисление факториалов в параллельных процессах.

Пример 6: Асинхронный контекстный менеджер (подключение к базе)

Пример
import asyncio

class AsyncDatabase:
async def connect(self):
print('Подключение к БД')
await asyncio.sleep(0.1)
return self

async def query(self, sql):
print(f'Выполнение {sql}')
await asyncio.sleep(0.1)
return 'data'

async def close(self):
print('Закрытие соединения')

async def __aenter__(self):
return await self.connect()

async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

async def main():
async with AsyncDatabase() as db:
result = await db.query('SELECT 1')
print(result)

if __name__ == '__main__':
asyncio.run(main())
Подключение к БД
Выполнение SELECT 1
data
Закрытие соединения

Контекстный менеджер с async with гарантирует корректное открытие и закрытие ресурса.

Пример 7: Обработка множества сокетов с asyncio (эхо-сервер)

Пример
import asyncio

async def handle_client(reader, writer):
addr = writer.get_extra_info('peername')
print(f'Подключен {addr}')
while True:
data = await reader.read(100)
if not data:
break
writer.write(data)
await writer.drain()
print(f'Отключен {addr}')
writer.close()

async def main():
server = await asyncio.start_server(handle_client, '127.0.0.1', 8888)
async with server:
await server.serve_forever()

if __name__ == '__main__':
asyncio.run(main())

Этот пример демонстрирует базовый TCP-эхо-сервер. Каждое подключение обрабатывается в отдельной корутине, что позволяет обслуживать много клиентов одновременно без потоков.

Asyncio в Python - comments

En
Python asyncio (python)