Содержание
Коротко
Один и тот же 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-блоков (флаг
-Zprint-type-sizesв nightly-сборках);Box::pin— осознанный выбор. - В Python ограничивайте конкурентность семафором или пулом; не держите миллионы одновременных корутин «на всякий случай».
- В .NET для часто синхронно завершающихся путей смотрите на ValueTask — меньше боксинга стейт-машины.
- В Node.js под нагрузкой первым часто упираетесь в сборщик мусора и число промисов в полёте, а не в CPU — смотрите снимок кучи в DevTools.
- При проектировании лимитов соединений закладывайте память на задачу из таблицы вашего рантайма, а не только дескрипторы файлов.
Итог
Async продают как «пиши синхронно, получай конкурентность бесплатно». Бесплатна только запись — у паузы есть цена в байтах на спящую задачу. Rust выставляет счёт на этапе компиляции; Python и управляемые рантаймы прячут его в куче и показывают позже в графике RSS. Когда async-сервис «ест память на ровном месте», сначала смотрите, что живёт через await.