Асинхронность python на примере

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

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


import time

def waiter():
cook('Паста', 8)
cook('Салат Цезарь', 3)
cook('Отбивные', 16)

def cook(order, time_to_prepare):
print(f'Новый заказ: {order}')
time.sleep(time_to_prepare)
print(order, '- готово')

if __name__ == '__main__':
waiter()

Сохраним файл sync.py.

Здесь повар симулируется в виде функции. Он принимает заказ и время на его приготовление. Затем с помощью функции time.sleep симулируется сам процесс готовки. А по завершении выводится сообщение о том, что заказ готов.

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

Убедитесь, что установлена версия Python 3.7+ с помощью команды python3 --version на Mac или python --version — на Windows. Если версия меньше 3.7, обновите.

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

Новый заказ: Паста
Паста - готово
Новый заказ: Салат Цезарь
Салат Цезарь - готово
Новый заказ: Отбивные
Отбивные - готово

Первая асинхронная программа

Теперь конвертируем программу так, чтобы она использовала библиотеку asyncio. Это будет первый шаг для того, чтобы разобраться с тем, как писать асинхронный код. Скопируем файл sync.py в новый файл coros.py со следующим кодом:


import asyncio
import time

async def waiter() -> None:
cook('Паста', 8)
cook('Салат Цезарь', 3)
cook('Отбивные', 16)

async def cook(order: str, time_to_prepare: int) -> None:
print(f'Новый заказ: {order}')
time.sleep(time_to_prepare)
print(order, '- готово')

asyncio.run(waiter())

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

В конце программы заменим if __name__ == '__main__' на новый метод run из модуля asyncio. Что именно делает run?

По сути, run берет низкоуровневый псевдо-сервер asyncio, который называется рабочим циклом. Этот цикл является координатором, который следит за приостановкой и возобновлением задач из кода. В примере с поваром и официантом вызов «cook(»Паста’)» — это задача, которая выполнится, но также будет приостановлена на 8 секунд. Таким образом после получения запроса он отмечается, а программа переходит к выполнению следующего. После завершения заказа на приготовление пасты цикл продолжит выполнение на следующей строке, где готовится салат «Цезарь».

Команде run нужна функция, которую она будет выполнять, поэтому передаем waiter, которая является основной функцией в этом коде.

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

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


async def waiter() -> None:
await cook('Паста', 8)
await cook('Салат Цезарь', 3)
await cook('Отбивные', 16)

Функция waiter объявляется асинхронной за счет добавления приставки async в начале. После этого появляется возможность сообщать asyncio, какие из задач будут асинхронными внутри. Для этого к ним добавляется ключевое слово await.

Такой код можно читать следующим образом: «вызвать функцию cook и дождаться (await) ее результата, прежде чем переходить к следующей строке». Но это не процесс с блокировкой потока. Наоборот, он сообщает циклу следующее: «если есть другие запросы, можешь переходить к их выполнению, пока мы ждем, а мы дадим знать, когда текущий запрос завершится».

Достаточно лишь запомнить, что если есть задачи с await, то сама функция должна быть объявлена с async.

А как же функция cook? Ее тоже нужно сделать асинхронной, поэтому перепишем ее вот так.


async def cook(order, time_to_prepare):
print(f'Новый заказ: {order}')
await time.sleep(time_to_prepare)
print(order, '- готово')

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


async def cook(order, time_to_prepare):
print(f'Новый заказ: {order}')
await asyncio.sleep(time_to_prepare)
print(order, '- готово')

Это гарантирует, что пока функция cook находится в состоянии сна до завершения таймера, программа сможет начать выполнение других запросов.

Если сейчас запустить программу, то результат будет таким:

Новый заказ: Паста
Паста - готово
Новый заказ: Салат Цезарь
Салат Цезарь - готово
Новый заказ: Отбивные
Отбивные - готово

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

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

Сопрограммы и задачи (coroutines and tasks)

Сопрограммы (coroutines)

Функции waiter и cook трансформируются именно в тот момент, когда перед их определением ставится ключевое слово async. С этого момент их можно считать сопрограммами.

Если попытаться запустить одну из таких прямо, то вернется сообщение с информацией о ней, но сама программа не будет запущена. Попробуем запустить терминал Python и импортировать туда функцию cook из файла coros. Во-первых, нужно закомментировать команду asyncio.run так, чтобы код не выполнялся. После этого файл можно сохранить.

