Обработка исключений в asyncio требует тщательного управления, чтобы гарантировать, что код не станет нестабильным.

Это вторая часть серии статей, посвященных проблемам, возникающим при ожидании нескольких событий в Python Asyncio. В первой статье было рассмотрено, как управлять жизненным циклом задач при использовании asyncio.wait. В этой статье обсуждается, как asyncio.wait обрабатывает исключения и что нужно сделать программисту, чтобы обеспечить сбор результатов и согласованное завершение задач.

Ожидание одной сопрограммы

При ожидании одной сопрограммы обработка исключений управляется точно так же, как и не-асинхронный код с использованием блока try…except…finally. Например, в сопрограмме foo возникает исключение, которое распространяется до блока main, где оно перехватывается и печатается ошибка.

Однако, если результат в main ожидает завершения нескольких сопрограмм, что должно произойти, если одна из этих сопрограмм выдаст исключение? Должен ли он быть немедленно распространен на основную сопрограмму? Что делать с незаконченными задачами? Должны ли они быть прекращены или разрешено продолжать? Чтобы предотвратить любые нежелательные побочные эффекты, программист должен иметь план действий для обработки исключений, когда они возникают.

Планировщик вызывает сопрограмму

В предыдущем примере, когда мы запускаем main и ожидаем корутины foo, они не запускаются сразу. Вместо этого они добавляются в список сопрограмм для запуска в какой-то момент в будущем. Это большое отличие по сравнению с обычными функциями Python и то, что позволяет нескольким задачам выполняться параллельно.

Итак, когда вызывается asyncio.run(main()), сопрограмма main() помещается в цикл событий и, поскольку это единственная сопрограмма в цикле событий, она вызывается планировщиком и начинает выполняться. Когда основная сопрограмма встречает оператор await foo(), сопрограмма foo добавляется в список событий, и выполнение основной функции приостанавливается, а управление возвращается планировщику. Затем планировщик проверяет, есть ли какие-либо другие задачи для выполнения, и, увидев foo, вызывает сопрограмму до тех пор, пока она не завершится. Завершение foo устанавливает флаг в main, что вызов foo завершен; поэтому при следующем проходе по циклу событий планировщик видит, что main может продолжать выполнение, и поэтому вызывает его, при этом выполнение продолжается с точки, где main уступил.

Мы также можем рассмотреть случай, когда main ожидает завершения двух задач. В этом случае создаются две задачи (a, b). Они добавляются в цикл событий, но не будут выполняться до тех пор, пока основной цикл не передаст управление обратно планировщику, после чего планировщик сможет вызывать вновь созданные задачи.

Теперь мы можем рассмотреть случай, когда сопрограмма main передала управление планировщику, а планировщик вызвал задачу a. По какой-то причине задача a вызывает исключение. Это исключение не распространяется до main, так как main равнозначно a в цикле событий и вместо этого распространяется до планировщика.

Планировщик фиксирует исключение и передает объект задачи обратно в main, отмечая, что возникло исключение. В зависимости от параметров в asyncio.wait планировщик затем определит, можно ли вызвать main при следующем проходе по циклу событий или все задачи должны быть завершены до вызова main.

В этот момент вы заметите:

  • На данный момент в main исключений не возникало. Его можно обработать только после того, как планировщик вызовет сопрограмму main, что происходит при выполнении условия return_when.
  • Сопрограмма a завершена и будет удалена из цикла обработки событий. Все использованные ресурсы будут освобождены.
  • Статус сопрограммы b не определен. Он мог или не мог начать выполнение; и, возможно, или, возможно, не завершено. Если условие return_when для main равно FIRST_EXCEPTION, то main может продолжить выполнение и завершиться до того, как b будет выполнено и завершится.

При ожидании нескольких задач необходим план:

  • Какое поведение требуется, когда в задаче (сопрограмме) возникает исключение? Вы хотите дождаться завершения всех других задач, прежде чем вызывающая сопрограмма продолжит выполнение или немедленно вернется?
  • Если вы дождетесь возврата всех задач, как вы определите, какие задачи вернули результаты, а какие имели исключения? Сколько задач могут одновременно возвращать исключения? Это может быть от нуля до количества задач, которые вы ожидаете.
  • Если вы возвращаетесь, когда возникает первое исключение, как вы гарантируете, что все остальные задачи завершили выполнение и все используемые ими ресурсы безопасно освобождены? Задачи могут находиться в неизвестном состоянии и могут создавать нежелательные побочные эффекты, если они оставлены для выполнения на неопределенный срок или ресурсы, на которые они полагаются, уничтожены. Например, если вызывающая сопрограмма закрывает сетевые соединения до того, как вызванная сопрограмма будет выполнена, то вызываемая сопрограмма может бесконечно ждать несуществующего сетевого соединения.

Захват исключений, возникающих в задачах

Чтобы продемонстрировать обработку исключений с помощью asyncio.wait, в следующем примере есть две сопрограммы: foo, которая немедленно вызывает ValueError, и bar, которая засыпает на одну секунду, а затем возвращается. Функция main инкапсулирует эти сопрограммы в задачи перед ожиданием завершения обеих задач. Когда задачи завершены, завершенные задачи возвращаются в список done, а незавершенные задачи возвращаются в pending.

Когда foo_task и bar_task завершатся, список выполненных задач будет содержать обе задачи. Однако в этот момент исключение, вызванное в foo, не перебрасывается в main. Если код запущен, то создается следующий вывод.

Done: Waiting task
Done: Exception task
------------ At program termination -------------
Task exception was never retrieved
future: <Task 
    finished name='Exception task'
    coro=<foo() done,exception=ValueError('Foo value error')>
>
ValueError: Foo value error

