Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

Async Rust: как уменьшить бинарник, пока rustc не оптимизирован

Дата публикации: 29-06-2026 13:21:00

Разбираем, почему 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 и как это обойти в своём коде

  • Асинхронный Rust компилируется в state machine: на каждую await-точку приходится отдельное состояние плюс служебные Unresumed, Returned и Panicked.
  • Простая функция с двумя await-точками даёт 360 строк MIR против 23 у синхронного кода. Часть излишков LLVM убирает, но не все, особенно при оптимизации по размеру.
  • Компилятор не схлопывает идентичные состояния, не инлайнит futures через await, генерирует state machine даже для async-блоков без await и оставляет panic-путь в состоянии Returned.
  • В release-сборках замена panic в Returned на возврат Pending может сократить прошивку на 2–5%. Убирание state machine у пустых async-блоков — ещё около 0,2%.
  • Пока rustc не исправлен, можно вручную: возвращать impl Future из «прозрачных» функций, использовать std::future::ready, схлопывать await-точки через match и передавать большие данные по ссылке.

Почему async-функция — это всегда state machine

Когда вы пишете 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-графах вызовов он быстро сдаётся.

Четыре места, где компилятор работает вхолостую

1. Panic в состоянии Returned

Контракт future требует только отсутствия UB. Паниковать при повторном poll — не обязательно. Можно просто вернуть Pending снова: ничего опасного не произойдёт, а ветка panic — это лишний побочный эффект, который плохо оптимизируется.

Дион сделал экспериментальный патч rustc: в release-режиме Returned больше не паникует. На embedded-прошивках это дало 2–5% экономии бинарного размера. В debug-сборках панику можно оставить, чтобы быстро ловить ошибочный повторный poll, — по аналогии с overflow-checks.

2. State machine без await

Взгляните на функцию без единой await-точки:

			async fn foo() -> i32 {
    5
}
		

Идеальная реализация — всегда возвращать Poll::Ready(5) безо всякого enum. А rustc генерирует полноценный CoroutineLayout с тремя служебными состояниями и switch по дискриминанту. Такие функции часто появляются в трейтах, где один интерфейс должен быть async, но конкретная реализация ничего не ждёт. Патч, убирающий state machine у async-блоков без await, даёт ещё 0,2% размера — мало, но правка тривиальная.

3. Futures не инлайнятся через await

Классический шаблон: адаптер просто пересылает вызов вниз.

			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.

4. Одинаковые состояния не схлопываются

Если в 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.

Заменяйте async-пустышки на impl Future

Типичный случай — трейт, где одна реализация реально ждёт ввода-вывода, а другая просто возвращает значение.

			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 вниз, уберите 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)
}
		

Схлопывайте await-точки

Если несколько веток 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, который компилятор вынужден вставлять при перемещении больших значений.

Перспективы: Project Goal и финансирование

Дион оформил эти идеи как 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() и паузам в потоках0725-06-2026
2Как ограничить расходы на OpenAI API, чтобы ИИ-агенты не сожгли бюджет0804-07-2026
3ИИ для автоматизации РКО. Как финансовые организации сокращают расходы и убирают ручной ввод документов5701-07-2026
4Аварийное восстановление до аварии: как настроить DRaaS, пока ничего не упал5802-07-2026
5Как выбрать VPS/VDS под свой проект: гайд по параметрам и 6 провайдеров0830-06-2026
6Технический долг в деньгах: как считать ROI рефакторинга легаси-системы0730-06-2026
7Deckhouse Conf 2026: зачем инженерам самописный SDN и виртуалки в Kubernetes0502-07-2026
8Как я ужал NixOS ISO с 458 МБ до 183 МБ0729-06-2026
9Почему классический CI/CD не справляется с LLM (и какие release gates мы построили, чтобы это исправить)0704-07-2026
10AI-агенты сломали управление жизненным циклом идентификации. Что с этим делать0704-07-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 7. Источник: tproger.ru.