Публикуем девятую, заключительную часть (12345678) перевода руководства по модулю asyncio в Python. Здесь вы найдёте разделы исходного материала с 23 по 26.

23. Часто задаваемые вопросы об asyncio

В этом разделе приведены ответы на распространённые вопросы об использовании asyncio в Python.

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

23.1. Как остановить задачу?

Отменить задачу можно, прибегнув к методу cancel() объекта asyncio.Task.

Метод cancel() возвращает True в том случае, если задача была отменена, а в противном случае он возвращает False.

Например:

...
# отмена задачи
was_cancelled = task.cancel()

Если работа задачи уже завершена — отменить её нельзя. В таком случае метод cancel(), вызванный для этой задачи, возвратит False, но в состояние задачи не попадут сведения о том, что она была отменена.

Когда в следующий раз у задачи появится возможность выполниться — она вызовет исключение CancelledError.

Если это исключение не обрабатывается в пределах обёрнутой задачей корутины — задача будет отменена.

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

Исследуем отмену выполняющихся задач.

В этом примере мы определяем корутину, на основе которой будет создана задача. Эта корутина выводит сообщение и затем на некоторое время блокируется.

Затем мы определяем главную корутину, которая будет использоваться как точка входа в asyncio-программу. Она выводит сообщение, создаёт задачу, планирует её выполнение, а после этого ненадолго «засыпает».

После этого главная корутина возобновляет работу и отменяет задачу, которая всё ещё выполняется. Она ждёт ещё некоторое время, которое нужно для того, чтобы у задачи была бы возможность обработать запрос на её отмену. Далее — главная корутина сообщает о том, был ли успешным запрос на отмену задачи.

У задачи появляется возможность выполниться, она вызывает исключение CancelledError, после чего её работа завершается.

В итоге главная корутина выводит сведения о состоянии задачи, после чего работа программы завершается.

Вот код примера:

# SuperFastPython.com
# пример отмены выполняющейся задачи
import asyncio
 
# определение корутины для задачи
async def task_coroutine():
    # вывод сообщения
    print('executing the task')
    # блокировка на некоторое время
    await asyncio.sleep(1)
 
# главная корутина
async def main():
    # вывод сообщения
    print('main coroutine started')
    # создание задачи и планирование её выполнения
    task = asyncio.create_task(task_coroutine())
    # ожидание
    await asyncio.sleep(0.1)
    # отмена задачи
    was_cancelled = task.cancel()
    # вывод сведений о том, был ли успешным запрос на отмену задачи
    print(f'was canceled: {was_cancelled}')
    # ожидание
    await asyncio.sleep(0.1)
    # проверка состояния задачи
    print(f'canceled: {task.cancelled()}')
    # вывод итогового сообщения
    print('main coroutine done')
 
# запуск asyncio-программы
asyncio.run(main())

При выполнении этого кода запускается цикл событий asyncio, который выполняет корутину main().

Эта корутина выводит сообщение, а затем, на основе корутины task_coroutine(), создаёт задачу и планирует её выполнение.

Корутина main() после этого приостанавливается и даёт возможность начать выполняться задаче, основанной на корутине.

Задача запускается, выводит сообщение и на некоторое время «засыпает».

Корутина main() возобновляет работу и отменяет задачу. Она сообщает о том, что запрос на отмену задачи был успешным.

Затем главная корутина снова ненадолго «засыпает», позволяя задаче обработать запрос на её отмену.

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

Корутина main() в очередной раз возобновляет работу и сообщает о том, была ли задача отменена, используя её метод cancelled(). В данном случае он возвращает True, а значит — задача действительно была отменена.

Тут показан пример запланированного завершения работающей задачи.

main coroutine started
executing the task
was canceled: True
canceled: True
main coroutine done

23.2. Как дождаться завершения задачи?

Дождаться завершения задачи можно, воспользовавшись конструкцией await с объектом задачи asyncio.Task:

...
# ожидание завершения задачи
await task

Создать задачу и дождаться её завершения можно, записав всё необходимое в одной строке кода:

...
# создание задачи и ожидание её завершения
await asyncio.create_task(custom_coro())

23.3. Как получить значение, возвращаемое задачей?

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

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

Вот определение корутины, возвращающей значение:

# корутина, которая возвращает значение
async def other_coro():
    return 100

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

Например:

...
# выполнение корутины и получение возвращаемого ею значения
value = await other_coro()

Корутина может быть обёрнута в объект asyncio.Task.

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

Сделать это можно с помощью функции asyncio.create_task():

...
# оборачивание корутины в задачу и планирование её выполнения
task = asyncio.create_task(other_coro())

Подробности о создании задач можно найти здесь.

Существует два способа получения значений, возвращаемых задачами — объектами asyncio.Task:

  1. Подождать завершения выполнения задачи.

  2. Вызвать метод задачи result().

