Рівночасність і async / await¶
🌐 Переклад ШІ та людьми
Цей переклад виконано ШІ під керівництвом людей. 🤝
Можливі помилки через неправильне розуміння початкового змісту або неприродні формулювання тощо. 🤖
Ви можете покращити цей переклад, допомігши нам краще спрямовувати AI LLM.
Деталі щодо синтаксису async def для функцій операції шляху і деякі відомості про асинхронний код, рівночасність і паралелізм.
Поспішаєте?¶
TL;DR:
Якщо ви використовуєте сторонні бібліотеки, які вимагають виклику з 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- Співпрограми
Асинхронний код¶
Асинхронний код означає, що мова 💬 має спосіб сказати комп’ютеру/програмі 🤖, що в певний момент у коді він 🤖 має почекати, поки «щось інше» завершиться десь ще. Скажімо, це «щось інше» називається «slow-file» 📝.
Отже, в цей час комп’ютер може піти і зробити іншу роботу, доки «slow-file» 📝 завершується.
Далі комп’ютер/програма 🤖 повертатиметься щоразу, коли матиме можливість, бо знову чекає, або коли він 🤖 завершив усю роботу, яка була в нього на той момент. І він 🤖 перевірить, чи якась із задач, на які він чекав, уже завершилася, виконавши все, що потрібно.
Потім він 🤖 бере першу завершену задачу (скажімо, наш «slow-file» 📝) і продовжує робити те, що потрібно було зробити з нею.
Це «чекати на щось інше» зазвичай стосується операцій I/O, які відносно «повільні» (порівняно зі швидкістю процесора та пам’яті з довільним доступом), наприклад, очікування:
- даних від клієнта, що надсилаються мережею
- даних, надісланих вашим застосунком, які клієнт має отримати мережею
- вмісту файла на диску, який система має прочитати і передати вашому застосунку
- вмісту, який ваш застосунок передав системі, щоб він був записаний на диск
- віддаленої операції API
- завершення операції бази даних
- повернення результатів запиту до бази даних
- тощо
Оскільки час виконання переважно витрачається на очікування операцій I/O, їх називають операціями «I/O bound».
Це називається «асинхронним», тому що комп’ютеру/програмі не потрібно бути «синхронізованими» з повільною задачею, очікуючи точного моменту її завершення, нічого не роблячи, лише щоб отримати результат задачі та продовжити роботу.
Натомість, у «асинхронній» системі щойно завершена задача може трохи зачекати в черзі (кілька мікросекунд), доки комп’ютер/програма завершить те, що пішов робити, а потім повернеться, щоб забрати результати і продовжити роботу з ними.
Для «синхронного» (на противагу «асинхронному») часто також використовують термін «послідовний», бо комп’ютер/програма слідує всім крокам послідовно, перш ніж перемкнутися на іншу задачу, навіть якщо ці кроки включають очікування.
Рівночасність і бургери¶
Ідею асинхронного коду, описану вище, інколи також називають «рівночасністю». Вона відрізняється від «паралелізму».
І рівночасність, і паралелізм стосуються «різних речей, що відбуваються більш-менш одночасно».
Але деталі між рівночасністю і паралелізмом досить різні.
Щоб побачити різницю, уявімо таку історію про бургери:
Рівночасні бургери¶
Ви йдете зі своєю симпатією по фастфуд, стаєте в чергу, доки касир приймає замовлення у людей перед вами. 😍

Потім ваша черга, ви замовляєте 2 дуже вишукані бургери для вашої симпатії і для себе. 🍔🍔

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

Ви платите. 💸
Касир дає вам номер вашої черги.

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

Чекаючи і спілкуючись із вашою симпатією, час від часу ви перевіряєте номер на табло біля прилавка, щоб побачити, чи вже ваша черга.
І от нарешті ваша черга. Ви підходите до прилавка, забираєте бургери і повертаєтеся до столика.

Ви з вашою симпатією їсте бургери і гарно проводите час. ✨

