Перехід з JavaScript на Python нагадує переїзд до міста з тими самими дорожніми знаками, але іншими правилами дорожнього руху. Синтаксис виглядає приязним і знайомим. Ви бачите async та await прямо в граматиці, тому припускаєте, що ментальна модель перенесеться без змін. Але це не так. Один звичний патерн із JavaScript непомітно обвалює продуктивність вашого Python-коду, не викликаючи збоїв, не записуючи помилок у логах і не помічаючись під час швидкого перегляду коду.
Як JavaScript вчить вас «запустити та забути»
У JavaScript виклик асинхронної функції є «жадібним» (eager). Щойно ви викликаєте її, рушій створює Promise, і робота починається негайно. Event loop уже в розпалі роботи. Ось чому розробники JavaScript природним чином пишуть код ось так:
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;
Обидва мережеві запити вже виконуються ще до того, як буде досягнуто будь-якого await. Перший await призупиняє поточну функцію, доки fetchUser не завершиться, але fetchOrders працює у фоновому режимі ще з попереднього рядка. До того часу, як вам знадобиться змінна orders, другий запит може бути вже виконаний. Цей патерн здається настільки природним у JavaScript, що багато розробників навіть не вважають його прийомом для паралельного виконання. Це просто те, як працює async.
Сюрприз Python: «холодна» корутина
Python використовує інший контракт. Коли ви викликаєте функцію async def у Python, ви не запускаєте жодної роботи. Ви отримуєте об'єкт корутини (coroutine). Уявіть, що це рецепт, записаний на папері. Інгредієнти перелічені, кроки зрозумілі, але нічого не готується в духовці. Поки щось явно не пропустить цю корутину через event loop, вона залишатиметься інертною.
Ось у чому пастка. Інженер із досвідом JavaScript, якому потрібен користувач та його замовлення, може написати в Python ось так:
user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
Це виглядає як паралельне виконання. Це відчувається як паралельне виконання. Але насправді це суто послідовний процес.
Перший рядок присвоює неактивну корутину змінній user_coro. Другий рядок присвоює ще одну неактивну корутину змінній orders_coro. Коли виконання доходить до await user_coro, Python нарешті запускає перше завдання і виконує його до кінця. Тільки після завершення fetch_user інтерпретатор дійде до await orders_coro і запустить друге завдання. Загальний час виконання — це сума обох I/O операцій, а не час найдовшої з них. Ви не запустили їх паралельно. Ви запустили їх одну за одною, але з додатковими кроками.
Чому цей баг невидимий
Це саме той тип регресії продуктивності, який може зберігатися місяцями. Код є валідним Python-кодом. Він проходить перевірку типів. Він повертає правильні результати. Він просто працює вдвічі повільніше або навіть гірше. Оскільки немає стеку викликів (stack trace) або попереджень, інженерні команди часто шукають причину в іншому. Вони додають кеші Redis, оновлюють рівні баз даних або змінюють регіони хостингу. Справжня причина — у тонка невідповідність очікувань щодо того, що насправді робить await.
Три способи змусити Python дійсно виконувати завдання паралельно
Щоб виправити це, ви повинні наказати event loop у Python запланувати роботу негайно. Вам потрібно щось активніше за звичайну корутину. Вам потрібна Task.
1. asyncio.create_task
Найбільш прямий переклад патерну з JavaScript — це обгорнути вашу корутину в Task. Task планується в event loop одразу після створення. Це найближчий еквівалент JavaScript Promise у русі в Python.
user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task
orders = await_orders_task
Тепер і fetch_user, і fetch_orders уже виконуються ще до першого await. Коли ви доходите до await user_task, ви призупиняєтеся лише до завершення цього конкретного Task, але інший Task продовжує працювати. Якщо fetch_orders завершиться першим, його результат просто чекатиме всередині orders_task, доки ви його не запитаєте.
Проте будьте обережні. Якщо ви створите Task і ніколи не використаєте await для нього, Python видасть помилку про знищений незавершений task (destroyed pending task). Ви все одно повинні зібрати свої результати.
2. asyncio.gather
Якщо у вас є кілька корутин, які мають завершитися, перш ніж ви продовжите, asyncio.gather візьме на себе всю рутину. Він внутрішньо планує кожну корутину як Task і очікує на їхнє спільне завершення.
user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))
Це лаконічно і зрозуміло. Цей метод чудово підходить, коли операції незалежні і ви хочете одним рядком висловити: «виконай усе це, а потім дай мені кожен результат». Він також зберігає порядок аргументів у повернутому списку або кортежі, навіть якщо базові завдання завершуються в іншому порядку.
3. asyncio.TaskGroup
Python 3.11 представив TaskGroup, що привносить структуровану конкурентність у стандартну бібліотеку. Замість того, щоб створювати завдання вручну, ви використовуєте контекстний менеджер, який гарантує, що кожне створене завдання завершиться належним чином. Якщо одне завдання викликає виняток, інші скасовуються автоматично.
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(id))
orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
Цей патерн чудово підходить для складних робочих процесів. Він усуває ризик появи Task-сиріт і групує життєвий цикл пов'язаних операцій під однією логічною парасолькою. Якщо ваш кодовий бази працює на Python 3.11 або новіше, це часто є найчистішою архітектурою для конкурентності типу fan-out.
Ментальна модель: await означає «запусти це зараз»
Основний урок — лінгвістичний. У JavaScript ви можете читати await як «тим часом». Ви запускаєте роботу, робите інші речі та ставите на паузу лише тоді, коли вам потрібне значення. У Python await означає «доведіть цю корутину до наступної точки призупинення або завершення». Якщо корутина ще не була запланована, саме await її планує. Ось чому ви не можете просто запустити дві «сирі» корутини, а потім чекати на них через await. Ви не дали event loop нічого робити тим часом.
Думайте про корутини Python як про функції-генератори. Виклик генератора не призводить до його ітерації. Вам потрібно пройтися по ньому циклом, викликати next() або передати його споживачу. Асинхронність працює так само. asyncio.create_task — це споживач, який каже: «постав це в event loop прямо зараз». Подальший await просто чекає на сигнал про завершення.
Одна конкретна корисна звичка: щоразу, коли ви присвоюєте виклик асинхронної функції змінній без await, запитайте себе, чи запланували ви її виконання. Якщо права частина не обгорнута в create_task, gather або TaskGroup, вона не виконується. Це просто «рецепт», що лежить на стільниці.
Висновок
Асинхронне середовище виконання Python є потужним, але воно вимагає чіткого наміру. Мова не запускає фонову роботу лише тому, що ви викликали функцію. Якщо ви переходите з JavaScript, перевірте кожне місце, де ви зберігаєте корутину у змінній, а потім чекаєте на неї через await. Якщо ви спочатку не перетворили її на Task, ви написали послідовний код, одягнений в асинхронне вбрання. Починайте роботу з Task, а потім чекайте на результати. Саме так ви перетворите асинхронність Python із прихованого вузького місця на справжній інструмент конкурентності.
