Разбираем, почему async Rust генерирует лишние state machine, как это бьёт по embedded и WASM, и что можно сделать в коде прямо сейчас.— Читать дальше «Async Rust: как уменьшить бинарник, пока rustc не оптимизирован»
Асинхронный Rust обещал нам zero-cost abstraction: пишете почти как на обычном синхронном языке, а runtime сам разбирается с конкурентностью. На сервере с десятками гигабайт RAM это обещание обычно сбывается. Но стоит перейти к embedded-прошивке на 256 КБ flash или к WebAssembly-модулю, который гонится за каждым килобайтом, — и картина меняется. Функция с двумя await-точками превращается в state machine из 360 строк MIR, тогда как синхронный аналог укладывается в 23. Почему так происходит и что с этим делать уже сегодня?
Тема получила новый импульс после серии публикаций Диона Доктера (Dion Dokter) из голландской компании Tweede golf. Во второй части он заглядывает внутрь rustc и утверждает: async Rust так и не вышел из состояния MVP. Я не буду дословно переводить его рассуждения, а переосмыслю их с упором на практику: как понять, платите ли вы за async-удобство лишними байтами, и как снизить налог, пока компилятор не научился делать это сам.
Что не так с async Rust и как это обойти в своём коде
Когда вы пишете async fn, компилятор не просто оборачивает тело в callback. Он строит конечный автомат: enum, где каждый вариант — состояние между await-точками. Это называется coroutine desugaring и происходит на уровне MIR (Mid-level IR) — до того, как код попадает в LLVM.
Рассмотрим минимальный пример. Есть две функции, каждая из которых просто возвращает число, и третья складывает результаты:
fn foo() -> impl Future<Output = i32> {
async { 5 }
}
fn bar() -> impl Future<Output = i32> {
async {
foo().await + foo().await
}
}
У bar две await-точки, поэтому минимум два пользовательских состояния. Но если сдампить MIR после прохода coroutine_resume, картина шире:
variant_fields: {
Unresumed(0): [], // старт
Returned (1): [], // future уже завершилась
Panicked (2): [], // future упала с panic и его поймали
Suspend0 (3): [_s1], // на 1-м await: храним первую foo-future
Suspend1 (4): [_s0, _s2], // на 2-м await: результат + вторая foo-future
}
Три служебных состояния добавляются всегда. Они нужны, чтобы соблюсти контракт Future::poll: опрос завершённой future не должен приводить к неопределённому поведению. Поэтому после первого Ready future переходит в Returned, а повторный poll паникует. Аналогично Panicked блокирует future после пойманного panic — похоже на poisoning мьютекса.
Логика корректная, но цена высокая: bar порождает 360 строк MIR, а эквивалентный синхронный код — 23. В 15 с лишним раз больше. LLVM часть этого выбросит, но на opt-level = "z" или в толстых async-графах вызовов он быстро сдаётся.
Контракт future требует только отсутствия UB. Паниковать при повторном poll — не обязательно. Можно просто вернуть Pending снова: ничего опасного не произойдёт, а ветка panic — это лишний побочный эффект, который плохо оптимизируется.
Дион сделал экспериментальный патч rustc: в release-режиме Returned больше не паникует. На embedded-прошивках это дало 2–5% экономии бинарного размера. В debug-сборках панику можно оставить, чтобы быстро ловить ошибочный повторный poll, — по аналогии с overflow-checks.
Взгляните на функцию без единой await-точки:
async fn foo() -> i32 {
5
}
Идеальная реализация — всегда возвращать Poll::Ready(5) безо всякого enum. А rustc генерирует полноценный CoroutineLayout с тремя служебными состояниями и switch по дискриминанту. Такие функции часто появляются в трейтах, где один интерфейс должен быть async, но конкретная реализация ничего не ждёт. Патч, убирающий state machine у async-блоков без await, даёт ещё 0,2% размера — мало, но правка тривиальная.
Классический шаблон: адаптер просто пересылает вызов вниз.
async fn foo(blah: SomeType) -> OtherType {
// реальная работа
}
async fn bar(blah: SomeType) -> OtherType {
foo(blah).await
}
Сейчас bar получает собственный state machine, который вызывает state machine foo. Вручную мы бы написали fn bar(blah) -> impl Future { foo(blah) } и избавились бы от лишнего enum. Аналогично с преамбулой и постамбулой: их можно перенести на FutureExt::map из крейта futures.
Если в async-функции несколько веток match ведут к одинаковому await, компилятор создаёт отдельное состояние на каждую ветку:
pub async fn process_command() {
match get_command() {
CommandId::A => send_response(123).await,
CommandId::B => send_response(456).await,
}
}
Здесь send_response вызывается с разными аргументами, но структура состояний дублируется. Если вынести выбор аргумента за await, получится одно состояние вместо двух:
pub async fn process_command() {
let response = match get_command() {
CommandId::A => 123,
CommandId::B => 456,
};
send_response(response).await;
}
В одном примере из статьи MIR сокращается с 456 до 302 строк, а ассемблер — примерно на 11%. Оптимизации stack, поэтому выигрыш в реальном коде может быть заметнее.
Всё перечисленное выше — это работа для команды компилятора. Но релиз с этими патчами может занять месяцы, а то и годы. Пока он не вышел, можно снизить async-bloat в своём коде.
Главный принцип: каждый лишний async fn — это лишний state machine. Если функция не содержит await, не делайте её async.Типичный случай — трейт, где одна реализация реально ждёт ввода-вывода, а другая просто возвращает значение.
impl ConfigLoader for DefaultLoader {
fn load(&mut self) -> impl Future<Output = Result<Config, Error>> {
std::future::ready(Ok(Config::new()))
}
}
Вместо полноценного state machine получается готовая future, которая сразу возвращает Ready. Это особенно полезно в embedded-HAL, где трейты часто async, но конкретный драйвер может делать только прямой доступ к регистрам.
Если функция только пересылает await вниз, уберите await:
// Плохо: лишний state machine
async fn transaction(&mut self, address: u8, ops: &mut [Operation]) -> Result<(), Error> {
self.driver.transaction(address, ops).await
}
// Лучше: просто возвращаем чужую future
fn transaction(&mut self, address: u8, ops: &mut [Operation]) -> impl Future<Output = Result<(), Error>> {
self.driver.transaction(address, ops)
}
Если несколько веток match заканчиваются одним и тем же await, вынесите выбор аргументов наружу. Это уменьшает число состояний state machine и облегчает жизнь LLVM.
Async-функция захватывает в state machine всё, что живёт через await. Если передать массив по значению, он окажется внутри future целиком:
async fn foo_big(mut buffer: [u8; 1024]) {
let result = fill_async(&mut buffer).await;
println!("{result}");
}
async fn foo_small(buffer: &mut [u8; 1024]) {
let result = fill_async(buffer).await;
println!("{result}");
}
// size_of_val(&foo_big([0; 1024])) == 2080
// size_of_val(&foo_small(&mut [0; 1024])) == 40
Разница в 52 раза по размеру future — и это без учёта memcpy, который компилятор вынужден вставлять при перемещении больших значений.
Дион оформил эти идеи как Rust Project Goal — формальный механизм, через который команды заявляют цели на полугодие. По его оценке, работа требует порядка €30 000 финансирования. Для компиляторного проекта это скромная сумма: речь идёт о 2–5% размера прошивки практически в любом async-проекте.
Есть и другой подход к той же проблеме: не убирать state machine на входе, а научить LLVM лучше их оптимизировать на выходе. Дион считает, что оба направления дополняют друг друга. Чем проще state machine попадает в LLVM, тем эффективнее её можно проинлайнить и упростить на поздних проходах.
Часто задаваемые вопросы
1
Почему эта проблема критична именно для embedded?
На сервере лишние килобайты теряются в гигабайтах RAM и в L3-кэше. На микроконтроллере с 256 КБ flash 2–5% — это 5–13 КБ, которые могут определить, влезет ли прошивка. Кроме того, embedded часто использует оптимизацию по размеру, где LLVM менее агрессивен, чем при оптимизации по скорости.
2
Можно ли просто отказаться от async Rust на embedded?
Можно, и многие так делают. Но async даёт удобную конкурентность без RTOS, позволяет писать executor-agnostic код и упрощает тестирование. Альтернатива — ручные state machine или блокирующий код с прерываниями. Выбор зависит от размера прошивки, сложности логики и команды.
3
Почему LLVM не убирает эти излишки сам?
Потому что desugaring async в state machine происходит в rustc до LLVM. К моменту, когда LLVM видит код, уже сформированы enum состояний, panic-ветки и вызовы между futures. LLVM не может «понять», что две функции можно было бы слить в одну future, потому что эта информация потерялась на этапе MIR.
4
Как быстро проверить, страдает ли мой проект?
Измерьте размер future через std::mem::size_of_val, сравните async и синхронный вариант, посмотрите MIR с помощью rustc --emit=mir или Godbolt. Если прошивка больше ожидаемого, ищите async-функции без await, прозрачные обёртки и большие захваченные значения.
5
Когда ждать исправлений в rustc?
Project Goal — это план, а не гарантия. Без финансирования автор может уделять проекту лишь несколько часов в неделю. Если найдутся спонсоры, работа пойдёт быстрее. Практические патчи, скорее всего, появятся не раньше чем через несколько release-циклов Rust.
Async Rust never left the MVP state. The compiler generates state machines with a lot of unnecessary baggage. With a few targeted optimizations in rustc we can get smaller binaries and better performance for everyone.
Dion Dokterembedded software engineer, Tweede golf
Утверждение «async Rust так и не вышел из MVP» звучит резко, но поясняет, почему наши «zero-cost» абстракции иногда всё-таки стоят дорого. Компилятор делает корректный, но не оптимальный код: лишние panic-ветки, неинлайненные futures, дублирующиеся состояния и state machine там, где они не нужны.
Для embedded и WASM это не абстрактная проблема, а конкретные килобайты прошивки. Пока rustc учится, разработчик может снизить налог вручную: убирать async у функций без await, возвращать impl Future из прозрачных обёрток, схлопывать await-точки и не передавать большие структуры по значению.
Если ваш проект на Rust бьётся о лимит flash, имеет смысл посмотреть на async-граф вызовов свежим взглядом. Часто проще убрать одну лишнюю async-обёртку, чем месяцами ждать патч в компиляторе.
Источники:
— Dion Dokter, «Async Rust never left the MVP state»;
— Dion Dokter, «Debloat your async Rust»;
— Rust Project Goal: Async state machine optimisation.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как усыпить Python: полный гид по time.sleep(), asyncio.sleep() и паузам в потоках | 0 | 7 | 25-06-2026 |
| 2 | Как ограничить расходы на OpenAI API, чтобы ИИ-агенты не сожгли бюджет | 0 | 8 | 04-07-2026 |
| 3 | ИИ для автоматизации РКО. Как финансовые организации сокращают расходы и убирают ручной ввод документов | 5 | 7 | 01-07-2026 |
| 4 | Аварийное восстановление до аварии: как настроить DRaaS, пока ничего не упал | 5 | 8 | 02-07-2026 |
| 5 | Как выбрать VPS/VDS под свой проект: гайд по параметрам и 6 провайдеров | 0 | 8 | 30-06-2026 |
| 6 | Технический долг в деньгах: как считать ROI рефакторинга легаси-системы | 0 | 7 | 30-06-2026 |
| 7 | Deckhouse Conf 2026: зачем инженерам самописный SDN и виртуалки в Kubernetes | 0 | 5 | 02-07-2026 |
| 8 | Как я ужал NixOS ISO с 458 МБ до 183 МБ | 0 | 7 | 29-06-2026 |
| 9 | Почему классический CI/CD не справляется с LLM (и какие release gates мы построили, чтобы это исправить) | 0 | 7 | 04-07-2026 |
| 10 | AI-агенты сломали управление жизненным циклом идентификации. Что с этим делать | 0 | 7 | 04-07-2026 |