Інформація
Прекрасні ілюстрації від Ketrina Thompson. 🎨
Уявіть, що в цій історії ви - комп’ютер/програма 🤖.
Поки ви в черзі, ви просто бездіяльні 😴, чекаєте своєї черги, нічого «продуктивного» не роблячи. Але черга рухається швидко, бо касир лише приймає замовлення (а не готує їх), тож це нормально.
Коли ж ваша черга, ви виконуєте справді «продуктивну» роботу: переглядаєте меню, вирішуєте, що бажаєте, дізнаєтеся вибір вашої симпатії, платите, перевіряєте, що віддаєте правильну купюру чи картку, що з вас правильно списали кошти, що замовлення містить правильні позиції тощо.
Але потім, хоча у вас ще немає бургерів, ваша взаємодія з касиром «на паузі» ⏸, бо вам доводиться чекати 🕙, поки бургери будуть готові.
Втім, відійшовши від прилавка і сівши за столик із номерком, ви можете перемкнути 🔀 увагу на свою симпатію і «попрацювати» ⏯ 🤓 над цим. Тоді ви знову робите щось дуже «продуктивне» - фліртуєте зі своєю симпатією 😍.
Потім касир 💁 каже «Я закінчив робити бургери», виводячи ваш номер на табло прилавка, але ви не підстрибуєте миттєво, щойно номер змінюється на ваш. Ви знаєте, що ніхто не вкраде ваші бургери, адже у вас є номер вашої черги, а в інших - свій.
Тож ви чекаєте, поки ваша симпатія завершить історію (завершить поточну роботу ⏯/задачу 🤓), лагідно усміхаєтеся і кажете, що підете за бургерами ⏸.
Потім ви йдете до прилавка 🔀, до початкової задачі, яку тепер завершено ⏯, забираєте бургери, дякуєте і несете їх до столу. Це завершує той крок/задачу взаємодії з прилавком ⏹. Натомість з’являється нова задача «їсти бургери» 🔀 ⏯, але попередня «отримати бургери» завершена ⏹.
Паралельні бургери¶
А тепер уявімо, що це не «рівночасні бургери», а «паралельні бургери».
Ви йдете зі своєю симпатією по паралельний фастфуд.
Ви стаєте в чергу, поки кілька (скажімо, 8) касирів, які водночас є кухарями, приймають замовлення у людей перед вами.
Кожен перед вами чекає, поки його бургери будуть готові, перш ніж відійти від прилавка, тому що кожен з 8 касирів одразу йде і готує бургер, перш ніж приймати наступне замовлення.

Нарешті ваша черга, ви замовляєте 2 дуже вишукані бургери для вашої симпатії і для себе.
Ви платите 💸.

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

Оскільки ви з вашою симпатією зайняті тим, щоб ніхто не став перед вами і не забрав ваші бургери, щойно вони з’являться, ви не можете приділяти увагу своїй симпатії. 😞
Це «синхронна» робота, ви «синхронізовані» з касиром/кухарем 👨🍳. Вам доводиться чекати 🕙 і бути тут у точний момент, коли касир/кухар 👨🍳 завершить бургери і віддасть їх вам, інакше хтось інший може їх забрати.

Потім ваш касир/кухар 👨🍳 нарешті повертається з вашими бургерами після довгого очікування 🕙 перед прилавком.

Ви берете бургери і йдете до столика зі своєю симпатією.
Ви просто їх їсте - і все. ⏹

