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.

Спасибо за внимание, пишите в комментах ваши замечания!

Комментарии (8)


  1. Hellpain
    12.06.2026 13:53

    очень крутая статья! узнал много нового!


  1. akardapolov
    12.06.2026 13:53

    Про Kotlin Coroutines добавить бы раздел, для полноты картины (они же в ветке Stackless тоже).


  1. sdramare
    12.06.2026 13:53

    На каком еще стеке вызывающего - в 99% future будет обернут в таску токио и улетит в кучу, откуда waker её и будет шедулить. В целом сравнение Future раста и тех же Task в .net просто не корректно, потому что сами по future сами по себе не могу исполняться, это только часть async модели


  1. cpud47
    12.06.2026 13:53

    Потому что в расте миллион объектов могут (в среднем будут) весить меньше чем сто тысяч в Питоне, не?

    Просто напросто в расте в целом чуть оптимальнее используется память, т.к. меньше индерекций, плюс нет заголовков объектов*. Всё остальное не имеет особого значения.

    Примечание*: про rc/arc немного другой разговор.

    И Вы не совсем правы по поводу того, что все 4 упомянутых языка реализуют async fn как стейт-машину. Полностью как стейт-машину их реализует только раст и питон. C# и js используют гибридный подход: одну функцию они реализуют через стейт-машину, но вызов другой функции они обрабатывают через коллбэки.


  1. monah_tuk
    12.06.2026 13:53

    А что в C++?


    1. EvilMan
      12.06.2026 13:53

      В зависимости от того, какую асинхронность использовать (стекфул или стеклесс) будет так же: стекфул, естественно, будет требовать памяти гораздо больше.


      1. monah_tuk
        12.06.2026 13:53

        Конечно, больше интересует, что в самом языке. Стеклесс из C++20.


  1. vvdev
    12.06.2026 13:53

    В .NET есть стандартный механизм AsyncMethodBuilder, который в том числе можно использовать и для пулинга стейтмашин - для ValueTask из коробки есть PoolingAsyncValueTaskMethodBuilder, для Task - можно заморочиться и написать свой пул+МетодБилдер.

    Для ValueTask можно написать свой переиспользуемый IValueTaskSource на базе встроенного ManualResetValueTaskSourceCore.

    С использованием всего этого можно очень, прямо вот ОЧЕНЬ серьёзно снизить количество и объём аллокаций.

    Ну и NET 11 с Runtime Async не забываем.