Поговорим об этом.

Завершения работы задачи можно подождать и получить значение, которое она возвращает.

Если выполнение задачи запланировано, или если она уже выполняется — работа вызывающей стороны будет приостановлена до тех пор, пока выполнение задачи не завершится и не будет предоставлено значение, которое она возвращает.

Если работа задачи уже завершена — возвращаемое ей значение будет предоставлено немедленно.

Например:

...
# получение значения, возвращаемого задачей
value = await task

Использовать команду await в применении к задачам можно более одного раза, что, в отличие от попытки работать так с корутинами, не вызовет ошибки.

Например:

...
# получение значения, возвращаемого задачей
value = await task
# получение значения, возвращаемого задачей
value = await task

Получить значение, возвращаемое задачей, можно и прибегнув к её методу result():

...
# получение значения, возвращаемого задачей
value = task.result()

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

Если задача была отменена — будет выдано исключение CancelledError.

Здесь можно найти подробности о получении результатов, возвращаемых задачами.

23.4. Как организовать фоновое выполнение задачи?

Запустить корутину в фоновом режиме можно, обернув её в объект asyncio.Task.

Чтобы это сделать — надо вызвать функцию asyncio.create_task() и передать ей корутину.

Корутина будет обёрнута в объект Task, после чего она будет запланирована на выполнение. Функция create_task() вернёт объект задачи, при этом работа вызывающей стороны не будет приостановлена.

Например:

...
# запланировать задачу на выполнение.
task = asyncio.create_task(other_coroutine())

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

Мы можем поспособствовать запуску задачи, приостановив на некоторое время работу текущей корутины. Это позволит задаче начать выполняться.

Сделать это можно, заставив корутину «заснуть» на 0 секунд:

...
# приостановка работы текущей корутины, благодаря чему у задачи появляется возможность запуститься
await asyncio.sleep(0)

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

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

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

Например:

...
# подождать завершения выполнения задачи
await task

23.5. Как дождаться завершения работы всех фоновых задач?

В программах, основанных на asyncio, можно дожидаться завершения выполнения всех независимых задач.

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

...
# получение множества всех работающих задач
all_tasks = asyncio.all_tasks()

Эта функция вернёт множество, содержащее по одному объекту asyncio.Task на каждую задачу, выполняющуюся на момент вызова функции. Среди этих объектов будет и объект главной корутины.

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

Чтобы не столкнуться с такой проблемой — надо получить объект asyncio.Task для текущей задачи и исключить его из множества.

Для получения текущей задачи нужно вызвать метод asyncio.current_task(), возвращающий текущую корутину, сохранить её, а потом удалить из множества с помощью метода remove():

...
# получение текущей задачи
current_task = asyncio.current_task()
# удаление текущей задачи из коллекции, содержащей все задачи
all_tasks.remove(current_task)

После этого можно дождаться завершения работы всех оставшихся задач.

Это приведёт к приостановке работы вызывающей стороны, которая продолжится до тех пор, пока не будут завершены все задачи, входящие в состав множества:

...
# приостановка работы до тех пор, пока все задачи не будут завершены
await asyncio.wait(all_tasks)

Если собрать вместе всё то, что мы только что обсудили, и поместить получившийся фрагмент кода в конец главной корутины — это позволит, перед завершением её работы, дождаться завершения всех фоновых задач:

...
# получение множества всех работающих задач
all_tasks = asyncio.all_tasks()
# получение текущей задачи
current_task = asyncio.current_task()
# удаление текущей задачи из коллекции, содержащей все задачи
all_tasks.remove(current_task)
# приостановка работы до тех пор, пока все задачи не будут завершены
await asyncio.wait(all_tasks)

23.6. Мешает ли выполняющаяся задача завершению работы цикла событий?

Если кратко ответить на вопрос, вынесенный в заголовок этого раздела, то — нет, не мешает.

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

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

В предыдущем разделе мы говорили именно об этом.

23.7. Как вывести сведения о завершении выполнения работающих задач?

Вывести сведения о завершении выполнения задачи можно, воспользовавшись коллбэком, который добавляют к ней с помощью метода add_done_callback().

Этот коллбэк представляет собой функцию, привязанную к объекту asyncio.Task. Данная функция вызывается тогда, когда задача завершена — либо успешно, либо — нет.

Это — обычная функция, а не корутина. Она принимает в качестве аргумента связанный с ней объект asyncio.Task.

Один и тот же коллбэк можно использовать для нескольких задач и единообразно выводить сведения об их завершении. Например — показывая пользователю некое сообщение.

Например:

# функция-коллбэк, сообщающая о завершении задач
def progress(task):
    # вывод сведений о завершении работы задачи
    print('.', end='')

