Конкурентность и async / await
Подробная информация о синтаксисе async def для функций операций пути, а также некоторые сведения об асинхронном коде, конкурентности и параллелизме.
Нет времени?
Кратко:
Если вы используете сторонние библиотеки, которые предлагают вызывать их с помощью await, например:
results = await some_library()
Тогда объявляйте свои функции операций пути с помощью async def, например:
@app.get('/')
async def read_results():
results = await some_library()
return results
Примечание
Использовать 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- Корутины
Асинхронный код
Асинхронный код означает, что в языке 💬 есть способ сообщить компьютеру / программе 🤖, что в какой-то момент выполнения кода придётся подождать, пока где-то ещё завершится что-то другое. Допустим, это что-то другое называется «медленный-файл» 📝.
В это время компьютер может заняться какой-нибудь другой работой, пока «медленный-файл» 📝 завершается.
Затем компьютер / программа 🤖 будет возвращаться к этой работе всякий раз, когда появится возможность, потому что он снова чего-то ждёт, либо когда завершит всю работу, которую выполнял в тот момент. Он 🤖 проверит, завершились ли уже какие-либо задачи, которых он ждал, и выполнит необходимые действия.
Затем он 🤖 возьмёт первую завершившуюся задачу (скажем, наш «медленный-файл» 📝) и продолжит работу, которую нужно было с ней выполнить.
Ожидание «чего-то другого» обычно связано с операциями ввода-вывода, которые относительно «медленные» (по сравнению со скоростью процессора и оперативной памяти), например ожидание:
- передачи данных от клиента по сети
- получения клиентом по сети данных, отправленных вашей программой
- чтения системой содержимого файла на диске и передачи его вашей программе
- записи на диск содержимого, переданного вашей программой системе
- выполнения удалённой операции API
- завершения операции с базой данных
- возвращения результатов запроса к базе данных
- и т. д.
Поскольку большая часть времени выполнения уходит на ожидание операций ввода-вывода, их называют операциями с ограничением по вводу-выводу («I/O bound»).
Это называется «асинхронностью», потому что компьютеру / программе не нужно быть «синхронизированным» с медленной задачей и бездействовать, дожидаясь точного момента её завершения, чтобы получить результат и продолжить работу.
Вместо этого в «асинхронной» системе после завершения задача может немного подождать в очереди (несколько микросекунд), пока компьютер / программа не закончит работу, которой занялись, а затем вернуться, чтобы передать результаты и продолжить работу с ними.
Для «синхронного» выполнения (в отличие от «асинхронного») также часто используют термин «последовательное», поскольку компьютер / программа выполняет все шаги по порядку, прежде чем переключиться на другую задачу, даже если эти шаги связаны с ожиданием.
Конкурентность и бургеры
Описанную выше идею асинхронного кода также иногда называют «конкурентностью». Она отличается от «параллелизма».
И конкурентность, и параллелизм связаны с тем, что «разные вещи происходят примерно одновременно».
Но между конкурентностью и параллелизмом есть существенные различия.
Чтобы понять разницу, представьте следующую историю о бургерах:
Конкурентные бургеры
Вы идёте со своей симпатией в ресторан быстрого питания и стоите в очереди, пока кассир принимает заказы у людей перед вами. 😍

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

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

Вы платите. 💸
Кассир выдаёт вам номер заказа.

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

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

Вы со своей симпатией едите бургеры и приятно проводите время. ✨

Примечание
Прекрасные иллюстрации созданы Кетриной Томпсон. 🎨
Представьте, что в этой истории вы — компьютер / программа 🤖.
Пока вы стоите в очереди, вы просто бездействуете 😴 и ждёте своей очереди, не занимаясь ничем особо «продуктивным». Но очередь движется быстро, ведь кассир только принимает заказы, а не готовит бургеры, так что это не проблема.
Затем, когда подходит ваша очередь, вы выполняете настоящую «продуктивную» работу: изучаете меню, решаете, чего хотите, узнаёте выбор своей симпатии, платите, проверяете, что дали нужную купюру или карту, убеждаетесь, что с вас списали правильную сумму и что в заказе указаны нужные блюда, и так далее.
Но затем, хотя у вас всё ещё нет бургеров, работа с кассиром «приостанавливается» ⏸, потому что вам нужно ждать 🕙, пока бургеры не будут готовы.
Но, отойдя от стойки и усевшись за столик с номером заказа, вы можете переключить 🔀 внимание на свою симпатию и «заняться» ⏯ 🤓 этим. И снова делать что-то очень «продуктивное» — флиртовать с человеком, который вам нравится 😍.
Затем кассир 💁 сообщает: «Бургеры готовы», показывая ваш номер на табло у стойки. Но вы не вскакиваете как сумасшедший сразу же, как только на табло появляется ваш номер. Вы знаете, что никто не заберёт ваши бургеры, потому что у вас есть номер заказа, а у остальных — свои номера.
Поэтому вы ждёте, пока ваша симпатия закончит рассказ (завершит текущую работу ⏯ / обрабатываемую задачу 🤓), мягко улыбаетесь и говорите, что идёте за бургерами ⏸.
Затем вы возвращаетесь к стойке 🔀, к исходной задаче, которая теперь завершена ⏯, забираете бургеры, благодарите и относите их к столику. На этом этап / задача взаимодействия со стойкой завершается ⏹. В свою очередь, это создаёт новую задачу — «есть бургеры» 🔀 ⏯, тогда как предыдущая задача «забрать бургеры» завершена ⏹.
Параллельные бургеры
А теперь представим, что это не «конкурентные бургеры», а «параллельные бургеры».
Вы идёте со своей симпатией в ресторан быстрого питания с параллельным обслуживанием.
Вы стоите в очереди, пока несколько (скажем, 8) кассиров, которые одновременно являются поварами, принимают заказы у людей перед вами.
Все, кто стоит перед вами, ждут готовности своих бургеров у стойки, потому что каждый из 8 кассиров сразу же идёт готовить бургер и только потом принимает следующий заказ.

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

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

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

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