Багато розмов чи флірту не було, бо більшість часу пішла на очікування 🕙 перед прилавком. 😞
Інформація
Прекрасні ілюстрації від 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 / машинного навчання (серед багатьох інших).
Щоб побачити, як досягти цього паралелізму у продакшні, див. розділ про Розгортання.
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, ви також можете це зробити.
Пишемо свій власний async-код¶
Starlette (і FastAPI) базуються на AnyIO, що робить їх сумісними як зі стандартною бібліотекою Python asyncio, так і з Trio.
Зокрема, ви можете безпосередньо використовувати AnyIO для ваших просунутих сценаріїв рівночасності, що потребують складніших патернів у вашому коді.
І навіть якщо ви не використовували FastAPI, ви могли б писати свої власні async-застосунки з AnyIO, щоб мати високу сумісність і отримати його переваги (наприклад, структурована рівночасність).
Я створив іншу бібліотеку поверх AnyIO, як тонкий шар, щоб дещо покращити анотації типів і отримати кращу автодопомогу (autocompletion), вбудовані помилки (inline errors) тощо. Вона також має дружній вступ і навчальний посібник, щоб допомогти вам зрозуміти і написати власний async-код: Asyncer. Вона буде особливо корисною, якщо вам потрібно поєднувати async-код зі звичайним (блокуючим/синхронним) кодом.
Інші форми асинхронного коду¶
Такий стиль використання async і await відносно новий у мові.
Але він значно полегшує роботу з асинхронним кодом.
Такий самий (або майже ідентичний) синтаксис нещодавно з’явився в сучасних версіях JavaScript (у Browser і NodeJS).
До цього робота з асинхронним кодом була значно складнішою.
У попередніх версіях Python ви могли використовувати потоки (threads) або Gevent. Але код набагато складніший для розуміння, налагодження і мислення про нього.
У попередніх версіях NodeJS/Browser JavaScript ви б використовували «callbacks», що призводить до «callback hell».
Співпрограми¶
Співпрограма - це просто дуже вишукана назва для об’єкта, який повертає функція async def. Python знає, що це щось на кшталт функції, яку можна запустити і яка завершиться в певний момент, але яку також можна поставити на паузу ⏸ всередині, коли є await.
Але всю цю функціональність використання асинхронного коду з async і await часто підсумовують як використання «співпрограм». Це порівняно з головною ключовою особливістю Go - «Goroutines».
Висновок¶
Погляньмо на ту саму фразу ще раз:
Сучасні версії Python мають підтримку «асинхронного коду» за допомогою так званих «співпрограм», з синтаксисом
asyncіawait.
Тепер це має більше сенсу. ✨
Усе це приводить у дію FastAPI (через Starlette) і дає йому таку вражаючу продуктивність.
Дуже технічні деталі¶
Попередження
Ймовірно, ви можете пропустити це.
Це дуже технічні деталі про те, як FastAPI працює «під капотом».
Якщо у вас є чимало технічних знань (співпрограми, потоки, блокування тощо) і вам цікаво, як FastAPI обробляє async def проти звичайного def, - вперед.
Функції операції шляху¶
Коли ви оголошуєте функцію операції шляху зі звичайним def замість async def, вона виконується у зовнішньому пулі потоків (threadpool), який потім «очікується», замість прямого виклику (оскільки прямий виклик блокував би сервер).
Якщо ви прийшли з іншого async-фреймворку, який не працює так, як описано вище, і звикли визначати тривіальні, лише обчислювальні функції операції шляху зі звичайним def заради крихітного виграшу у продуктивності (близько 100 наносекунд), зверніть увагу, що у FastAPI ефект буде протилежним. У таких випадках краще використовувати async def, якщо тільки ваші функції операції шляху не використовують код, що виконує блокуюче I/O.
Втім, у будь-якій ситуації є велика ймовірність, що FastAPI все одно буде швидшим (або принаймні порівнянним) за ваш попередній фреймворк.
Залежності¶
Те саме стосується і залежностей. Якщо залежність є стандартною функцією def замість async def, вона виконується у зовнішньому пулі потоків.
Підзалежності¶
Ви можете мати кілька залежностей і підзалежностей, які вимагають одна одну (як параметри визначень функцій). Деякі з них можуть бути створені з async def, а деякі - зі звичайним def. Все працюватиме, і ті, що створені зі звичайним def, будуть викликані у зовнішньому потоці (з пулу потоків), а не «очікувані».
Інші допоміжні функції¶
Будь-яка інша допоміжна функція, яку ви викликаєте безпосередньо, може бути створена зі звичайним def або async def, і FastAPI не впливатиме на спосіб її виклику.
Це відрізняється від функцій, які FastAPI викликає за вас: функції операції шляху і залежності.
Якщо ваша допоміжна функція є звичайною функцією з def, її буде викликано безпосередньо (як ви написали у своєму коді), не в пулі потоків; якщо функція створена з async def, тоді вам слід використовувати await при її виклику у вашому коді.
Знову ж таки, це дуже технічні деталі, які, ймовірно, стануть у пригоді, якщо ви спеціально їх шукали.
Інакше вам вистачить настанов із розділу вище: Поспішаєте?.