# asyncio.run(waiter())

Затем откроем терминал и сделаем следующее:


>>> from coros import cook
>>> cook('Паста', 8)

Сопрограммы могут выполняться только в пределах рабочего цикла или их ожидания (awaiting) внутри других сопрограмм.

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

Задачи (tasks)

С помощью задач можно запустить несколько сопрограмм одновременно. Скопируем файл coros.py в файл tasks.py и допишем следующее:


import asyncio

async def waiter():
task1 = asyncio.create_task(cook('Паста', 8))
task2 = asyncio.create_task(cook('Салат Цезарь', 3))
task3 = asyncio.create_task(cook('Отбивные', 16))

await task1
await task2
await task3

async def cook(order, time_to_prepare):
print(f'Новый заказ: {order}')
await asyncio.sleep(time_to_prepare)
print(order, '- готово')

asyncio.run(waiter())

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


  1. Они используются для планирования последовательного выполнения сопрограмм;
  2. Задачи могут быть отменены при ожидании завершения их выполнения.

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

Новый заказ: Паста
Новый заказ: Салат Цезарь
Новый заказ: Отбивные
Салат Цезарь - готово
Паста - готово
Отбивные - готово

Это уже больше похоже на то, чего многие ждали: официант забирает заказы в том порядке, в котором их возвращает повар.

Частые ошибки с asyncio

При переходе к асинхронному коду нужно помнить о некоторых вещах:

Вызов блокирующей функции из сопрограммы

Одна из самых распространенных проблем — использование синхронной функции внутри асинхронной.

Один из примеров такого — использование синхронной функции time.sleep внутри асинхронной функции cook. Использование обычного метода sleep из стандартной библиотеки заблокировало бы весь код.

Попробуйте. Добавьте import time в верхней части файла coros.py и синхронную функцию sleep:


async def cook(order, time_to_prepare):
print(f'Новый заказ: {order}')
await time.sleep(time_to_prepare)
print(order, '- готово')

При попытке выполнить такой код вернется следующая ошибка:

...
  File "coros.py", line 7, in waiter
    await cook('Pasta', 8)
  File "coros.py", line 13, in cook
    await time.sleep(time_to_prepare)
TypeError: object NoneType can't be used in 'await' expression

На первый взгляд эта ошибка кажется странной. Дело в том, что time.sleep() — это не объект, выполнения которого можно дождаться (await). Таким образом он возвращает вызвавшей его функции None. И даже исключение появляется не сразу, а только через 8 секунд.

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

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

Поэтому важно запомнить, что внутри сопрограммы нужно вызывать только другие сопрограммы.

Не дожидаться завершения выполнения сопрограммы

Во-первых, уберем time.sleep и вернем на место asyncio.sleep. После этого поменяем второй вызов функции cook, забрав у нее ключевое слово await:


import asyncio
import time

async def waiter() -> None:
await cook('Паста', 8)
cook('Салат Цезарь', 3)
await cook('Отбивные', 16)

async def cook(order, time_to_prepare):
print(f'Новый заказ: {order}')
await asyncio.sleep(time_to_prepare)
print(order, '- готово')

asyncio.run(waiter())

При попытке запустить этот код вернется следующая ошибка:

coros.py:5: RuntimeWarning: coroutine 'cook' was never awaited

Она появляется в тех случаях, когда функция была вызвана без await. Иногда это не так очевидно, и придется покопаться, чтобы понять, где именно это произошло.

Неполученные результаты

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

Например, возьмем следующий код:


import asyncio

async def executed():
asyncio.sleep(15)
print("Функция executed")

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

asyncio.run(main())

После его запуска вернется следующая ошибка:


RuntimeWarning: coroutine 'sleep' was never awaited
  asyncio.sleep(15)
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
Функция executed

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

Теперь вы знаете, на что обращать внимание при получении ошибки not consumed.

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

Максим
Я создал этот блог в 2018 году, чтобы распространять полезные учебные материалы, документации и уроки на русском. На сайте опубликовано множество статей по основам python и библиотекам, уроков для начинающих и примеров написания программ.
Мои контакты: Почта
admin@pythonru.comAlex Zabrodin2018-10-26OnlinePython, Programming, HTML, CSS, JavaScript