Мы ясно видим, что обе задачи выполнены, и мы можем получить названия задач и распечатать их. Однако обращение к имени задачи не приводило к повторной генерации исключения в foo_task. Что, возможно, больше беспокоит, так это то, что foo_task остается в цикле событий до тех пор, пока программа не завершится. Если исключение не обрабатывается, задачи могут оставаться в цикле событий на неопределенный срок.

Получение исключений для каждой задачи

Задачи, возвращенные из asyncio.wait, имеют следующие методы, относящиеся к обработке исключений:

  • get_name() : Это необязательно, однако присвоение задачам осмысленных имен значительно упрощает создание описательных исключений и отладку.
  • exception() : возвращает объект исключения, сгенерированный сопрограммой, None, если исключение не возникло.
  • result() : возвращает результат сопрограммы, повторно выдает исключение, если сопрограмма вызвала исключение.

Используя эти методы, мы можем переписать основную функцию для вывода имени задачи; проверить, вызвала ли сопрограмма исключение, и распечатать сведения об исключении; и получить результат сопрограммы, используя блок try…except для захвата любых повторно сгенерированных исключений.

Выполнение приведенного выше кода приводит к следующему выводу. Мы можем ясно видеть, что foo сгенерировал исключение ValueError, которое затем было повторно сгенерировано и перехвачено при вызове .result(). Обратите внимание, порядок возвращаемых задач — это порядок их выполнения; и использование этого комплексного подхода к обработке исключений и ожидающих задач гарантирует, что все задачи будут управляться и удаляться из цикла обработки событий до завершения вызывающей сопрограммы (в данном случае main).

DONE: Exception_task
Exception_task threw Foo value error
ValueError: Foo value error
DONE: Waiting_task
Waiting_task returned Bar finished
------------ At program termination -------------

При планировании обработки исключений и отмены ожидающих задач необходимо учитывать несколько соображений:

  • Спланируйте, как вы будете обрабатывать отложенные задачи и исключения. Трудно отлаживать асинхронный код, когда нет кода для явной обработки этих условий.
  • Существует высокая вероятность того, что задачи, выдающие исключения, вернутся раньше, чем те, которые возвращают результат со значением. Это означает, что они появятся раньше, если не первыми, в итерируемом списке. Таким образом, первая задача, для которой вызывается метод result(), может вызвать исключение, и в этом случае вы можете захотеть обработать все остальные результаты и отменить ожидающие задачи, прежде чем повторно выдать эту ошибку.
  • Порядок действий после asyncio.wait может быть важен в вашей реализации. Разработайте наиболее подходящий порядок отмены отложенных задач, получения результатов, проверки и обработки исключений.
  • Если более чем одна задача выдает исключение, вам может потребоваться решить, как объединить их в одно сообщение об ошибке, которое можно повторно выдать после обработки всех других задач.

Исключения в asyncio.gather

Python предоставляет asyncio.gather в качестве более высокоуровневой альтернативы asyncio.wait при ожидании завершения всех задач. Он управляет переносом сопрограмм в задачи и обеспечивает удаление задач из цикла обработки событий при возврате функции. Используя предыдущий пример foo bar, мы можем заменить asyncio.wait на:

results = await asyncio.gather(foo(), bar(), return_exceptions=True)
print(results)
[ValueError('Foo value error'), 'Bar finished']

Ключевым параметром здесь является return_exceptions. Если для этого параметра задано значение True, функция возвращает список как значений, так и исключений; позволяя завершить все задачи перед возвращением. По мере выполнения всех задач они удаляются из цикла событий.

Если для return_exceptions задано значение False (значение по умолчанию), то asyncio.gather возвращается немедленно, выбрасывая исключение в вызывающую сопрограмму. Любая задача, которая не была завершена, останется в цикле событий.

Следующий код объясняет поведение более четко.

[Следующий раздел был отредактирован, чтобы исправить ошибку в оригинале, в которой говорилось, что панель была отменена при выходе из оператора ожидания. Это было ошибочно, так как задачи не отменяются при выходе из сбора.]

Когда этот код выполняется, foo немедленно вызывает исключение ValueError. Это немедленно распространяется на main, где оно перехватывается и печатается сообщение об ошибке. bar будет продолжать работать до тех пор, пока не завершится или программа не завершится. В этом примере программа завершается до завершения бара, что приводит к появлению CancelledError .

Value Error raised.
Bar Cancelled.

Если bar — это задача, которая не завершается или выполняется в течение длительного времени, а foo выдает ошибку, то нет возможности получить доступ к bar, чтобы завершить ее вручную. Если он удерживает ресурсы, то они не будут освобождены до тех пор, пока не истечет время ожидания bar или программа не завершится.

Всегда планируйте отмененные задачи

Предыдущий пример обращает внимание на возможную отмену задач в середине выполнения. При планировании выполнения кода вполне возможно, что сопрограммы будут отменены, когда выполнение будет запущено в другом месте кода. В случае сбора это может привести к тому, что CancelledError будет выброшено в одноранговую сопрограмму.

Если сопрограмма, в которой вызывается CancelledError, взаимодействует с другой системой, обращается к базе данных или изменяет ресурсы, это может оставить систему в нестабильном состоянии. Улавливание CancelledError дает возможность раскручивать транзакции, сводя к минимуму риск повреждения данных, неэффективности или сбоя системы.

Python asyncio — это мощный инструмент для повышения эффективности системы за счет асинхронного выполнения кода. Однако очень легко реализовать код, который явно не обрабатывает жизненный цикл задач и исключений. Заранее планируя создание и отмену задач и обработку исключений, вы значительно сократите время, затрачиваемое на диагностику ошибок, которые могут возникнуть.

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

Дополнительные материалы на PlainEnglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter и LinkedIn. Присоединяйтесь к нашему сообществу Discord.