Асинхронное программирование на Python: практические приёмы asyncio
Введение в 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 finishedAsync 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-эхо-сервер. Каждое подключение обрабатывается в отдельной корутине, что позволяет обслуживать много клиентов одновременно без потоков.