Async Rust: как уменьшить бинарник, пока rustc не оптимизирован
Асинхронный Rust обещал zero-cost абстракции, но компилятор до сих пор генерирует лишние состояния и panic-ветки. Разбираем, почему это больно для embedded и WASM, и как вручную уменьшить бинарник, пока 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.
Рассмотрим минимальный пример. Есть две функции, каждая из которых просто возвращает число, и третья складывает результаты:
У bar две await-точки, поэтому минимум два пользовательских состояния. Но если сдампить MIR после прохода coroutine_resume, картина шире:
Три служебных состояния добавляются всегда. Они нужны, чтобы соблюсти контракт 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-точки:
Идеальная реализация — всегда возвращать Poll::Ready(5) безо всякого enum. А rustc генерирует полноценный CoroutineLayout с тремя служебными состояниями и switch по дискриминанту. Такие функции часто появляются в трейтах, где один интерфейс должен быть async, но конкретная реализация ничего не ждёт. Патч, убирающий state machine у async-блоков без await, даёт ещё 0,2% размера — мало, но правка тривиальная.
3. Futures не инлайнятся через await
Классический шаблон: адаптер просто пересылает вызов вниз.
Сейчас bar получает собственный state machine, который вызывает state machine foo. Вручную мы бы написали fn bar(blah) -> impl Future { foo(blah) } и избавились бы от лишнего enum. Аналогично с преамбулой и постамбулой: их можно перенести на FutureExt::map из крейта futures.
4. Одинаковые состояния не схлопываются
Если в async-функции несколько веток match ведут к одинаковому await, компилятор создаёт отдельное состояние на каждую ветку:
Здесь send_response вызывается с разными аргументами, но структура состояний дублируется. Если вынести выбор аргумента за await, получится одно состояние вместо двух:
В одном примере из статьи MIR сокращается с 456 до 302 строк, а ассемблер — примерно на 11%. Оптимизации stack, поэтому выигрыш в реальном коде может быть заметнее.
Что делать сегодня, не дожидаясь rustc
Всё перечисленное выше — это работа для команды компилятора. Но релиз с этими патчами может занять месяцы, а то и годы. Пока он не вышел, можно снизить async-bloat в своём коде.
Главный принцип: каждый лишний async fn — это лишний state machine. Если функция не содержит await, не делайте её async.Заменяйте async-пустышки на impl Future
Типичный случай — трейт, где одна реализация реально ждёт ввода-вывода, а другая просто возвращает значение.
Вместо полноценного state machine получается готовая future, которая сразу возвращает Ready. Это особенно полезно в embedded-HAL, где трейты часто async, но конкретный драйвер может делать только прямой доступ к регистрам.
Прозрачные обёртки без await
Если функция только пересылает await вниз, уберите await:
Схлопывайте await-точки
Если несколько веток match заканчиваются одним и тем же await, вынесите выбор аргументов наружу. Это уменьшает число состояний state machine и облегчает жизнь LLVM.
Передавайте большие данные по ссылке
Async-функция захватывает в state machine всё, что живёт через await. Если передать массив по значению, он окажется внутри future целиком:
Разница в 52 раза по размеру future — и это без учёта memcpy, который компилятор вынужден вставлять при перемещении больших значений.
Перспективы: Project Goal и финансирование
Дион оформил эти идеи как Rust Project Goal — формальный механизм, через который команды заявляют цели на полугодие. По его оценке, работа требует порядка €30 000 финансирования. Для компиляторного проекта это скромная сумма: речь идёт о 2–5% размера прошивки практически в любом async-проекте.
Есть и другой подход к той же проблеме: не убирать state machine на входе, а научить LLVM лучше их оптимизировать на выходе. Дион считает, что оба направления дополняют друг друга. Чем проще state machine попадает в LLVM, тем эффективнее её можно проинлайнить и упростить на поздних проходах.
Часто задаваемые вопросы
Почему эта проблема критична именно для embedded?
На сервере лишние килобайты теряются в гигабайтах RAM и в L3-кэше. На микроконтроллере с 256 КБ flash 2–5% — это 5–13 КБ, которые могут определить, влезет ли прошивка. Кроме того, embedded часто использует оптимизацию по размеру, где LLVM менее агрессивен, чем при оптимизации по скорости.
Можно ли просто отказаться от async Rust на embedded?
Можно, и многие так делают. Но async даёт удобную конкурентность без RTOS, позволяет писать executor-agnostic код и упрощает тестирование. Альтернатива — ручные state machine или блокирующий код с прерываниями. Выбор зависит от размера прошивки, сложности логики и команды.
Почему LLVM не убирает эти излишки сам?
Потому что desugaring async в state machine происходит в rustc до LLVM. К моменту, когда LLVM видит код, уже сформированы enum состояний, panic-ветки и вызовы между futures. LLVM не может «понять», что две функции можно было бы слить в одну future, потому что эта информация потерялась на этапе MIR.
Как быстро проверить, страдает ли мой проект?
Измерьте размер future через std::mem::size_of_val, сравните async и синхронный вариант, посмотрите MIR с помощью rustc --emit=mir или Godbolt. Если прошивка больше ожидаемого, ищите async-функции без await, прозрачные обёртки и большие захваченные значения.
Когда ждать исправлений в 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.
Утверждение «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.