Документация Python неофициальный перевод
Содержание страницы

Разработка с asyncioDeveloping with asyncio

Асинхронное программирование отличается от классического «последовательного» программирования.

На этой странице перечислены типичные ошибки и ловушки, а также объясняется, как их избежать.

Режим отладкиDebug Mode

По умолчанию asyncio работает в производственном режиме. Для упрощения разработки в asyncio есть режим отладки.

Включить режим отладки asyncio можно несколькими способами:

В дополнение к включению режима отладки также рекомендуется:

  • Установка уровня журналирования логгера asyncio на logging.DEBUG; например, следующий фрагмент кода можно выполнить при запуске приложения:

    logging.basicConfig(level=logging.DEBUG)
    
  • Настройка модуля warnings на отображение предупреждений ResourceWarning. Один из способов сделать это – использовать параметр командной строки -W default.

Когда режим отладки включён:

  • Многие небезопасные по отношению к потокам API asyncio (например, методы loop.call_soon() и loop.call_at()) вызывают исключение, если их вызывают не из того потока.

  • Время выполнения селектора ввода-вывода журналируется, если выполнение операции ввода-вывода занимает слишком много времени.

  • Колбэки, выполняющиеся дольше 100 миллисекунд, журналируются. Атрибут loop.slow_callback_duration можно использовать для задания минимальной продолжительности выполнения в секундах, которая считается «медленной».

Параллелизм и многопоточностьConcurrency and Multithreading

Цикл событий работает в потоке (обычно в главном) и выполняет все колбэки и задачи в своём потоке. Пока задача выполняется в цикле событий, никакие другие задачи не могут выполняться в том же потоке. Когда задача выполняет выражение await, она приостанавливается, и цикл событий переходит к следующей задаче.

Для планирования колбэка из другого потока ОС следует использовать метод loop.call_soon_threadsafe(). Пример:

loop.call_soon_threadsafe(callback, *args)

Почти все объекты asyncio не являются потокобезопасными, что обычно не является проблемой, если только нет кода, который работает с ними вне задачи или колбэка. Если такому коду нужно вызвать низкоуровневый API asyncio, следует использовать метод loop.call_soon_threadsafe(), например:

loop.call_soon_threadsafe(fut.cancel)

Для планирования объекта корутины из другого потока ОС следует использовать функцию run_coroutine_threadsafe(). Она возвращает concurrent.futures.Future для доступа к результату:

async def coro_func():
     return await asyncio.sleep(1, 42)

# Позже в другом потоке ОС:

future = asyncio.run_coroutine_threadsafe(coro_func(), loop)
# Ожидать результат:
result = future.result()

Для обработки сигналов цикл событий должен выполняться в главном потоке.

Метод loop.run_in_executor() можно использовать с concurrent.futures.ThreadPoolExecutor или InterpreterPoolExecutor для выполнения блокирующего кода в другом потоке ОС, не блокируя поток ОС, в котором работает цикл событий.

В настоящее время нет возможности планировать корутины или колбэки напрямую из другого процесса (например, запущенного с помощью multiprocessing). В разделе Методы цикла событий перечислены API, которые могут читать из каналов и отслеживать файловые дескрипторы, не блокируя цикл событий. Кроме того, API asyncio для подпроцессов позволяют запустить процесс и взаимодействовать с ним из цикла событий. Наконец, упомянутый выше метод loop.run_in_executor() также можно использовать с concurrent.futures.ProcessPoolExecutor для выполнения кода в другом процессе.

Выполнение блокирующего кодаRunning Blocking Code

Блокирующий (связанный с процессором) код не следует вызывать напрямую. Например, если функция выполняет интенсивные вычисления в течение 1 секунды, все параллельные задачи asyncio и операции ввода-вывода будут задержаны на 1 секунду.

Исполнитель можно использовать для выполнения задачи в другом потоке, в том числе в другом интерпретаторе, или даже в другом процессе, чтобы не блокировать поток ОС с циклом событий. Подробнее см. метод loop.run_in_executor().

ЖурналированиеLogging

asyncio использует модуль logging, а все логирование выполняется через логгер "asyncio".

Уровень логирования по умолчанию – logging.INFO. Его можно легко изменить:

logging.getLogger("asyncio").setLevel(logging.WARNING)

Сетевое логирование может блокировать цикл событий. Рекомендуется использовать отдельный поток для обработки логов или неблокирующий ввод-вывод. Пример см. в разделе «Обработчики, которые блокируют выполнение».

Обнаружение никогда не ожидаемых корутинDetect never-awaited coroutines

Когда корутинная функция вызывается, но не ожидается (например, coro() вместо await coro()) или корутина не запланирована с помощью asyncio.create_task(), asyncio выдаёт сообщение RuntimeWarning:

import asyncio

async def test():
    print("never scheduled")

async def main():
    test()

asyncio.run(main())

Вывод:

test.py:7: RuntimeWarning: coroutine 'test' was never awaited
  test()

Вывод в режиме отладки:

test.py:7: RuntimeWarning: coroutine 'test' was never awaited
Coroutine created at (most recent call last)
  File "../t.py", line 9, in <module>
    asyncio.run(main(), debug=True)

  < .. >

  File "../t.py", line 7, in main
    test()
  test()

Обычно это исправляется либо ожиданием корутины, либо вызовом функции asyncio.create_task():

async def main():
    await test()

Обнаружение никогда не извлечённых исключенийDetect never-retrieved exceptions

Если вызван метод Future.set_exception(), но сам объект Future никогда не ожидается, исключение не будет передано пользовательскому коду. В этом случае asyncio выдаёт сообщение в журнал при сборке мусора для этого Future.

