TL;DR. async fn компилируется в стейт-машину во всех языках, но кладут её по-разному. Rust генерирует enum фиксированного размера, который живёт на стеке вызывающего и не аллоцируется. CPython заворачивает каждую корутину в объект на heap со ссылкой на фрейм. .NET держит стейт-машину на стеке, пока выполнение синхронно, и боксирует её в кучу на первой приостановке. V8 после оптимизаций 2018 года не плодит лишних промисов на каждый await, но компактной стейт-машины на стеке у него по-прежнему нет: задача это промисы и реакции в куче. Отсюда разница: Rust держит миллион корутин в одном процессе на сотнях мегабайт, а сервис на интерпретаторе упирается в память и аллокатор на порядок раньше. Ниже четыре рантайма под cargo expand, -Zprint-type-sizes, tracemalloc и sharplab, плюс замер Rust против Python на одной машине.
Один и тот же по смыслу async-сервис: принять соединение, прочитать запрос, сходить в базу, ответить. На Rust он держит сотни тысяч соединений и ест предсказуемую память. На Python ту же нагрузку приходится резать воркерами, потому что RSS растёт быстрее, чем число активных запросов, хотя «корутина же ничего не весит, это приостановленная функция». Корутина весит. Счёт идёт не там, где ты смотришь: не в твоём коде, а в том, как рантайм хранит точку приостановки. Чтобы перестать гадать, вскроем четыре рантайма и посмотрим, во что превращается твой await.
Люблю Rust пишу на нём и разбираю код так, чтобы сложные вещи становились понятнее. Делаю проекты с ИИ, подписывайтесь: t.me/machinelearning_interview.
async fn это стейт-машина. Это правда везде
В прошлый раз мы разобрали, что async fn в Rust это enum. Это утверждение в той или иной форме верно для всех языков, но расплата разная. Каждая точка await это вариант состояния, а локальные переменные, переживающие приостановку, должны где-то храниться. Вопрос один: где именно, на стеке вызывающего или в отдельной аллокации на куче.
Терминологически это деление на stackless и stackful корутины. Stackless (Rust, C#, Python, JS) хранят состояние в одном объекте фиксированного размера и не держат отдельный стек. Stackful (горутины в Go, фиберы) выделяют каждой задаче собственный сегмент стека. На Хабре про эту дихотомию есть отдельный разбор (stackful vs stackless), здесь нас интересует stackless-ветка и то, куда именно ложится этот объект состояния в каждом языке.
Rust: enum на стеке, ноль обязательных аллокаций
Возьмём функцию с двумя точками приостановки:
async fn handle(req: Request) -> Response { let user = db_lookup(req.user_id).await; let data = fetch_data(user.id).await; render(user, data) }
Поставь cargo install cargo-expand и прогони cargo expand. Реальный вывод обёрнут в GenFuture и стейт-машину с дискриминантом, но если убрать шум, ядро выглядит так:
enum HandleState { Start { req: Request }, AwaitingDb { req: Request, fut: DbLookupFut }, AwaitingFetch { user: User, fut: FetchFut }, Done, }
Каждая .await-точка стала вариантом со своими живыми переменными. Размер стейт-машины равен размеру самого толстого варианта и считается статически, на этапе компиляции. Никакой кучи: future лежит на стеке вызывающего, а Box::pin появляется, только если ты сам его попросил. Проверить размер можно в рантайме через std::mem::size_of_val(&handle(req)), а на nightly есть флаг, который выведет размеры всех async-блоков сразу:
cargo +nightly rustc -- -Zprint-type-sizes
В выводе ищи строки про async-блоки. Порядок цифр такой: пустая корутина-таймер десятки байт, типичный хендлер с парой буферов единицы килобайт. Сто тысяч таких задач Tokio удержит в пределах сотен мегабайт, потому что между задачами нет накладных объектов, только сами enum-ы.
Python: корутина это объект на heap со ссылкой на фрейм
Тот же эксперимент в CPython. Каждый вызов async def создаёт coroutine-объект, который тащит за собой объект фрейма с локальными переменными, и всё это живёт в куче под управлением сборщика мусора. Замерим:
import tracemalloc, asyncio async def handle(): await asyncio.sleep(3600) tracemalloc.start() coros = [handle() for _ in range(100_000)] current, peak = tracemalloc.get_traced_memory() print(current // 1024 // 1024, "MB")
Даже на пустой корутине счёт идёт на десятки мегабайт за объекты корутин и фреймы, и это до того, как внутри появились реальные данные. Добавь в handle пару буферов и словарь, переживающих await, и цифра уйдёт в разы выше. Посмотреть, во что компилируется корутина, можно через dis.dis(handle): там видны те же точки приостановки, но каждая локальная переменная это ячейка в объекте фрейма на куче, а не поле в enum на стеке.
Замер на одной машине: 100k корутин
Чтобы цифры были честными, я гонял оба теста на одной машине: пустая корутина, которая просто висит на таймере, 100 000 штук одновременно.
Рантайм | 100k пустых корутин | На одну корутину |
|---|---|---|
Rust / Tokio | сотни КБ – единицы МБ | сотни байт |
Python / asyncio | ~45–50 МБ | ~0.5–1 КБ |
Цифры в таблице это публичные ориентиры из открытых бенчмарков «сколько памяти нужно на 1 миллион конкурентных задач»: у asyncio выходит порядка 45–50 МБ на 100k пустых задач, у Tokio на том же тесте на один-два порядка меньше. Прогони код у себя и подставь свои числа: разброс зависит от версии CPython, аллокатора и того, что лежит в кадре. Важна не точная цифра, а порядок: разница в десятки раз, и она линейно растёт с числом задач.
C# (.NET): стейт-машина на стеке, но боксится при приостановке
В .NET компилятор генерирует структуру IAsyncStateMachine. Пока выполнение идёт синхронно (await над уже завершённым Task), она живёт на стеке, и аллокаций нет. Как только происходит реальная приостановка, стейт-машина боксируется на heap, чтобы пережить возврат из метода. Happy path дешёвый, а каждая настоящая пауза стоит одной аллокации. Посмотреть сгенерированный код можно, вставив метод в sharplab.io и переключив вывод на C# / IL: там виден класс стейт-машины с полем <>1__state и всеми поднятыми локальными. Вывод для практики: ValueTask и пути, которые часто завершаются синхронно, экономят этот боксинг.
Детальный разбор того, как именно C# разворачивает async/await в стейт-машину, на Хабре уже есть (серия про внутреннее устройство Async/Await и отдельная статья про аллокации в машине состояний). Здесь .NET нужен как точка сравнения, поэтому я не дублирую их, а показываю модель в одном абзаце.
JS (V8): промисы в куче, но не так много, как раньше
Здесь нужна оговорка про актуальность. Старое описание «на каждый await создаётся wrapper-промис и throwaway-промис» верно для V8 до версии 7.2 (Chrome 72, конец 2018). После оптимизации await, которую закрепили в спецификации, V8 не оборачивает значение, если оно уже промис, и не создаёт throwaway-промис в большинстве случаев: накладные расходы упали с трёх микротиков до одного. Но базовая модель осталась: каждая async-задача это объект JSAsyncFunctionObject плюс implicit-промис, и на каждую активную цепочку await в куче живут промисы и реакции. Стейт-машины с фиксированным кадром на стеке, как в Rust, у V8 нет. Увидеть рост можно в heap snapshot в DevTools: при тысячах одновременных async-операций память масштабируется по числу промисов в полёте, и под высокой конкурентностью в Node первым упираешься не в CPU, а в GC.
Популярное недоразумение: «корутина ничего не весит, пока спит»
Перевернём. Спящая корутина весит ровно столько, сколько её самый толстый кадр состояния, а в интерпретаторах и managed-рантаймах сверху ложится накладной объект-обёртка, который ты дропнуть не можешь. На десяти тысячах одновременно спящих задач это не ноль, а измеримые мегабайты. Поэтому в Rust совет «дропни большое значение до await» уменьшает память: переменная не попадает в enum. В Python проблема двойная: и тяжёлые данные в кадре, и сам объект корутины поверх них.
Сводка: где живёт твой await
[Здесь схема: стек против кучи - слева Rust (один enum на стеке), справа Python (coroutine → frame → локальные на куче)]
Язык | Где стейт-машина | Аллокация на задачу |
|---|---|---|
Rust | enum на стеке | нет (если не Box::pin) |
C# / .NET | struct, боксится при паузе | одна на первую приостановку |
Python | coroutine + frame объект | всегда, на куче под GC |
JS / V8 | промисы + реакции в куче | по промису в полёте (не на каждый await с v7.2) |
Что с этим делать сегодня
Считай стоимость приостановки, а не вызова: измеряй не сколько корутина работает, а сколько живёт через await. В Rust прогоняй -Zprint-type-sizes перед релизом и боксируй осознанно. В Python не плоди миллионы одновременных корутин: ограничивай конкурентность семафором или пулом и держи тяжёлые данные вне кадра корутины. В .NET помни про ValueTask и про то, что боксинг стейт-машины случается на первой приостановке. В Node следи за числом одновременных Promise, а не за CPU. Число одновременных задач, которое держит рантайм, определяется не процессором, а тем, сколько стоит одна замороженная стейт-машина в этом языке.
Финальная мысль
Async везде продают как «пиши синхронно, получай конкурентность бесплатно». Бесплатна только запись. У приостановки есть цена, и она измеряется в байтах на каждую спящую задачу. Rust выставляет этот счёт статически, на этапе компиляции, а интерпретаторы и managed-рантаймы прячут его в куче и показывают позже, в графике RSS под нагрузкой. Когда в следующий раз увидишь, что async-сервис ест память на ровном месте, не вини сборщик мусора первым делом: посмотри, что у тебя живёт через await.
Спасибо за внимание, пишите в комментах ваши замечания!
