Конкурентность и 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
.
Если вы не уверены, используйте обычный синтаксис def
.
Примечание: при необходимости можно смешивать def
и async def
в функциях обработки пути
и использовать в каждом случае наиболее подходящий синтаксис. А FastAPI сделает с этим всё, что нужно.
В любом из описанных случаев FastAPI работает асинхронно и очень быстро.
Однако придерживаясь указанных советов, можно получить дополнительную оптимизацию производительности.
Технические подробности¶
Современные версии Python поддерживают разработку так называемого "асинхронного кода" посредством написания "сопрограмм" с использованием синтаксиса async
и await
.
Ниже разберём эту фразу по частям:
- Асинхронный код
async
иawait
- Сопрограммы
Асинхронный код¶
Асинхронный код означает, что в языке 💬 есть возможность сообщить машине / программе 🤖, что в определённой точке кода ей 🤖 нужно будет ожидать завершения выполнения чего-то ещё в другом месте. Допустим это что-то ещё называется "медленный файл" 📝.
И пока мы ждём завершения работы с "медленным файлом" 📝, компьютер может переключиться для выполнения других задач.
Но при каждой возможности компьютер / программа 🤖 будет возвращаться обратно. Например, если он 🤖 опять окажется в режиме ожидания, или когда закончит всю работу. В этом случае компьютер 🤖 проверяет, не завершена ли какая-нибудь из текущих задач.
Потом он 🤖 берёт первую выполненную задачу (допустим, наш "медленный файл" 📝) и продолжает работу, производя с ней необходимые действия.
Вышеупомянутое "что-то ещё", завершения которого приходится ожидать, обычно относится к достаточно "медленным" операциям I/O (по сравнению со скоростью работы процессора и оперативной памяти), например:
- отправка данных от клиента по сети
- получение клиентом данных, отправленных вашей программой по сети
- чтение системой содержимого файла с диска и передача этих данных программе
- запись на диск данных, которые программа передала системе
- обращение к удалённому API
- ожидание завершения операции с базой данных
- получение результатов запроса к базе данных
- и т. д.
Поскольку в основном время тратится на ожидание выполнения операций I/O, их обычно называют операциями, ограниченными скоростью ввода-вывода.
Код называют "асинхронным", потому что компьютеру / программе не требуется "синхронизироваться" с медленной задачей и, будучи в простое, ожидать момента её завершения, с тем чтобы забрать результат и продолжить работу.
Вместо этого в "асинхронной" системе завершённая задача может немного подождать (буквально несколько микросекунд), пока компьютер / программа занимается другими важными вещами, с тем чтобы потом вернуться, забрать результаты выполнения и начать их обрабатывать.
"Синхронное" исполнение (в противовес "асинхронному") также называют "последовательным", потому что компьютер / программа последовательно выполняет все требуемые шаги перед тем, как перейти к следующей задаче, даже если в процессе приходится ждать.
Конкурентность и бургеры¶
Тот асинхронный код, о котором идёт речь выше, иногда называют "конкурентностью". Она отличается от "параллелизма".
Да, конкурентность и параллелизм подразумевают, что разные вещи происходят примерно в одно время.
Но внутреннее устройство конкурентности и параллелизма довольно разное.
Чтобы это понять, представьте такую картину:
Конкурентные бургеры¶
Вы идёте со своей возлюбленной 😍 в фастфуд 🍔 и становитесь в очередь, в это время кассир 💁 принимает заказы у посетителей перед вами.
Когда наконец подходит очередь, вы заказываете парочку самых вкусных и навороченных бургеров 🍔, один для своей возлюбленной 😍, а другой себе.
Отдаёте деньги 💸.
Кассир 💁 что-то говорит поварам на кухне 👨🍳, теперь они знают, какие бургеры нужно будет приготовить 🍔 (но пока они заняты бургерами предыдущих клиентов).
Кассир 💁 отдаёт вам чек с номером заказа.
В ожидании еды вы идёте со своей возлюбленной 😍 выбрать столик, садитесь и довольно продолжительное время общаетесь 😍 (поскольку ваши бургеры самые навороченные, готовятся они не так быстро ✨🍔✨).
Сидя за столиком с возлюбленной 😍 в ожидании бургеров 🍔, вы отлично проводите время, восхищаясь её великолепием, красотой и умом ✨😍✨.
Всё ещё ожидая заказ и болтая со своей возлюбленной 😍, время от времени вы проверяете, какой номер горит над прилавком, и не подошла ли уже ваша очередь.
И вот наконец настаёт этот момент, и вы идёте к стойке, чтобы забрать бургеры 🍔 и вернуться за столик.
Вы со своей возлюбленной 😍 едите бургеры 🍔 и отлично проводите время ✨.
А теперь представьте, что в этой небольшой истории вы компьютер / программа 🤖.
В очереди вы просто глазеете по сторонам 😴, ждёте и ничего особо "продуктивного" не делаете. Но очередь движется довольно быстро, поскольку кассир 💁 только принимает заказы (а не занимается приготовлением еды), так что ничего страшного.
Когда подходит очередь вы наконец предпринимаете "продуктивные" действия 🤓: просматриваете меню, выбираете в нём что-то, узнаёте, что хочет ваша возлюбленная 😍, собираетесь оплатить 💸, смотрите, какую достали карту, проверяете, чтобы с вас списали верную сумму, и что в заказе всё верно и т. д.
И хотя вы всё ещё не получили бургеры 🍔, ваша работа с кассиром 💁 ставится "на паузу" ⏸, поскольку теперь нужно ждать 🕙, когда заказ приготовят.
Но отойдя с номерком от прилавка, вы садитесь за столик и можете переключить 🔀 внимание на свою возлюбленную 😍 и "работать" ⏯ 🤓 уже над этим. И вот вы снова очень "продуктивны" 🤓, мило болтаете вдвоём и всё такое 😍.
В какой-то момент кассир 💁 поместит на табло ваш номер, подразумевая, что бургеры готовы 🍔, но вы не станете подскакивать как умалишённый, лишь только увидев на экране свою очередь. Вы уверены, что ваши бургеры 🍔 никто не утащит, ведь у вас свой номерок, а у других свой.
Поэтому вы подождёте, пока возлюбленная 😍 закончит рассказывать историю (закончите текущую работу ⏯ / задачу в обработке 🤓), и мило улыбнувшись, скажете, что идёте забирать заказ ⏸.
И вот вы подходите к стойке 🔀, к первоначальной задаче, которая уже завершена ⏯, берёте бургеры 🍔, говорите спасибо и относите заказ за столик. На этом заканчивается этап / задача взаимодействия с кассой ⏹. В свою очередь порождается задача "поедание бургеров" 🔀 ⏯, но предыдущая ("получение бургеров") завершена ⏹.
Параллельные бургеры¶
Теперь представим, что вместо бургерной "Конкурентные бургеры" вы решили сходить в "Параллельные бургеры".
И вот вы идёте со своей возлюбленной 😍 отведать параллельного фастфуда 🍔.
Вы становитесь в очередь пока несколько (пусть будет 8) кассиров, которые по совместительству ещё и повары 👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳, принимают заказы у посетителей перед вами.
При этом клиенты не отходят от стойки и ждут 🕙 получения еды, поскольку каждый из 8 кассиров идёт на кухню готовить бургеры 🍔, а только потом принимает следующий заказ.
Наконец настаёт ваша очередь, и вы просите два самых навороченных бургера 🍔, один для дамы сердца 😍, а другой себе.
Ни о чём не жалея, расплачиваетесь 💸.
И кассир уходит на кухню 👨🍳.
Вам приходится ждать перед стойкой 🕙, чтобы никто по случайности не забрал ваши бургеры 🍔, ведь никаких номерков у вас нет.
Поскольку вы с возлюбленной 😍 хотите получить заказ вовремя 🕙, и следите за тем, чтобы никто не вклинился в очередь, у вас не получается уделять должного внимание своей даме сердца 😞.
Это "синхронная" работа, вы "синхронизированы" с кассиром/поваром 👨🍳. Приходится ждать 🕙 у стойки, когда кассир/повар 👨🍳 закончит делать бургеры 🍔 и вручит вам заказ, иначе его случайно может забрать кто-то другой.
Наконец кассир/повар 👨🍳 возвращается с бургерами 🍔 после невыносимо долгого ожидания 🕙 за стойкой.
Вы скорее забираете заказ 🍔 и идёте с возлюбленной 😍 за столик.
Там вы просто едите эти бургеры, и на этом всё 🍔 ⏹.
Вам не особо удалось пообщаться, потому что большую часть времени 🕙 пришлось провести у кассы 😞.
В описанном сценарии вы компьютер / программа 🤖 с двумя исполнителями (вы и ваша возлюбленная 😍), на протяжении долгого времени 🕙 вы оба уделяете всё внимание ⏯ задаче "ждать на кассе".
В этом ресторане быстрого питания 8 исполнителей (кассиров/поваров) 👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳. Хотя в бургерной конкурентного типа было всего два (один кассир и один повар) 💁 👨🍳.
Несмотря на обилие работников, опыт в итоге получился не из лучших 😞.
Так бы выглядел аналог истории про бургерную 🍔 в "параллельном" мире.
Вот более реалистичный пример. Представьте себе банк.
До недавних пор в большинстве банков было несколько кассиров 👨💼👨💼👨💼👨💼 и длинные очереди 🕙🕙🕙🕙🕙🕙🕙🕙.
Каждый кассир обслуживал одного клиента, потом следующего 👨💼⏯.
Нужно было долгое время 🕙 стоять перед окошком вместе со всеми, иначе пропустишь свою очередь.
Сомневаюсь, что у вас бы возникло желание прийти с возлюбленной 😍 в банк 🏦 оплачивать налоги.
Выводы о бургерах¶
В нашей истории про поход в фастфуд за бургерами приходится много ждать 🕙, поэтому имеет смысл организовать конкурентную систему ⏸🔀⏯.
И то же самое с большинством веб-приложений.
Пользователей очень много, но ваш сервер всё равно вынужден ждать 🕙 запросы по их слабому интернет-соединению.
Потом снова ждать 🕙, пока вернётся ответ.
Это ожидание 🕙 измеряется микросекундами, но если всё сложить, то набегает довольно много времени.
Вот почему есть смысл использовать асинхронное ⏸🔀⏯ программирование при построении веб-API.
Большинство популярных фреймворков (включая Flask и Django) создавались до появления в Python новых возможностей асинхронного программирования. Поэтому их можно разворачивать с поддержкой параллельного исполнения или асинхронного программирования старого типа, которое не настолько эффективно.
При том, что основная спецификация асинхронного взаимодействия Python с веб-сервером (ASGI) была разработана командой Django для внедрения поддержки веб-сокетов.
Именно асинхронность сделала NodeJS таким популярным (несмотря на то, что он не параллельный), и в этом преимущество Go как языка программирования.
И тот же уровень производительности даёт FastAPI.
Поскольку можно использовать преимущества параллелизма и асинхронности вместе, вы получаете производительность лучше, чем у большинства протестированных NodeJS фреймворков и на уровне с Go, который является компилируемым языком близким к C (всё благодаря Starlette).
Получается, конкурентность лучше параллелизма?¶
Нет! Мораль истории совсем не в этом.
Конкурентность отличается от параллелизма. Она лучше в конкретных случаях, где много времени приходится на ожидание. Вот почему она зачастую лучше параллелизма при разработке веб-приложений. Но это не значит, что конкурентность лучше в любых сценариях.
Давайте посмотрим с другой стороны, представьте такую картину:
Вам нужно убраться в большом грязном доме.
Да, это вся история.
Тут не нужно нигде ждать 🕙, просто есть куча работы в разных частях дома.
Можно организовать очередь как в примере с бургерами, сначала гостиная, потом кухня, но это ни на что не повлияет, поскольку вы нигде не ждёте 🕙, а просто трёте да моете.
И понадобится одинаковое количество времени с очередью (конкурентностью) и без неё, и работы будет сделано тоже одинаковое количество.
Однако в случае, если бы вы могли привести 8 бывших кассиров/поваров, а ныне уборщиков 👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳, и каждый из них (вместе с вами) взялся бы за свой участок дома, с такой помощью вы бы закончили намного быстрее, делая всю работу параллельно.
В описанном сценарии каждый уборщик (включая вас) был бы исполнителем, занятым на своём участке работы.
И поскольку большую часть времени выполнения занимает реальная работа (а не ожидание), а работу в компьютере делает ЦП, такие задачи называют ограниченными производительностью процессора.
Ограничение по процессору проявляется в операциях, где требуется выполнять сложные математические вычисления.
Например:
- Обработка звука или изображений.
- Компьютерное зрение: изображение состоит из миллионов пикселей, в каждом пикселе 3 составляющих цвета, обработка обычно требует проведения расчётов по всем пикселям сразу.
- Машинное обучение: здесь обычно требуется умножение "матриц" и "векторов". Представьте гигантскую таблицу с числами в Экселе, и все их надо одновременно перемножить.
- Глубокое обучение: это область машинного обучения, поэтому сюда подходит то же описание. Просто у вас будет не одна таблица в Экселе, а множество. В ряде случаев используется специальный процессор для создания и / или использования построенных таким образом моделей.
Конкурентность + параллелизм: Веб + машинное обучение¶
FastAPI предоставляет возможности конкуретного программирования, которое очень распространено в веб-разработке (именно этим славится NodeJS).
Кроме того вы сможете использовать все преимущества параллелизма и многопроцессорности (когда несколько процессов работают параллельно), если рабочая нагрузка предполагает ограничение по процессору, как, например, в системах машинного обучения.
Необходимо также отметить, что Python является главным языком в области дата-сайенс, машинного обучения и, особенно, глубокого обучения. Всё это делает FastAPI отличным вариантом (среди многих других) для разработки веб-API и приложений в области дата-сайенс / машинного обучения.
Как добиться такого параллелизма в эксплуатации описано в разделе Развёртывание.
async
и await
¶
В современных версиях Python разработка асинхронного кода реализована очень интуитивно. Он выглядит как обычный "последовательный" код и самостоятельно выполняет "ожидание", когда это необходимо.
Если некая операция требует ожидания перед тем, как вернуть результат, и поддерживает современные возможности Python, код можно написать следующим образом:
burgers = await get_burgers(2)
Главное здесь слово await
. Оно сообщает интерпретатору, что необходимо дождаться ⏸
пока 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
указывает интерпретатору, что внутри этой функции
следует ожидать выражений 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
.
Но выполнение такой функции необходимо "ожидать" с помощью await
.
Это означает, что её можно вызвать только из другой функции, которая тоже объявлена с async def
.
Но как же тогда появилась первая курица? В смысле... как нам вызвать первую асинхронную функцию?
При работе с FastAPI просто не думайте об этом, потому что "первой" функцией является ваша функция обработки пути, и дальше с этим разберётся FastAPI.
Кроме того, если хотите, вы можете использовать синтаксис async
/ await
и без FastAPI.
Пишите свой асинхронный код¶
Starlette (и FastAPI) основаны на AnyIO, что делает их совместимыми как со стандартной библиотекой asyncio в Python, так и с Trio.
В частности, вы можете напрямую использовать AnyIO в тех проектах, где требуется более сложная логика работы с конкурентностью.
Даже если вы не используете FastAPI, вы можете писать асинхронные приложения с помощью AnyIO, чтобы они были максимально совместимыми и получали его преимущества (например структурную конкурентность).
Другие виды асинхронного программирования¶
Стиль написания кода с async
и await
появился в языке Python относительно недавно.
Но он сильно облегчает работу с асинхронным кодом.
Ровно такой же синтаксис (ну или почти такой же) недавно был включён в современные версии JavaScript (в браузере и NodeJS).
До этого поддержка асинхронного кода была реализована намного сложнее, и его было труднее воспринимать.
В предыдущих версиях Python для этого использовались потоки или Gevent. Но такой код намного сложнее понимать, отлаживать и мысленно представлять.
Что касается JavaScript (в браузере и NodeJS), раньше там использовали для этой цели "обратные вызовы". Что выливалось в ад обратных вызовов.
Сопрограммы¶
Корути́на (или же сопрограмма) — это крутое словечко для именования той сущности,
которую возвращает функция async def
. Python знает, что её можно запустить, как и обычную функцию,
но кроме того сопрограмму можно поставить на паузу ⏸ в том месте, где встретится слово await
.
Всю функциональность асинхронного программирования с использованием async
и await
часто обобщают словом "корутины". Они аналогичны "горутинам", ключевой особенности
языка Go.
Заключение¶
В самом начале была такая фраза:
Современные версии Python поддерживают разработку так называемого "асинхронного кода" посредством написания "сопрограмм" с использованием синтаксиса
async
иawait
.
Теперь всё должно звучать понятнее. ✨
На этом основана работа FastAPI (посредством Starlette), и именно это обеспечивает его высокую производительность.
Очень технические подробности¶
Warning
Этот раздел читать не обязательно.
Здесь приводятся подробности внутреннего устройства FastAPI.
Но если вы обладаете техническими знаниями (корутины, потоки, блокировка и т. д.)
и вам интересно, как FastAPI обрабатывает async def
в отличие от обычных def
,
читайте дальше.
Функции обработки пути¶
Когда вы объявляете функцию обработки пути обычным образом с ключевым словом def
вместо async def
, FastAPI ожидает её выполнения, запустив функцию во внешнем
пуле потоков, а не напрямую (это бы заблокировало сервер).
Если ранее вы использовали другой асинхронный фреймворк, который работает иначе,
и привыкли объявлять простые вычислительные функции через def
ради
незначительного прироста скорости (порядка 100 наносекунд), обратите внимание,
что с FastAPI вы получите противоположный эффект. В таком случае больше подходит
async def
, если только функция обработки пути не использует код, приводящий
к блокировке I/O.
Но в любом случае велика вероятность, что FastAPI окажется быстрее другого фреймворка (или хотя бы на уровне с ним).
Зависимости¶
То же относится к зависимостям. Если это обычная функция def
, а не async def
,
она запускается во внешнем пуле потоков.
Подзависимости¶
Вы можете объявить множество ссылающихся друг на друга зависимостей и подзависимостей
(в виде параметров при определении функции). Какие-то будут созданы с помощью async def
,
другие обычным образом через def
, и такая схема вполне работоспособна. Функции,
объявленные с помощью def
будут запускаться на внешнем потоке (из пула),
а не с помощью await
.
Другие служебные функции¶
Любые другие служебные функции, которые вы вызываете напрямую, можно объявлять
с использованием def
или async def
. FastAPI не будет влиять на то, как вы
их запускаете.
Этим они отличаются от функций, которые FastAPI вызывает самостоятельно: функции обработки пути и зависимости.
Если служебная функция объявлена с помощью def
, она будет вызвана напрямую
(как вы и написали в коде), а не в отдельном потоке. Если же она объявлена с
помощью async def
, её вызов должен осуществляться с ожиданием через await
.
Ещё раз повторим, что все эти технические подробности полезны, только если вы специально их искали.
В противном случае просто ознакомьтесь с основными принципами в разделе выше: Нет времени?.