Пример необработанного исключения:

import asyncio

async def bug():
    raise Exception("not consumed")

async def main():
    asyncio.create_task(bug())

asyncio.run(main())

Вывод:

Task exception was never retrieved
future: <Task finished coro=<bug() done, defined at test.py:3>
  exception=Exception('not consumed')>

Traceback (most recent call last):
  File "test.py", line 4, in bug
    raise Exception("not consumed")
Exception: not consumed

Включите режим отладки, чтобы получить трассировку стека на момент создания задачи:

asyncio.run(main(), debug=True)

Вывод в режиме отладки:

Task exception was never retrieved
future: <Task finished coro=<bug() done, defined at test.py:3>
    exception=Exception('not consumed') created at asyncio/tasks.py:321>

source_traceback: Object created at (most recent call last):
  File "../t.py", line 9, in <module>
    asyncio.run(main(), debug=True)

< .. >

Traceback (most recent call last):
  File "../t.py", line 4, in bug
    raise Exception("not consumed")
Exception: not consumed

Рекомендации по работе с асинхронными генераторамиAsynchronous generators best practices

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

Явное закрытие асинхронных генераторовClose asynchronous generators explicitly

Рекомендуется вручную закрывать асинхронный генератор. Если генератор завершается досрочно – например, из-за исключения, возникшего в теле цикла async for, – его асинхронный код очистки может выполниться в неожиданном контексте. Это может произойти уже после завершения задач, от которых он зависит, или при остановке цикла событий, когда срабатывает сборщик мусора для async-генератора.

Чтобы этого избежать, явно закрывайте генератор, вызывая его метод aclose(), или используйте контекстный менеджер contextlib.aclosing():

import asyncio
import contextlib

async def gen():
  yield 1
  yield 2

async def func():
  async with contextlib.aclosing(gen()) as g:
    async for x in g:
      break  # Не выполнять итерацию до конца

asyncio.run(func())

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

import asyncio
work_done = False

async def cursor():
    try:
        yield 1
    finally:
        assert work_done

async def rows():
    global work_done
    try:
        yield 2
    finally:
        await asyncio.sleep(0.1) # имитировать некоторую асинхронную работу
        work_done = True


async def main():
    async for c in cursor():
        async for r in rows():
            break
        break

asyncio.run(main())

Для этого примера получаем следующий вывод:

unhandled exception during asyncio.run() shutdown
task: <Task finished name='Task-3' coro=<<async_generator_athrow without __name__>()> exception=AssertionError()>
Traceback (most recent call last):
  File "example.py", line 6, in cursor
    yield 1
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "example.py", line 8, in cursor
    assert work_done
           ^^^^^^^^^
AssertionError

Асинхронный генератор cursor() был финализирован до генератора rows – неожиданное поведение.

Пример можно исправить, явно закрыв async-генераторы cursor и rows:

async def main():
    async with contextlib.aclosing(cursor()) as cursor_gen:
        async for c in cursor_gen:
            async with contextlib.aclosing(rows()) as rows_gen:
                async for r in rows_gen:
                    break
            break

Создавайте асинхронные генераторы только при работающем цикле событийCreate asynchronous generators only when the event loop is running

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

Чтобы гарантировать надёжное закрытие асинхронных генераторов, цикл событий использует функцию sys.set_asyncgen_hooks() для регистрации колбэков. Эти колбэки обновляют список запущенных асинхронных генераторов, поддерживая его в согласованном состоянии.

При вызове функции loop.shutdown_asyncgens() запущенные генераторы корректно останавливаются, а список очищается.

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

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

Рассмотрим следующий пример:

import asyncio

async def agenfn():
    try:
        yield 10
    finally:
        await asyncio.sleep(0)


with asyncio.Runner() as runner:
    agen = agenfn()
    print(runner.run(anext(agen)))
    del agen

Вывод:

10
Exception ignored while closing generator <async_generator object agenfn at 0x000002F71CD10D70>:
Traceback (most recent call last):
  File "example.py", line 13, in <module>
    del agen
        ^^^^
RuntimeError: async generator ignored GeneratorExit

Этот пример можно исправить так:

import asyncio

async def agenfn():
    try:
        yield 10
    finally:
        await asyncio.sleep(0)

async def main():
    agen = agenfn()
    print(await anext(agen))
    del agen

asyncio.run(main())

Избегайте одновременных итерации и закрытия одного и того же генератораAvoid concurrent iteration and closure of the same generator

Асинхронные генераторы могут быть повторно вызваны во время выполнения другого вызова __anext__() / athrow() / aclose(). Это может привести к несогласованному состоянию async-генератора и вызвать ошибки.

Рассмотрим следующий пример:

import asyncio

async def consumer():
    for idx in range(100):
        await asyncio.sleep(0)
        message = yield idx
        print('received', message)

async def amain():
    agenerator = consumer()
    await agenerator.asend(None)

    fa = asyncio.create_task(agenerator.asend('A'))
    fb = asyncio.create_task(agenerator.asend('B'))
    await fa
    await fb

asyncio.run(amain())

Вывод:

received A
Traceback (most recent call last):
  File "test.py", line 38, in <module>
    asyncio.run(amain())
    ~~~~~~~~~~~^^^^^^^^^
  File "Lib/asyncio/runners.py", line 204, in run
    return runner.run(main)
           ~~~~~~~~~~^^^^^^
  File "Lib/asyncio/runners.py", line 127, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "Lib/asyncio/base_events.py", line 719, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~^^
  File "test.py", line 36, in amain
    await fb
RuntimeError: anext(): asynchronous generator is already running

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