Вчера на официальном сайте был опубликован первый релиз-кандидат Python 3.11, который принесет важные оптимизации и доработки в возможности языка. Релиз планируется в октябре этого года, но уже сейчас можно поэкспериментировать с новыми возможностями и сегодня мы поговорим о группах исключений и асинхронных задач. Первые позволяют одновременно выбрасывать и обрабатывать несколько исключений, в то время как вторые позволяют объединять задачи в общий event loop и координированно управлять группами задач.

Для тестирования мы будем использовать образ контейнера python:3.11-rc-slim-buster. Запустим контейнер через docker run --rm -it python:3.11-rc-slim-buster и получим доступ к REPL, где мы можем экспериментировать с новыми языковыми возможностями. Также мы сможем запускать python-файл через cat test.py | docker run -i python:3.11-rc-slim-buster.

Начнем с групп исключений и сразу попробуем подготовить для них пример. Группы исключений позволяют обрабатывать несколько одновременно возникших исключений (например, в async-функциях) и интерпретировать их как список объектов (ранее обрабатывалось только первое исключение). Более подробно спецификация групп исключений описана здесь.

В обычных ситуациях обработка исключений выполняется через блок try-except и если внутри блока кода несколько конкурирующих функций вернут исключение, то будет обработано только первое из них. Если исключение возникнет в блоке raise, то оно будет помечено как "произошедшее во время обработки другого исключения", при этом оно не будет иметь связи с исходным исключением.

В Python 3.11 добавлен новый класс ExceptionGroup, который может сообщать о возникновении нескольких исключений и давать для них текстовое описание. Это добавляет контекста к сообщению об ошибке и позволяет объединять несколько событий (кроме того, группа исключений может содержать другие группы и, таким образом, исключения могут быть рекурсивными). Если исключение не будет обработано, мы получим трассировку ошибки, в которой дерево исключений будет показано с учетом вложенности и комментариев. При этом при перехвате группы через try-except будет обнаружен только внешний объект ExceptionGroup, но не связанные с ним исключения. Для корректной обработки одного из исключений группы используется новая синтаксическая конструкция try - except*. Например, если выбросить одновременно исключения ValueError и ZeroDivisionError, то обработка их может выполнять независимо (и параллельно):

try:
  raise ExceptionGroup('Multiple exceptions', [ValueError(), ZeroDivisionError()])
except* ValueError as gr:
	print('Value error '+gr.exceptions)
except* ZeroDivisionError as gr:
  print('Zero division error '+gr.exceptions)

Обратите внимание, что в объект ошибки собираются все экземпляры данного типа (например, может быть отправлено несколько ValueError и можно получить доступ к каждому из них из списка exceptions). Если не все отправленные типы были обработаны в except*, они будут отправлены выше (и могут привести к аварийному завершению, при этом в трассировке будут указаны только необработанные типы).

В Python 3.11 все типы единичных исключений будут автоматически распознаваться в except*, как если бы они были отправлены внутри группы исключений.

Наиболее интересный сценарий - возникновение последовательных исключений при ожидании завершения группы задач. Предположим, что у нас есть относительно долгая задача, которая внутри себя обрабатывает данные из файла, которая может вызвать исключение. Тогда что будет возвращено, если мы вызовем несколько задач и соберем их результаты через asyncio.gather?

import asyncio
import io

async def process_file(filename):
  with open(filename, mode="r"):
  	print('File is opened')
    return True

async def process(filenames):
	tasks = [asyncio.create_task(process_file(filename)) for filename in filenames]
  await asyncio.gather(*tasks)
  
if __name__=="__main__":
	asyncio.run(process(['file1','file2','file3'])

Запустим это приложение (в каталоге, где нет файлов file1, file2 и file3) и увидим, что в трассировке будет отмечена только одна ошибка. Чтобы обойти эту проблему мы можем использовать флаг return_exceptions в gather, но более хорошим решением будет использование asyncio.TaskGroup. При выполнении задач в группе, исключения объединяются в общую ExceptionGroup и могут быть обработаны с помощью except*. Управление ресурсами (например, создание задачи) теперь будет выполняться внутри группы (вместо asyncio.create_task будет использовать tg.create_task), которая создается через менеджер контекста. Например, рассмотренный выше код может быть переработан следующим образом:

import asyncio

async def process_file(filename):
  with open(filename, mode="r"):
    print('File is opened')
    return True

async def process(filenames):
  try:
    async with asyncio.TaskGroup() as tg:
      tasks = [tg.create_task(process_file(filename)) for filename in filenames]
  except* FileNotFoundError as errors:
    print(f'Files not found')
    print([e.filename for e in errors.exceptions])
    
if __name__=="__main__":
	asyncio.run(process(['file1','file2','file3'])

Как можно видеть, TaskGroup заменяет gather и обеспечивает конкурентное выполнение задач и объединение возникших исключений в одну группу. После запуска мы увидим, что при перехвате ошибки будет сообщено о трех отсутствующих файлах (несмотря на то, что они запускались в отдельных асинхронных задачах, но в пределах TaskGroup все собранные исключения объединяются в группу исключений).

cat test.py | docker run -i python:3.11-rc-slim-buster
Files not found
['file1', 'file2', 'file3']

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

И в заключение приглашаю всех на бесплатный урок по теме: "Чистая архитектура в Python разработке", который проведет мой коллега из OTUS - Станислав Ступников.

Комментарии (5)


  1. teror4uks
    09.08.2022 14:38

    Эм я может что то путаю, но открытие файла так же как и его чтение в питоне не является асинхронной операцией, т.е. все эти корутины будут выполнены последовательно а не конкурентно. Может стоило ради примера использовать код (и библиотеки типа aiohttp или httpx) который бы скачивал какие то данные по сети?


    1. dmitriizolotov Автор
      09.08.2022 15:30

      Здесь мы проверяем выбрасывание исключения при отсутствующем файле, для этого достаточно использовать синхронную операцию. Конечно же, в реальных условиях надо было бы использовать async with aiofiles.open, но это потребовало бы установки aiofiles (или aiohttp), а мы используем REPL в docker-образе (конечно же и тут можно было бы применить pip.main(['install', 'aiofiles']), но это бы несколько усложнило пример).


    1. unn4m3d
      11.08.2022 17:38
      +1

      > чтение в питоне не является асинхронной операцией

      Да, асинхронные задачи в заголовке статьи, россыпь async в коде и совершенно синхронный код под капотом, python...


  1. AcckiyGerman
    09.08.2022 16:09
    +5

    Python 3.11 TaskGroups WorkGroups ;)


  1. AcckiyGerman
    09.08.2022 16:30
    +4

    asyncio.TaskGroup это мощная фишка из структурного асинхронного программирования и я бы назвал это основным в новости, а отлов групп исключений (имхо) в основном дополняет этот механизм (хотя группы исключений и возникли раньше).

    TaskGroup запилен лично Гвидо , на основе уже отлаженного кода и идей (trio, anyio), что дает надежду на мощный импульс нового стиля асинхронного программирования.

    Достаточно только сказать, что Гвидо предлагает считать asyncio.gather неприоритетным способом запускать группы задач.