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

Наконец ваша очередь: вы заказываете 2 очень «навороченных» бургера — для вашей возлюбленной и для себя. 🍔🍔

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

Вы платите. 💸
Кассир выдаёт вам номер вашей очереди.

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

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

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

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

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

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

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

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

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

Разговоров и флирта было немного, потому что большую часть времени вы ждали 🕙 у стойки. 😞
/// info | Информация
Прекрасные иллюстрации от Ketrina Thompson. 🎨
///
В этом сценарии «параллельных бургеров» вы — компьютер/программа 🤖 с двумя процессорами (вы и ваша возлюбленная), оба ждут 🕙 и уделяют внимание ⏯ тому, чтобы «ждать у стойки» 🕙 долгое время.
В ресторане 8 процессоров (кассиров/поваров). Тогда как в «конкурентных бургерах» могло быть только 2 (один кассир и один повар).
И всё же финальный опыт — не самый лучший. 😞
Это была параллельная версия истории про бургеры. 🍔
Для более «жизненного» примера представьте банк.
До недавнего времени в большинстве банков было несколько кассиров 👨💼👨💼👨💼👨💼 и длинная очередь 🕙🕙🕙🕙🕙🕙🕙🕙.
Все кассиры делают всю работу с одним клиентом за другим 👨💼⏯.
И вам приходится долго 🕙 стоять в очереди, иначе вы потеряете свою очередь.
Вы вряд ли захотите взять свою возлюбленную 😍 с собой, чтобы заняться делами в банке 🏦.
Вывод про бургеры
В этом сценарии «фастфуда с вашей возлюбленной», так как много ожидания 🕙, гораздо логичнее иметь конкурентную систему ⏸🔀⏯.
Так обстоит дело и с большинством веб-приложений.
Очень много пользователей, но ваш сервер ждёт 🕙, пока их не самое хорошее соединение отправит их запросы.
А затем снова ждёт 🕙, пока отправятся ответы.
Это «ожидание» 🕙 измеряется микросекундами, но если всё сложить, то в сумме получается много ожидания.
Вот почему асинхронный ⏸🔀⏯ код очень уместен для веб-API.
Именно такая асинхронность сделала NodeJS популярным (хотя NodeJS — не параллельный), и это сильная сторона Go как языка программирования.
Того же уровня производительности вы получаете с FastAPI.
А так как можно одновременно использовать параллелизм и асинхронность, вы получаете производительность выше, чем у большинства протестированных фреймворков на NodeJS и на уровне Go, который — компилируемый язык, ближе к C (всё благодаря Starlette).
Конкурентность лучше параллелизма?
Нет! Мораль истории не в этом.
Конкурентность отличается от параллелизма. И она лучше в конкретных сценариях, где много ожидания. Поэтому при разработке веб-приложений она обычно намного лучше параллелизма. Но не во всём.
Чтобы уравновесить это, представьте такую короткую историю:
Вам нужно убрать большой грязный дом.
Да, это вся история.
Здесь нигде нет ожидания 🕙, просто очень много работы в разных местах дома.
Можно организовать «очереди» как в примере с бургерами — сначала гостиная, потом кухня, — но так как вы ничего не ждёте 🕙, а просто убираете и убираете, очереди ни на что не повлияют.
На завершение уйдёт одинаковое время — с очередями (конкурентностью) и без них — и объём выполненной работы будет одинаковым.
Но в этом случае, если бы вы могли привести 8 бывших кассиров/поваров, а теперь — уборщиков, и каждый из них (плюс вы) взял бы свою зону дома для уборки, вы могли бы сделать всю работу параллельно, с дополнительной помощью, и завершить гораздо быстрее.
В этом сценарии каждый уборщик (включая вас) был бы процессором, выполняющим свою часть работы.
И так как основное время выполнения уходит на реальную работу (а не ожидание), а работу в компьютере выполняет CPU, такие задачи называют «ограниченными процессором» (CPU bound).
Типичные примеры CPU-bound операций — те, которые требуют сложной математической обработки.
Например:
- Обработка аудио или изображений.
- Компьютерное зрение: изображение состоит из миллионов пикселей, каждый пиксель имеет 3 значения/цвета; обычно требуется вычислить что-то для всех этих пикселей одновременно.
- Машинное обучение: обычно требует множества умножений «матриц» и «векторов». Представьте огромную таблицу с числами и умножение всех этих чисел «одновременно».
- Глубокое обучение: это подполе Машинного обучения, так что всё вышесказанное применимо. Просто это не одна таблица чисел, а их огромный набор, и во многих случаях вы используете специальный процессор, чтобы строить и/или использовать такие модели.
Конкурентность + параллелизм: Веб + Машинное обучение
С FastAPI вы можете использовать преимущества конкурентности, что очень распространено в веб-разработке (это та же основная «фишка» NodeJS).
Но вы также можете использовать выгоды параллелизма и многопроцессности (когда несколько процессов работают параллельно) для рабочих нагрузок, ограниченных процессором (CPU bound), как в системах Машинного обучения.
Плюс к этому простой факт, что Python — основной язык для Data Science, Машинного обучения и особенно Глубокого обучения, делает FastAPI очень хорошим выбором для веб-API и приложений в области Data Science / Машинного обучения (среди многих других).
Как добиться такого параллелизма в продакшн, см. раздел Развёртывание{.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 вы бы использовали «callbacks» (обратные вызовы), что приводит к «callback hell» (ад обратных вызовов).
Сопрограммы
Сопрограмма (coroutine) — это просто «навороченное» слово для того, что возвращает функция async def
. Python знает, что это похоже на функцию: её можно запустить, она когда-нибудь завершится, но её выполнение может приостанавливаться ⏸ внутри, когда встречается await
.
Часто всю функциональность использования асинхронного кода с async
и await
кратко называют «сопрограммами». Это сопоставимо с ключевой особенностью Go — «goroutines».
Заключение
Вернёмся к той же фразе:
Современные версии Python поддерживают «асинхронный код» с помощью «сопрограмм» (coroutines) и синтаксиса
async
иawait
.
Теперь это должно звучать понятнее. ✨
Именно это «движет» FastAPI (через Starlette) и обеспечивает столь впечатляющую производительность.
Очень технические подробности
/// warning | Предупреждение
Скорее всего, этот раздел можно пропустить.
Здесь — очень технические подробности о том, как FastAPI работает «под капотом».
Если у вас есть достаточно технических знаний (сопрограммы, потоки, блокировки и т.д.) и вам интересно, как FastAPI обрабатывает async def
по сравнению с обычным def
, — вперёд.
///
Функции-обработчики пути
Когда вы объявляете функцию-обработчик пути обычным def
вместо async def
, она запускается во внешнем пуле потоков, который затем «ожидается», вместо прямого вызова (прямой вызов заблокировал бы сервер).
Если вы пришли из другого async-фреймворка, который работает иначе, и привыкли объявлять тривиальные функции-обработчики пути, выполняющие только вычисления, через простой 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
.
Снова: это очень технические подробности, полезные, вероятно, только если вы целенаправленно их ищете.
Иначе вам достаточно руководствоваться рекомендациями из раздела выше: Нет времени?.