You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

40 KiB

Конкурентность и async / await

Подробности о синтаксисе async def для функций обработки пути и некоторые сведения об асинхронном коде, конкурентности и параллелизме.

В спешке?

TL;DR:

Если вы используете сторонние библиотеки, которые требуют вызова с ключевым словом await, как:

results = await some_library()

Тогда объявляйте свои функции обработки пути с использованием async def, как:

@app.get('/')
async def read_results():
    results = await some_library()
    return results

/// note | Примечание

Вы можете использовать await только внутри функций, созданных с помощью async def.

///


Если вы используете стороннюю библиотеку, которая взаимодействует с чем-то (база данных, API, файловая система и т. д.) и не поддерживает использование await, (это в настоящее время относится к большинству библиотек для работы с базами данных), то объявляйте свои функции обработки пути как обычно, просто с def, как:

@app.get('/')
def results():
    results = some_library()
    return results

Если вашему приложению (каким-то образом) не нужно взаимодействовать ни с чем и ждать ответа, используйте async def, даже если вам не нужно использовать await внутри.


Если вы не знаете, используйте обычный def.


Примечание: Вы можете смешивать def и async def в своих функциях обработки пути столько, сколько вам нужно, и определять каждую с использованием наилучшего для вас варианта. FastAPI сделает с ними всё как надо.

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

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

Технические подробности

Современные версии Python поддерживают "асинхронный код" с использованием чего-то, называемого "сопрограммами", с синтаксисом async и await.

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

  • Асинхронный код
  • async и await
  • Сопрограммы

Асинхронный код

Асинхронный код просто значит, что язык 💬 имеет способ сообщить компьютеру / программе 🤖, что в какой-то момент в коде, ему 🤖 нужно будет дождаться, пока что-то ещё завершится где-то ещё. Пусть это что-то ещё называется "медленный файл" 📝.

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

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

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

Этот "ожидать чего-то ещё" обычно относится к операциям I/O, которые относительно "медленны" (по сравнению со скоростью процессора и оперативной памяти), например:

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

Поскольку основное время выполнения тратится на ожидание операций I/O, их называют операциями, "связанными с вводом-выводом" (I/O bound).

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

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

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

Конкурентность и бургеры

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

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

Но детали между конкурентностью и параллелизмом достаточно разные.

Чтобы увидеть разницу, представьте следующую историю о бургерах:

Конкурентные бургеры

Вы идёте с вашей возлюбленной в фастфуд, становитесь в очередь, пока кассир принимает заказы у людей перед вами. 😍

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

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

Вы платите. 💸

Кассир даёт вам номер вашего заказа.

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

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

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

Затем в какой-то момент это наконец ваша очередь. Вы идёте к стойке, забираете свои бургеры и возвращаетесь к столу.

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

/// info | Информация

Прекрасные иллюстрации от Кетрины Томпсон. 🎨

///


Представьте, что в этой истории вы — компьютер / программа 🤖.

В очереди вы бездействуете 😴, ожидая своей очереди, особо ничего "продуктивного" не делая. Но очередь быстро движется, так как кассир только берёт заказы (а не готовит их), так что это нормально.

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

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

Но уходя от стойки и садясь за столик с номером вашего заказа, вы можете переключить 🔀 своё внимание на свою возлюбленную, и "работать" ⏯ 🤓 над этим. Таким образом, вы снова делаете что-то очень "продуктивное", как флирт с вашей возлюбленной 😍.

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

Поэтому вы ждёте, пока ваша возлюбленная закончит рассказывать историю (закончите текущую работу ⏯ / задачу в обработке 🤓), мило улыбаетесь и говорите, что идёте забирать бургеры ⏸.

Затем вы идёте к стойке 🔀, к первоначальной задаче, которая завершена ⏯, берете бургеры, говорите спасибо и возвращаетесь за столик. Это завершает эту часть / задание взаимодействия с кассиром ⏹. Это, в свою очередь, создаёт новую задачу — "есть бургеры" 🔀 ⏯, но предыдущая — "получить бургеры" завершена ⏹.