Вы забираете бургеры и идёте со своей симпатией к столику.
Вы просто съедаете их, и всё. ⏹

Вы почти не разговаривали и не флиртовали, потому что большую часть времени провели в ожидании 🕙 у стойки. 😞
Примечание
Прекрасные иллюстрации созданы Кетриной Томпсон. 🎨
В этом сценарии с параллельными бургерами вы — компьютер / программа 🤖 с двумя процессорами (вы и ваша симпатия), которые оба ждут 🕙 и уделяют внимание ⏯ длительному «ожиданию у стойки» 🕙.
В ресторане быстрого питания работают 8 процессоров (кассиров-поваров). А в ресторане с конкурентным обслуживанием их могло быть всего 2 (один кассир и один повар).
Но, несмотря на это, впечатления всё равно не самые лучшие. 😞
Так выглядела бы история о бургерах с параллельным обслуживанием. 🍔
Для более «жизненного» примера представьте себе банк.
До недавнего времени в большинстве банков было несколько кассиров 👨💼👨💼👨💼👨💼 и длинная очередь 🕙🕙🕙🕙🕙🕙🕙🕙.
Каждый кассир выполнял всю работу с одним клиентом за другим 👨💼⏯.
И вам приходилось долго ждать 🕙 в очереди, иначе вы теряли своё место.
Вы, вероятно, не захотели бы брать свою симпатию 😍 с собой по делам в банк 🏦.
Вывод о бургерах
В этом сценарии — «бургеры из фастфуда с человеком, который вам нравится» — приходится много ждать 🕙, поэтому гораздо разумнее использовать конкурентную систему ⏸🔀⏯.
Так обстоит дело с большинством веб-приложений.
Пользователей очень много, а сервер ждёт 🕙, пока их не слишком хорошее подключение отправит запросы.
А затем снова ждёт 🕙, пока не придут ответы.
Это ожидание 🕙 измеряется микросекундами, но в сумме ожидания всё равно получается много.
Поэтому для веб-API имеет смысл использовать асинхронный код ⏸🔀⏯.
Именно такая асинхронность сделала NodeJS популярным (хотя NodeJS не является параллельным), и в этом заключается сильная сторона языка программирования Go.
И такую же производительность вы получаете с FastAPI.
А поскольку можно одновременно использовать параллелизм и асинхронность, производительность будет выше, чем у большинства протестированных фреймворков NodeJS, и сопоставима с Go — компилируемым языком, близким к C (всё это благодаря Starlette).
Конкурентность лучше параллелизма?
Нет! В этом не заключается мораль истории.
Конкурентность отличается от параллелизма. Она лучше в определённых сценариях, связанных с длительным ожиданием. Поэтому при разработке веб-приложений она, как правило, намного лучше параллелизма. Но так бывает не всегда.
Чтобы уравновесить это утверждение, представьте следующую короткую историю:
Вам нужно убрать большой грязный дом.
Да, это вся история.
Нигде не нужно ждать 🕙 — просто есть много работы в разных местах дома.
Можно убирать по очереди, как в примере с бургерами: сначала гостиную, потом кухню. Но поскольку ждать 🕙 ничего не нужно, а нужно лишь убираться, такая очерёдность ничего не изменит.
На завершение уйдёт столько же времени независимо от того, будете ли вы работать по очереди (конкурентно), и объём выполненной работы будет одинаковым.
Но в этом случае, если бы вы могли позвать 8 бывших кассиров-поваров, которые теперь стали уборщиками, и каждый из них (вместе с вами) убирал бы свою зону дома, вы смогли бы выполнить всю работу параллельно и, благодаря дополнительной помощи, закончить гораздо быстрее.
В этом сценарии каждый уборщик (включая вас) был бы процессором, выполняющим свою часть работы.
А поскольку большая часть времени выполнения уходит на саму работу, а не на ожидание, а работу в компьютере выполняет ЦП, такие задачи называют задачами с ограничением по процессору («CPU bound»).
Распространённые примеры задач с ограничением по процессору — задачи, требующие сложных математических вычислений.
Например:
- Обработка аудио или изображений.
- Компьютерное зрение: изображение состоит из миллионов пикселей, у каждого пикселя есть 3 значения / цвета, и обработка обычно требует одновременно выполнить какие-либо вычисления для всех этих пикселей.
- Машинное обучение: обычно оно требует множества умножений «матриц» и «векторов». Представьте себе огромную электронную таблицу с числами, которые нужно перемножить все одновременно.
- Глубокое обучение: это подраздел машинного обучения, поэтому здесь применимо то же самое. Только перемножать нужно не одну таблицу чисел, а огромное их множество; во многих случаях для создания и / или использования этих моделей применяется специальный процессор.
Конкурентность + параллелизм: веб + машинное обучение
С помощью FastAPI можно воспользоваться конкурентностью, которая широко применяется в веб-разработке (это же главное преимущество NodeJS).
Но можно также использовать преимущества параллелизма и многопроцессорной обработки (одновременного выполнения нескольких процессов) для задач с ограничением по процессору, таких как задачи в системах машинного обучения.
Благодаря этому, а также тому, что Python — основной язык для анализа данных, машинного обучения и особенно глубокого обучения, FastAPI отлично подходит для веб-API и приложений в сфере анализа данных / машинного обучения (а также для многих других задач).
О том, как добиться такого параллелизма в рабочей среде, см. раздел Развёртывание.
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):
# Do some asynchronous stuff to create the burgers
return burgers
...вместо def:
# This is not asynchronous
def get_sequential_burgers(number: int):
# Do some sequential stuff to create the burgers
return burgers
Благодаря async def Python знает, что внутри этой функции нужно учитывать выражения await, а также что выполнение функции можно «приостановить» ⏸, заняться чем-нибудь другим 🔀, а затем вернуться к ней.
При вызове функции async def нужно использовать «await». Поэтому следующий код работать не будет:
# This won't work, because get_burgers was defined with: 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) и обеспечивает его впечатляющую производительность.
Очень технические подробности
Предупреждение
Этот раздел, скорее всего, можно пропустить.
Здесь приведены очень технические подробности о том, как FastAPI работает под капотом.
Если у вас есть технические знания (о корутинах, потоках, блокирующих операциях и т. д.) и вам интересно, как FastAPI обрабатывает async def в сравнении с обычным def, читайте дальше.
Функции операций пути
Если объявить функцию операции пути с обычным def вместо async def, она будет выполняться в отдельном пуле потоков, а затем ожидаться, вместо того чтобы вызываться напрямую (что заблокировало бы сервер).
Если вы переходите с другого асинхронного фреймворка, который работает не так, как описано выше, и привыкли определять тривиальные функции операций пути, выполняющие только вычисления, с обычным def ради незначительного прироста производительности (около 100 наносекунд), обратите внимание: в FastAPI результат будет прямо противоположным. В таких случаях лучше использовать async def, если только ваши функции операций пути не выполняют блокирующий ввод-вывод I/O.
Тем не менее в обоих случаях FastAPI, скорее всего, всё равно будет быстрее вашего предыдущего фреймворка (или как минимум не уступит ему).
Зависимости
То же относится к зависимостям. Если зависимость — это обычная функция def, а не async def, она выполняется в отдельном пуле потоков.
Подзависимости
Можно создавать несколько зависимостей и подзависимостей, которым нужны другие зависимости (в качестве параметров определений функций); некоторые из них могут быть созданы с помощью async def, а некоторые — с помощью обычного def. Всё будет работать: функции, созданные с помощью обычного def, будут вызваны в отдельном потоке (из пула потоков), а не «ожидаться».
Другие вспомогательные функции
Любую другую вспомогательную функцию, которую вы вызываете напрямую, можно создать с помощью обычного def или async def — FastAPI не повлияет на способ её вызова.
Это отличается от функций, вызываемых FastAPI за вас: функций операций пути и зависимостей.
Если ваша вспомогательная функция — обычная функция с def, она будет вызвана напрямую (так, как вы написали её в коде), а не в пуле потоков. Если функция создана с помощью async def, при вызове в коде её нужно await.
Повторим: это очень технические подробности, которые, вероятно, пригодятся тем, кто искал именно такую информацию.
В противном случае достаточно рекомендаций из раздела выше: Нет времени?.
© 2018 Sebastián Ramírez
Licensed under the MIT License.
https://fastapi.tiangolo.com/async/