Функцию можно привязать к каждому создаваемому объекту asyncio.Task. Для этого вызывают метод add_done_callback() каждой из задач и передают ему коллбэк:

...
# привязка коллбэка к задаче
task.add_done_callback(progress)

23.8. Как запустить задачу с задержкой?

Для того чтобы запустить целевую корутину с заданной задержкой — можно создать особую корутину-обёртку.

Подобная корутина может принимать два аргумента — корутину, которую нужно запустить, и время в секундах.

Она «заснёт» на заданное время, а после этого дождётся завершения выполнения переданной ей корутины.

Представленный ниже код корутины delay() демонстрирует реализацию этой идеи.

# корутина, запускающая другую корутину после того, как пройдёт заданное количество секунд
async def delay(coro, seconds):
    # приостановка на заданное время
    await asyncio.sleep(seconds)
    # выполнение другой корутины
    await coro

Для того чтобы воспользоваться этой корутиной-обёрткой, можно создать её объект, и либо непосредственно дождаться завершения её выполнения, либо запустить её независимо, в виде задачи.

Например, вызывающая сторона может приостановить работу, запланировать выполнение корутины, которую нужно запустить с задержкой, и дождаться завершения её выполнения:

...
# выполнить корутину после того, как пройдёт заданное время
await delay(coro, 10)

Ещё можно запланировать выполнение корутины-обёртки независимо, в виде задачи:

...
# независимо выполнить корутину, которая запускает другую корутину с задержкой
_ = asyncio.create_task(delay(coro, 10))

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

Существует три основных способа запуска одних задач после завершения работы других задач:

  1. Можно запланировать следующую задачу из самой завершающейся задачи.

  2. Можно запланировать следующую задачу средствами вызывающей стороны.

  3. Можно запланировать следующую задачу автоматически, воспользовавшись коллбэком, вызываемым при завершении работы задачи.

Разберём каждый из этих подходов к запуску задач.

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

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

Затем выполнение задачи можно запланировать с помощью вызова asyncio.create_task().

Например:

...
# запланировать следующую задачу
task = asyncio.create_task(followup_task())

Сама задача может дождаться завершения выполнения следующей задачи, или может позволить ей завершиться независимо, в фоновом режиме.

Например:

...
# подождать завершения следующей задачи
await task

Вызывающая сторона, создавшая задачу, может принять решение о том, что нужно создать и следующую задачу.

Например, когда вызывающая сторона создаёт первую задачу — она может сохранить её объект asyncio.Task.

Затем она может проверить состояние задачи, узнать о том, была ли она завершена успешно.

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

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

Например:

...
# создание первой задачи и ожидание её завершения
task = await asyncio.create_task(task())
# проверка результатов работы первой задачи
if task.result():
    # создание следующей задачи
    followup = await asyncio.create_task(followup_task())

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

Код, в котором создана задача, может подключить к ней этот коллбэк.

Этот коллбэк должен, в качестве аргумента, принимать объект asyncio.Task. Он будет вызван только после того, как работа задачи завершится. После этого у него будет возможность принять решение о том, нужно или нет создавать следующую задачу.

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

Например, коллбэк может выглядеть так:

# функция-коллбэк
def callback(task):
    # планирование следующей задачи
    _ = asyncio.create_task(followup())

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

...
# планирование задачи
task = asyncio.create_task(work())
# добавление к задаче коллбэка, вызываемого после завершения её работы
task.add_done_callback(callback)

23.10. Как выполнять в asyncio-программах блокирующие операции ввода/вывода или интенсивные вычисления, зависящие от производительности процессора?

Модуль asyncio предусматривает два подхода к выполнению блокирующих вызовов.

Первый подход заключается в применении метода asyncio.to_thread().

Это — высокоуровневый API, который предназначен для разработчиков приложений.

Метод asyncio.to_thread() принимает имя функции, которую нужно выполнить, и набор аргументов, необходимых этой функции.

Функция выполняется в отдельном потоке. Метод to_thread() возвращает корутину. Её выполнения можно дождаться, но можно и запланировать её выполнение в виде независимой задачи.

Например:

...
# выполнение функции в отдельном потоке
await asyncio.to_thread(task)

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

Метод asyncio.to_thread() создаёт для своих целей объект ThreadPoolExecutor, используемый для выполнения блокирующих вызовов.

В результате получается, что применение asyncio.to_thread() подходит лишь для выполнения задач, блокирующих подсистему ввода/вывода.

Альтернативный подход заключается в применении метода loop.run_in_executor().

Он представляет собой низкоуровневый API модуля asyncio, перед применением которого нужно получить доступ к циклу событий. Например — посредством метода asyncio.get_running_loop().

Метод loop.run_in_executor() принимает исполнитель и функцию, которую нужно выполнить.

