Зміст
Коротко
Один і той самий async-сервіс на Rust тримає сотні тисяч з'єднань із передбачуваним споживанням пам'яті процесу, а на Python ту саму навантаження доводиться різати воркерами. На Habr розібрали, де фізично лежить стан призупиненої задачі — на стеку чи в кучі — і порівняли Tokio, asyncio, .NET та V8.
Що сталося
Автор бере типовий хендлер: прийняти з'єднання, прочитати запит, сходити в базу, відповісти. У кожній мові async fn компілюється в стейт-машину: кожна точка await — варіант стану; локальні змінні, що переживають паузу, мають десь зберігатися — стек викликача або окрема алокація в кучі.
У Rust cargo expand показує enum на кшталт HandleState із варіантами Start, AwaitingDb, AwaitingFetch. Розмір рахується статично; future лежить на стеку, доки ви самі не попросите Box::pin. Порожня корутина-таймер — десятки байт; типовий хендлер з буферами — одиниці кілобайт.
У CPython кожен async def створює об'єкт корутини та фрейм із локальними змінними в кучі під керуванням збирача сміття. Замір на 100 000 порожніх корутин на asyncio.sleep дає порядку 45–50 МБ — ще до реальних даних всередині.
C# тримає IAsyncStateMachine на стеку, поки виконання синхронне, але боксує в кучу на першій реальній паузі — одна алокація на задачу. JavaScript (V8) після оптимізацій 2018 року не плодить зайвих промісів на кожен await, але компактної стейт-машини на стеку немає: задача — це проміси та реакції в кучі.
| Рантайм | 100k порожніх корутин | На одну задачу |
|---|---|---|
| Rust / Tokio | сотні КБ – одиниці МБ | сотні байт |
| Python / asyncio | ~45–50 МБ | ~0,5–1 КБ |
Чому це важливо
Міф «корутина нічого не важить, поки спить» ламається на масштабі. Спляча задача «важить» стільки, скільки її найтовстіший кадр стану, плюс обгортка рантайму, яку ви не контролюєте. На десяти тисячах одночасно «сплячих» з'єднань це вже мегабайти, а не шум.
Для архітектури це означає: вибір мови та рантайму для висококонкурентного I/O — не лише про «Rust швидший на CPU». Це про лінійне зростання RSS із числом одночасних await. Сервіс на інтерпретаторі вперто в пам'ять і алокатор на порядок раніше, ніж у процесор.
У Rust порада «скинь важке значення до await» напряму зменшує enum. У Python проблема подвійна: і дані в кадрі, і сам об'єкт корутини зверху.
На практиці
- Рахуйте вартість призупинення, а не виклику: скільки байт живе через кожен
await. - У Rust перед релізом дивіться розміри async-блоків;
Box::pin— свідомий вибір. - У Python обмежуйте конкурентність семафором або пулом; не тримайте мільйони одночасних корутин «про всяк випадок».
- У .NET для часто синхронно завершуваних шляхів дивіться на ValueTask — менше боксингу стейт-машини.
- У Node.js під навантаженням часто першим упираєтесь у GC і число промісів у польоті, а не в CPU.
- При проєктуванні лімітів з'єднань закладайте пам'ять на задачу для вашого рантайму, а не лише дескриптори файлів.
Підсумок
Async продають як «пиши синхронно, отримуй конкурентність безкоштовно». Безкоштовна лише запис — у пауз є ціна в байтах на сплячу задачу. Rust виставляє рахунок на етапі компіляції; Python і керовані рантайми ховають його в кучі й показують пізніше на графіку споживання пам'яті. Коли async-сервіс «їсть пам'ять нізвідки», спочатку подивіться, що живе через await.