Параллельные бургеры

Теперь представим, что это не "Конкурентные бургеры", а "Параллельные бургеры".

Вы идёте с вашей возлюбленной попробовать параллельный фастфуд.

Вы стоите в очереди, пока несколько (скажем, 8) кассиров, которые одновременно являются поварами, принимают заказы у людей перед вами.

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

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

Вы оплачиваете 💸.

Кассир идёт на кухню.

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

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

Это "синхронная" работа: вы "синхронизированы" с кассиром/поваром 👨‍🍳. Вам приходится ждать 🕙 и быть там в точный момент, когда кассир/повар 👨‍🍳 завершает приготовление бургеров и отдаёт их вам, иначе кто-то может их взять.

Затем ваш кассир/повар 👨‍🍳 наконец возвращается с вашими бургерами, после долгого ожидания 🕙 перед стойкой.

Вы берёте ваши бургеры и идёте за столик с вашей возлюбленной.

Вы их просто кушаете, и всё. ⏹

Обменяться словами или пофлиртовать не очень получилось, так как больше времени было потрачено на ожидание 🕙 перед стойкой. 😞

/// info | Информация

Прекрасные иллюстрации от Кетрины Томпсон. 🎨

///


В этом сценарии с параллельными бургерами вы — это компьютер / программа 🤖 с двумя процессорами (вы и ваша возлюбленная), оба долгое время ожидающие 🕙 и посвящающие своё внимание ⏯ быть "ожидающими у кассы" 🕙.

В этом фастфуд-магазине 8 процессоров (кассиров/поваров). Хотя в магазине конкурентных бургеров могло быть только 2 (один кассир и один повар).

Но всё же, окончательный опыт не самый лучший. 😞


Это была бы параллельная аналогичная история для бургеров. 🍔

Для более "реального" примера этого, представьте банк.

До недавнего времени в большинстве банков было несколько кассиров 👨‍💼👨‍💼👨‍💼👨‍💼 и длинная очередь 🕙🕙🕙🕙🕙🕙🕙🕙.

Все кассиры занимались каждым клиентом один за другим 👨‍💼⏯.

И вам долгое время приходилось ждать 🕙 в очереди, иначе потеряете свою очередь.

Скорее всего, вам бы не захотелось брать вашу возлюбленную 😍 с собой для быстрого похода в банк 🏦.

Заключение о бургерах

В этом сценарии "быстрого питания с бургерами с вашей возлюбленной", так как там много ожидания 🕙, имеет много больше смысла иметь конкурентную систему ⏸🔀⏯.

И это и есть случай для большинства веб-приложений.

Много, много пользователей, но ваш сервер ожидает 🕙, чтобы они отправили свои запросы через своё не очень хорошее соединение.

И потом снова ждёт 🕙, чтобы ответы вернулись.

Это "ожидание" 🕙 измеряется в микросекундах, но в итоге, если всё сложить, это много времени ожидания.

Вот почему имеет смысл использовать асинхронный код ⏸🔀⏯ для веб-API.

Эта форма асинхронности является тем, что сделало NodeJS популярным (хотя NodeJS не параллелен) и это то, что делает Go мощным языком программирования.

И это тот же уровень производительности, который вы получаете с FastAPI.

И так как у вас может быть параллелизм и асинхронность одновременно, вы получаете более высокую производительность, чем большинство протестированных NodeJS фреймворков, и сопоставимо с Go, который является компилируемым языком, ближе к C (всё это благодаря Starlette).

Конкуренция лучше параллелизма?

Нет! Это не мораль истории.

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

Так что, чтобы уравновесить это, представьте себе следующий короткий рассказ:

Вам нужно убрать большой, грязный дом.

Да, это вся история.