Если в качестве исполнителя ему передано значение None — будет использоваться исполнитель, применяемый по умолчанию — ThreadPoolExecutor.

Метод loop.run_in_executor() возвращает объект, допускающий ожидание, завершения работы которого, при необходимости, можно дождаться. Задача начнёт выполняться немедленно, поэтому для того чтобы блокирующий вызов начал бы выполняться, не нужно ожидать завершения работы возвращённого объекта, допускающего ожидание, или планировать его выполнение.

Например:

...
# получение цикла событий
loop = asyncio.get_running_loop()
# выполнение функции в отдельном потоке
await loop.run_in_executor(None, task)

Исполнитель, кроме того, можно создать самостоятельно и передать методу loop.run_in_executor(). Этот метод будет выполнять асинхронные вызовы в предоставленном ему исполнителе.

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

Например:

...
# создание пула процессов
with ProcessPoolExecutor as exe:
    # получение цикла событий
    loop = asyncio.get_running_loop()
    # выполнение функции в отдельном потоке
    await loop.run_in_executor(exe, task)
    # пул процессов уничтожается автоматически...

Два вышеописанных подхода позволяют выполнять в asyncio-программах блокирующие вызовы в виде асинхронных задач.

24. Распространённые возражения против использования asyncio

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

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

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

24.1. Как насчёт глобальной блокировки интерпретатора?

Глобальная блокировка интерпретатора (Global Interpreter Lock, GIL) защищает внутренние механизмы интерпретатора Python он конкурентного доступа к ним и от их модификации, источником которой являются различные потоки.

Цикл событий asyncio работает в одном потоке.

Это значит, что все корутины выполняются в одном потоке.

В результате GIL не создаёт проблем при использовании asyncio и корутин.

24.2. Являются ли корутины в Python чем-то «настоящим»?

Корутины — это сущности, управление которыми осуществляется на программном уровне.

Корутины запускают и управляют ими (переключаются между ними) в цикле событий, который работает в среде выполнения Python.

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

В этом смысле в Python нет поддержки «нативных корутин». Но я не уверен в том, что нечто подобное существует в современных операционных системах.

24.3. Правда ли то, что система конкурентного выполнения в Python полна ошибок?

Нет, это неправда.

В Python имеются отличные механизмы конкурентного выполнения кода, основанные на корутинах, потоках и процессах.

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

24.4. Выбирать Python для написания конкурентного кода — это неудачная идея?

Python нравится разработчикам по многим причинам. Чаще всего из-за того, что им легко пользоваться, и из-за того, что он позволяет быстро создавать приложения.

Python часто использовался для написания кода, связывающего различные системы, для создания одноразовых скриптов, но это язык находит всё более широкое применение в построении крупномасштабных программных систем.

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

А если же вам нужно конкурентное выполнение кода и вы ещё не выбрали язык, на котором будете писать свой проект, то, возможно, вам лучше подойдёт какой-то другой язык, а не Python, а возможно — нет. Для того чтобы подобрать наилучший язык для некоего проекта — надо учесть весь спектр требований к нему — функциональных и нефункциональных (или — потребности, стремления и желания пользователей). Затем нужно рассмотреть возможности различных платформ для разработки приложений в свете этих требований.

24.5. Почему бы вместо возможностей asyncio не использовать потоки?

Вместо asyncio можно использовать потоки.

Любую программу, разработанную с применением потоков, можно переписать с использованием asyncio и корутин. И, аналогично, любую asyncio-программу можно переписать с применением потоков.

Применение asyncio в некоем проекте — это выбор программиста, который сам обосновывает этот выбор.

Потоки и возможности asyncio, по большей части, эквивалентны.

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

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

25. Дополнительные материалы

В этом разделе вы найдёте дополнительные материалы по asyncio.

25.1. Книги об asyncio

Вот мои книги и другие материалы по asyncio:

Вот — книги других авторов:

25.2. Материалы по API

25.3. Полезные ссылки

26. Итоги

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

Понравился ли вам материал? Использовали ли вы asyncio в своих проектах? Есть ли у вас вопросы по этому модулю? Автор руководства предлагает всем, кого оно оставило неравнодушным, всем, кому есть что сказать об asyncio, выразить своё мнение или оставить свой вопрос в комментариях. Вы, кроме того, можете присоединиться к обсуждению этого материала на Reddit и на Hacker News.

О, а приходите к нам работать? ???? ????

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде.

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


  1. yehitas
    00.00.0000 00:00

    спасибо вам за то, что раскрываете такую сложную тему, как многопоточность в пайтон. Я не такой профи, как вы, но недавно всё таки осилил многопоточность в сложной программе на PySide2.


  1. Nariche
    00.00.0000 00:00

    Пока я учусь на бек питон, очень интересно узнать как работает асинхронка) Спасибо за статьи