Async Rust до сих пор в MVP: четыре оптимизации компилятора
Async Rust обещали как zero-cost abstraction, но bloat в бинарнике остался. Tweede golf разбирает, что именно компилятор генерирует лишнего, и предлагает четыре оптимизации — с цифрами 2–5% и Project Goal в rustc на €30 000.
Async Rust продавали как «zero cost abstraction» — обещание, что асинхронный код по размеру и скорости должен быть равен ручному state machine на синхронном Rust. На бумаге это даёт executor-agnostic код (один и тот же исходник запускается под Tokio на сервере и под Embassy на микроконтроллере), на практике — на embedded особенно — видно: каждый байт прошивки на счету, а async добавляет десятки и сотни лишних строк сгенерированного кода. Команда из голландской Rust-консалтинговой компании Tweede golf разобрала, откуда берётся этот bloat и как его срезать на уровне компилятора — с конкретными цифрами и Rust Project Goal.
Это вторая часть серии. В первой — обходные приёмы для async-кода прямо в пользовательском коде. Во второй — автор копает в MIR (Mid-level Intermediate Representation — внутреннее представление rustc между исходником и LLVM IR) и формулирует, что должен научиться делать сам компилятор. Цель — не лечить симптомы, а добраться до корня и улучшить генерацию futures.
Главное
Ключевые выводы
Что не так с async Rust и как это чинить в компиляторе
- Async-функция в Rust разворачивается в state machine: компилятор генерирует enum с одним состоянием на каждую точку await плюс три служебных — Unresumed (старт), Returned (после завершения) и Panicked (после пойманного catch_unwind).
- Функция с двумя await-точками у автора разворачивается в 360 строк MIR против 23 у синхронного аналога — больше чем в 15 раз. Часть этого LLVM срежет, но не всё.
- Замена panic! в состоянии Returned на возврат Pending в release-сборках даёт 2–5% экономии размера прошивки на embedded.
- Ещё четыре оптимизации: убрать state machine у async-блоков без await-точек, заинлайнить futures с одним await, схлопнуть одинаковые состояния из ветвей match, под panic=abort вообще убрать состояние Panicked.
- Автор подал Rust Project Goal и оценил работу примерно в €30 000 финансирования.
Что компилятор делает с async-блоком
Возьмём простой пример из статьи:
У bar два await-points — значит, в state machine минимум два состояния. Просим компилятор сдампить MIR на этапе coroutine_resume (последний async-специфичный pass) и видим:
Returned и Panicked — служебные. Future::poll — безопасная функция, и опросить уже завершённую future нельзя так, чтобы получить undefined behaviour. Поэтому компилятор после первого Ready переключает future в Returned, и при повторном poll она паникует. Похожая логика для Panicked: после пойманного unwind future блокируется от повторного poll, иначе можно дёрнуть её в неконсистентном состоянии — автор сам отмечает, что точная документация по этому состоянию ему не нашлась, и сравнивает механизм с mutex poisoning.
Логично, но автор замечает: bar генерирует 360 строк MIR, а синхронный аналог — 23 строки. То есть в 15+ раз больше. Часть этого добавления LLVM оптимизирует позже, но не всё — и на embedded остаток ощутимо влияет на размер прошивки.
Оптимизация 1: вместо panic — снова Pending
Future в состоянии Returned обязана не вызывать UB. А не обязана паниковать. Можно вместо panic! просто возвращать Pending: контракт Future не нарушается, ничего опасного не происходит.
Panic — относительно дорого: добавляется ветка с побочным эффектом, которая плохо оптимизируется. Автор пропатчил компилятор и измерил: 2–5% сокращение размера бинарника для async-прошивок на embedded.
Идеальное место — флаг по аналогии с overflow-checks = false: в debug-сборке оставляем panic, чтобы быстро ловить ошибочный повторный poll, в release — экономим. Аналогично, при panic = abort состояние Panicked потенциально можно убрать целиком — автор хочет это отдельно исследовать.
Оптимизация 2: лишняя state machine у пустого async-блока
Возвращаемся к foo: там нет ни одного await-point, future просто возвращает число. Логично было бы реализовать вручную как:
Никакого состояния не нужно — просто вернуть 5. На деле компилятор генерирует CoroutineLayout с тремя вариантами (Unresumed, Returned, Panicked) и честный switch по дискриминанту перед каждым возвратом — даже там, где без него можно было обойтись. Когда await-точек ноль, state machine можно убирать целиком — это около 0,2% размера бинарника. Поведение чуть меняется только для неконформных executor-ов: future всегда возвращает Ready без переходов между состояниями.
Ещё две оптимизации в плане работ
- Inlining futures с одним await-point. В таких future внутреннее состояние сводится к Suspend0 + Returned + Panicked, а ветка управления при правильной работе компилятора схлопывается в обычный вызов с одним переключением.
- Схлопывание идентичных состояний из ветвей match. Если в функции несколько await-точек попадают в одинаковые по смыслу варианты (одни и те же сохранённые поля), их можно объединить в одно состояние state machine — без потери семантики.
Каждый из этих кейсов даёт меньше, чем замена panic на Pending, но вместе с ней образуют связный набор патчей в компилятор. Автор формулирует план так: вынести проблему из «каждая embedded-команда со своими обходными приёмами» в «исправление в rustc раз и навсегда».
Project Goal и финансирование
Изменения требуют времени на проектирование, обсуждение в Rust-проекте и собственно правки. Автор оформил это как Project Goal по async state machine optimisation и оценил необходимое финансирование примерно в €30 000 — в основном на работу embedded-инженера над патчами компилятора. Платит обычно либо сам Tweede golf, либо Rust Foundation через программу грантов, либо отдельный спонсор; Project Goal — публичный механизм, чтобы заявить цель и собрать поддержку, в том числе финансовую. По меркам компиляторных проектов это недорого: типичная цена «уберём 2–5% размера прошивки в любом async-проекте» — десятки тысяч евро.
Это вторая часть серии. В первой я показывал, что вы можете сделать в своём async-коде, чтобы избежать части bloat. Во второй мы лезем во внутренности и переводим методы из первой части в оптимизации для компилятора.
Частые вопросы
Это касается только embedded или серверного кода тоже?
Bloat одинаково присутствует на серверах, десктопах и микроконтроллерах. На больших машинах он растворяется в гигабайтах RAM и компиляторных оптимизациях LLVM, на embedded — упирается в килобайты flash. Поэтому на микроконтроллерах об этом громко говорят, а на серверах редко обращают внимание.
В чём суть состояний Returned и Panicked?
Future::poll должна оставаться безопасной даже если future уже завершилась или упала. Чтобы при повторном poll не дёрнуть код в неконсистентном состоянии, компилятор переводит future в одно из терминальных состояний и при следующем poll паникует. Логика корректная, но цена в коде сейчас выше, чем нужно.
Почему нельзя просто отключить state machine для пустых async-блоков?
Технически можно, но требует поддержки на уровне rustc и аккуратной проверки, что не ломаются гарантии Future. Текущий компилятор генерирует state machine унифицированно, без специального случая для «нет await-точек» — это упрощает реализацию, но ценой размера.
2–5% размера — это много или мало?
Для серверного кода — пренебрежимо. Для embedded-firmware с прошивкой в 256 КБ flash «2–5%» это 5–13 КБ. На целевых для async Rust микроконтроллерах — STM32L0 (32–192 КБ flash), nRF52810 (192 КБ flash), младших ESP32 — эти килобайты определяют, влезет ли async-логика рядом с драйверами и протоколом обмена.
Что значит подать Project Goal в Rust?
Project Goals — формальный механизм Rust-проекта, в котором команды и компании предлагают цели на полугодие или год. Если цель принимают, под неё выделяют слот в дорожной карте, рецензентов и иногда финансирование. Подача goal не означает автоматического обязательства: это начало обсуждения.
Выводы
Async в Rust — давно главный болевой пункт у embedded-сообщества: обещания zero cost не подтверждаются, а обходные приёмы на уровне пользовательского кода сильно ограничены. Tweede golf формулирует разговор иначе: показывает, какие именно паттерны генерации виноваты, и предлагает их закрывать в компиляторе.
Для практика это значит две вещи. Первое — писать async-код можно не оглядываясь на «не сделать ли я ещё одно состояние»; bloat лежит в компиляторе, и просьба к нему — править генерацию, а не ваши async fn. Второе — следить за статусом Project Goal: если его подхватят, размер async-кода в release-сборках уменьшится у всех пользователей сразу, без правок в их репозиториях.
Оригинал — на tweedegolf.nl/en/blog/237/async-rust-never-left-the-mvp-state. Первая часть серии — «Debloat your async Rust». Project Goal лежит на rust-project-goals/2026/async-statemachine-optimisation.