Тут нет ожидания 🕙 нигде, просто много работы по разным местам дома.

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

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

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

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

И поскольку основная часть времени выполнения уходит на реальную работу (вместо ожидания), и работа в компьютере выполняется ЦП, такие задачи называют "ограниченными производительностью процессора" (CPU bound).


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

Например:

  • Обработка звука или изображений.
  • Компьютерное зрение: изображение состоит из миллионов пикселей, каждый пиксель имеет 3 значения / цвета, обработка этого обычно требует вычисления чего-то на этих пикселях, все одновременно.
  • Машинное обучение: оно обычно требует множества умножений "матриц" и "векторов". Представьте огромную таблицу с числами в Экселе и умножение всех их вместе одновременно.
  • Глубокое обучение: это подполе Машинного обучения, так что, то же самое применимо. Просто здесь не одна таблица чисел для умножения, а огромное их множество, и в многих случаях используется специальный процессор для создания и / или использования этих моделей.

Конкурентность + параллелизм: Веб + Машинное обучение

С FastAPI вы можете использовать преимуществ конкурентности, который очень распространён в веб-разработке (та же главная привлекательность NodeJS).

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

Это, плюс простой факт, что Python является главным языком для Дата-сайенс, Машинного обучения и особенно глубокого обучения, делают FastAPI очень хорошим выбором для Дата-сайенс / Машинного обучения веб-API и приложений (среди многих других).

Чтобы увидеть, как достичь этого параллелизма в эксплуатации, смотрите раздел о Развёртывании{.internal-link target=_blank}.

async и await

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

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

burgers = await get_burgers(2)

Ключ здесь — await. Он сообщает Python, что нужно дождаться ⏸, пока get_burgers(2) завершит своё выполнение 🕙, прежде чем сохранить результаты в burgers. Основываясь на этом, Python сможет временно перейти к выполнению других задач 🔀 ⏯ (например, получение нового запроса).

Чтобы await работал, он должен находиться внутри функции, поддерживающей асинхронность. Для этого просто объявите её с помощью async def:

async def get_burgers(number: int):
    # Выполняем некоторые асинхронные действия, чтобы создать бургеры
    return burgers

...вместо def:

# Это не асинхронный код
def get_sequential_burgers(number: int):
    # Выполняем последовательные действия, чтобы создать бургеры
    return burgers

С async def, Python знает, что внутри этой функции ему нужно ожидать выражений await, и что выполнение такой функции можно будет "поставить на паузу" ⏸ и перейти к чему-то другому 🔀, прежде чем вернуться.

Когда вы хотите вызвать функцию async def, вы должны "ожидать" её. Поэтому, это не сработает:

# Это не сработает, потому что get_burgers была определена с использованием: async def
burgers = get_burgers(2)

Так, если вы используете библиотеку, которая сообщает вам, что можете вызвать её с помощью await, вам нужно создать функции обработки пути, которые её используют с async def, как в:

@app.get('/burgers')
async def read_burgers():
    burgers = await get_burgers(2)
    return burgers

Более технические подробности

Как вы могли заметить, await можно использовать только внутри функций, определённых с помощью async def.

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

Так что как же насчёт яйца и курицы, как вызвать первую async функцию?

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

Но если вы хотите использовать async / await без FastAPI, вы тоже можете это сделать.

Пишите свой асинхронный код

Starlette (и FastAPI) базируются на AnyIO, что делает их совместимыми как со стандартной библиотекой Python asyncio, так и с Trio.

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

И даже если вы не использовали FastAPI, вы всё равно могли бы написать свои собственные асинхронные приложения с помощью AnyIO, чтобы они были высоко совместимыми и получали все его преимущества (например, структурированная конкурентность).

Я создал другую библиотеку поверх AnyIO, как тонкий слой поверх него, чтобы немного улучшить аннотации типов и получить лучшую автодополнения, ошибки в строках кода и т. д. Она также имеет дружный вводный материал и учебник, который поможет вам понять и писать свой собственный асинхронный код: Asyncer. Он будет особенно полезен, если вам нужно сочетать асинхронный код с обычным (блокирующим/синхронным) кодом.

Другие формы асинхронного кода

Этот стиль использования async и await относительно новый в языке.

Но он сильно упрощает работу с асинхронным кодом.

Этот же синтаксис (или почти идентичный) также был недавно включен в современные версии JavaScript (в браузерах и NodeJS).

Но раньше обработка асинхронного кода была намного более сложной и трудной.

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

В предыдущих версиях NodeJS / Javascript для браузеров, для этого использовались "обратные вызовы". Что приводило к аду обратных вызовов.

Сопрограммы

Сопрограмма — это просто очень крутое слово для обозначения того, что возвращается функцией async def. Python знает, что это что-то вроде функции, которую можно запустить, и которая закончится в какой-то момент, но которая может быть также поставлена на паузу ⏸, когда внутри встречается await.

Но вся эта функциональность использования асинхронного кода с async и await часто суммируется как использование "сопрограмм". Это сопоставимо с основной ключевой особенностью Go, "Горутинами".

Заключение

Давайте увидим ту же фразу сверху:

Современные версии Python поддерживают "асинхронный код" с использованием чего-то, называемого "сопрограммами", с синтаксисом async и await.

Теперь это должно звучать более понятно.

Всё это то, на чём работает FastAPI (через Starlette) и что делает его настолько невероятно производительным.

Очень технические подробности

/// warning | Предупреждение

Вы, вероятно, можете это пропустить.

Это очень технические подробности того, как FastAPI работает внутри.

Если у вас есть технические знания (сопрограммы, потоки, блокировки и т. д.) и вы интересуетесь тем, как FastAPI обрабатывает async def в отличие от обычного def, продолжайте.

///

Функции обработки пути

Когда вы объявляете функцию обработки пути обычным образом с ключевым словом def вместо async def, она выполняется во внешнем пуле потоков, который затем ожидается, вместо того, чтобы быть вызванной напрямую (так как это заблокировало бы сервер).

Если вы приходите из другого асинхронного фреймворка, который работает не так, как описано выше, и вы привыкли определять тривиальные вычислительные функции обработки пути с простым def для маленького прироста производительности (около 100 наносекунд), обратите внимание, что в FastAPI эффект будет полностью противоположным. В этих случаях лучше использовать async def, если только ваши функции обработки пути не используют код, который выполняет блокирующий I/O.

Тем не менее, в обеих ситуациях есть вероятность, что FastAPI всё еще будет быстрее{.internal-link target=_blank}, чем (или, по крайней мере, сравним с) ваш предыдущий фреймворк.

Зависимости

Тоже самое относится к зависимостям{.internal-link target=_blank}. Если зависимость является стандартной функцией def вместо async def, она запускается во внешнем пуле потоков.

Подзависимости

Вы можете иметь множество зависимостей и подзависимостей{.internal-link target=_blank}, требующих друг друга (как параметры определения функций), некоторые из них могут быть созданы с помощью async def, а некоторые с обычным def. Это всё равно будет работать, и те, которые созданы с обычным def, будут вызываться на внешнем потоке (из пула), а не "ожидаться".

Другие служебные функции

Любая другая служебная функция, которую вы вызываете напрямую, может быть создана с обычным def или async def, и FastAPI не будет влиять на то, как вы её вызываете.

Это в отличие от функций, которые FastAPI вызывает для вас: функции обработки пути и зависимости.

Если ваша служебная функция — это обычная функция с def, она будет вызвана напрямую (как вы пишите это в коде), не в пуле потоков, если функция создана с async def, тогда вам нужно await для этой функции, когда вы вызываете её в вашем коде.


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

В противном случае вы должны обратиться к руководящим принципам из раздела выше: В спешке?.