Приветствую!
Возьмём асинхронную функцию. Заведём в ней буфер на килобайт, подождём чего-нибудь, потом прочитаем из буфера байт. Делали так тысячу раз: читаем из сокета, разбираем ответ, идём дальше, в общем — ничего особенного.
Такая функция возвращает future размером 1026 байт. А если передвинуть буфер так, чтобы он умирал до ожидания — банально, обернуть в фигурные скобки, — размер упадёт до 2 байт.
Прикинем, что это вообще значит. Сервис держит десять тысяч соединений и на каждое заводит такую задачу. Первый вариант — 10 мегабайт под буферы, которые в момент ожидания никому не нужны. Второй — 20 килобайт. А на микроконтроллере, где памяти к примеру всего 128 килобайт, первый вариант не влезет вообще, а второй займёт от неё шесть сотых процента.
Сегодня посмотрим, в чем же тут беда.
Открываем крышку
У компилятора есть флаг, который печатает раскладку типов по байтам. Нестабильный, поэтому нужна ночная сборка:
rustc +nightly --crate-type=lib -O -Zprint-type-sizes src.rs
Натравливаем на нашу функцию:
async fn with_buf() -> u8 { let buf = [0u8; 1024]; tiny().await; buf[0] }
Смотрим на вывод:
type: `{async fn body of with_buf()}`: 1026 bytes, alignment: 1 bytes discriminant: 1 bytes variant `Unresumed`: 0 bytes variant `Suspend0`: 1025 bytes local `.buf`: 1024 bytes local `.__awaitee`: 1 bytes, type: {async fn body of tiny()} variant `Returned`: 0 bytes variant `Panicked`: 0 bytes
Первое, что бросается в глаза: никакой структуры тут нет. Это перечисление.
Четыре варианта — и каждый описывает момент в жизни задачи. Unresumed — future создали, но ни разу не опрашивали. Suspend0 — выполнение встало на первом ожидании. Returned — досчитали до конца. Panicked — внутри что-то произошло.
Внутри Suspend0 лежат две вещи: наш буфер и .__awaitee размером в байт. Второе — это future той самой tiny(), которую мы ждём. Имя ей выдал компилятор, и с двумя подчёркиваниями впереди оно выглядит как что-то, за что на код-ревью не похвалят. Зато в куче байтов сразу видно, кто кого ждёт.
Дальше работает арифметика обычного перечисления: размер равен самому толстому варианту плюс дискриминант. 1025 плюс 1 — сходится.
Один байт на дискриминант — это вся плата за память о том, в каком месте функция остановилась.
Тут и стоит задержаться, потому что отсюда растёт всё дальнейшее. Раз это перечисление, значит варианты делят одну и ту же память. Как и в обычном enum, где Option<u64> не резервирует место под оба состояния разом.
Кто едет внутрь, а кто остаётся снаружи
Внутрь переезжает то, что переживает точку ожидания. Компилятор смотрит, какие переменные останутся нужны после того, как выполнение встанет и потом возобновится. Всё нужное обязано где-то храниться — стека у асинхронной задачи нет. Стек уйдёт вместе с возвратом из poll, а вернуться придётся ровно в то же состояние, с теми же значениями.
В нашей функции буфер объявлен до ожидания и читается после. Живёт через приостановку — значит, переезжает.
Во втором варианте буфер умирает раньше, чем начинается ожидание: скобки закрылись, значение уничтожено, помнить нечего. Остаются __awaitee и дискриминант — откуда и два байта.
Проверим, насколько правило точное. Возьмём два буфера и разложим их двумя способами:
async fn overlap() -> u8 { let a = [0u8; 1024]; tiny().await; let b = [0u8; 1024]; tiny().await; a[0] + b[0] } async fn disjoint() -> u8 { { let a = [0u8; 1024]; tiny().await; let _ = a[0]; } { let b = [0u8; 1024]; tiny().await; let _ = b[0]; } 0 }
Буферов поровну, ожиданий поровну, а размеры разные: 2050 байт против 1026. Тот килобайт разницы, который на десяти тысячах задач превращается в десять мегабайт.
Смотрим раскладку и видим, откуда взялась двойка:
{async fn body of overlap()}: 2050 bytes variant `Suspend0`: 1025 bytes local `.a`: 1024 bytes variant `Suspend1`: 2049 bytes local `.a`: 1024 bytes local `.b`: 1024 bytes {async fn body of disjoint()}: 1026 bytes variant `Suspend0`: 1025 bytes local `.a`: 1024 bytes variant `Suspend1`: 1025 bytes local `.b`: 1024 bytes
В первом случае буфер a нужен до самой последней строки, где складываются a[0] + b[0]. На втором ожидании живы оба, и вариант Suspend1 тащит их разом.
Во втором случае каждый буфер заперт в своих скобках. К моменту второго ожидания a уже уничтожен, и b спокойно занимает его байты. Оба варианта весят по 1025, и максимум из них тоже 1025.
Обратите внимание, что компилятор здесь не просто складывает переменные подряд. Он смотрит на время жизни каждой и разрешает разным вещам занимать одни и те же байты, если их жизни не пересекаются. Отсюда вся экономия и база про фигурную скобку. Мы не уговариваем оптимизатор и ничего ему не подсказываем. Мы просто сообщаем компилятору, что переменная больше не нужна и он перестаёт держать под неё место.
Матрица, по которой делят байты
У rustc есть флаг для дампа промежуточного представления, и для асинхронных функций он печатает довольно занятную вещь. Берём функцию с двумя восьмибайтными буферами и двумя ожиданиями:
rustc +nightly --crate-type=lib -Zdump-mir=all -Zdump-mir-dir=./mir src.rs
Внутри дампа лежит вот это:
coroutine layout { field _s0: [u8; 8]; field _s1: {async fn body of tiny()}; field _s2: [u8; 8]; field _s3: {async fn body of tiny()}; variant_fields = { Unresumed(0): [], Returned (1): [], Panicked (2): [], Suspend0 (3): [_s0, _s1], Suspend1 (4): [_s0, _s2, _s3], } storage_conflicts = BitMatrix(4x4) { (_s0,_s0), (_s0,_s1), (_s0,_s2), (_s0,_s3), (_s1,_s0), (_s1,_s1), (_s2,_s0), (_s2,_s2), (_s2,_s3), (_s3,_s0), (_s3,_s2), (_s3,_s3) } }
Сверху четыре слота: два буфера и две вложенные футуры. Имён переменных тут нет, компилятор резервирует пронумерованные ячейки.
Ниже — таблица вариантов: какие ячейки заняты в каждом состоянии. В Suspend0 живы s0 и s1, в Suspend1 — s0, s2 и _s3. Первый буфер присутствует в обоих, потому что нужен до конца функции.
А в самом низу — матрица конфликтов. Каждая пара означает «эти двое бывают живы одновременно, делить память им нельзя». Пробежимся глазами. s0 конфликтует со всеми подряд, включая себя — он живёт всё время. А вот пары s1 с s2 в списке нет. И s1 с _s3 тоже нет.
Значит, первая вложенная future и второй буфер могут лечь в одни и те же байты. Их времена жизни не пересекаются, компилятор это доказал и разрешил перекрытие. Разные типы, разные размеры — всё равно. Важно только одно: жизни не пересекаются.
Там же, парой строк ниже, лежит coroutine debug a => s0. Это отладочная информация: компилятор помнит, что ячейка s0 в исходнике называлась a. Благодаря ей отладчик показывает переменные внутри приостановленной задачи по-человечески, хотя физически они лежат в безымянных слотах перечисления.
Ссылка через ожидание обходится дороже
Заведём вектор, возьмём на него срез, подождём, потом воспользуемся срезом. И то же самое, но без промежуточной ссылки:
async fn selfref() -> usize { let data = vec![1u8, 2, 3]; let slice = &data[..]; tiny().await; slice.len() } async fn noref() -> usize { let data = vec![1u8, 2, 3]; tiny().await; data.len() }
Сорок восемь байт против тридцати двух. Смотрим, за что доплата:
{async fn body of selfref()}: 48 bytes variant `Suspend0`: 41 bytes local `.slice`: 16 bytes local `.data`: 24 bytes local `.__awaitee`: 1 bytes end padding: 6 bytes
Внутри лежат обе переменные: вектор на 24 байта и срез на 16. Срез — толстый указатель, адрес плюс длина, и хранить его приходится целиком: после возобновления он должен остаться рабочим.
Но байты тут вообще не главное. Посмотрите, куда показывает этот указатель: внутрь data, то есть внутрь того же объекта, в котором сам и лежит. Future ссылается сама на себя.
Отсюда растёт вся история с Pin. Пока такая future стоит на месте, всё хорошо. Стоит её переместить в памяти, хоть просто переложить из одной переменной в другую, и адрес внутри среза покажет на старое место, где уже ничего нет. Поэтому poll принимает не &mut self, а Pin<&mut Self>: подпись функции обещает, что после первого опроса future никто никуда не двинет.
Pin принято считать самой неудобной частью асинхронного Rust, и мне кажется зря его объясняют через лайфтаймы и трейты. Когда перед глазами лежит объект, внутри которого указатель на его же собственное поле, Pin перестаёт выглядеть произволом и становится единственным способом это описать. Объяснять его надо через шестнадцать байт вот в этом дампе.
Заодно в дампе видна ещё одна мелочь: end padding: 6 bytes. Выравнивание у этой future — восемь байт, потому что внутри Vec с указателем, а полезного набирается сорок два. До кратности восьми не хватает шести, и они просто добиваются пустотой. Шесть байт, которые не делают ничего.
Байт за этаж, и это ещё по-божески
С вложенностью выходит вообще забавно. Обернём тяжёлую функцию в цепочку пустых обёрток, каждая из которых только ждёт предыдущую:
with_buf() 1026 nested1() 1027 nested2() 1028 nested3() 1029
Каждый этаж добавляет ровно байт. Это дискриминант очередного перечисления, сама обёртка ничего не хранит, ей нужно помнить только собственное состояние.
Возьмем тот же обработчик HTTP-запроса в обычном сервиса, это примерно десять вложенных футур: роутер, пара мидлварей, хендлер, поход в базу, сериализация ответа. Десять этажей дают десять байт.
Правда, у обёртки есть и вторая цена. Каждая порождает собственную машину состояний со своим poll, который только и делает, что дёргает poll соседа этажом ниже. Байт в памяти — зато лишний уровень косвенности и лишняя ветка на панику в коде. К этому вернёмся ближе к концу.
А если опросить дважды
Раз мы разобрали все четыре варианта, стоит проверить границы. Дождёмся future до конца и опросим ещё раз:
первый poll: Ready(7) второй poll: `async fn` resumed after completion
Паника. Машина состояний, оказавшись в варианте Returned, не пытается ничего изобразить и падает.
Отсюда же видно, зачем в каждом перечислении сидит Panicked. Если тело функции запаниковало посреди работы, future обязана это запомнить, чтобы при следующем опросе не продолжать с середины разрушенного состояния.
Прикладной пользы от этого знания немного, нормальный исполнитель завершённую задачу второй раз не трогает. Но ветка на панику лежит в каждой сгенерированной машине состояний, и компилятору она мешает, вокруг неё труднее оптимизировать всё остальное. Это, забегая вперёд, одна из тех вещей, за которые сейчас взялись всерьёз.
Флаги сборки тут не спасут
Напрашивается вопрос: может, всё лечится настройками релиза? Я тоже так подумал и прогнал те же две функции на всех уровнях оптимизации подряд.
opt-level=0 disjoint 1026 overlap 2050 opt-level=1 disjoint 1026 overlap 2050 opt-level=2 disjoint 1026 overlap 2050 opt-level=3 disjoint 1026 overlap 2050 opt-level=s disjoint 1026 overlap 2050 opt-level=z disjoint 1026 overlap 2050
Нет разницы.
Все это следствие устройства конвейера. Раскладка машины состояний определяется в промежуточном представлении самого rustc, там же, где мы только что читали матрицу конфликтов, задолго до того, как за дело возьмётся LLVM. Оптимизатор получает готовый тип с готовым размером и его уже не трогает.
Размер future — свойство вашего кода, а не настроек сборки. Рычаг здесь один, и он в том, где вы объявляете переменные и когда они умирают.
Как это выглядело раньше
Сегодняшняя картина кажется разумной, и легко решить, что так было всегда.
В первых версиях асинхронного Rust варианты не делили память вовсе. Компилятор складывал все локальные переменные в одну плоскую структуру, каждой отводил персональное место, а вложенные футуры сваливались туда целиком. Каждая обёртка не прибавляла байт, а удваивала размер.
Отдельные тесты в больших проектах порождали одну машину состояний размером больше четырёхсот килобайт: втрое больше, чем весит утилита ls на моем компе целиком, со всем кодом и всеми таблицами.
Чинилось это долго, и работа была не про асинхронность как таковую. Сперва компилятор пришлось научить вообще выражать мнговариантную раскладку типа. Потом убрать из него зашитое допущение, что многовариантный тип обязан быть enum. Потом ещё одно — что дискриминант лежит первым полем. И только после этого переписывать сами генераторы на схему с матрицей конфликтов, которую мы разбирали выше.
Как можно на все это повлиять
План таков:
Сначала нужно все это дельце измерить, потому что гадать про размеры футур бессмысленно. Флаг из первого раздела показывает раскладку по вариантам с точностью до байта. На большом проекте вывод получается длинным, зато в нём сразу видно, какая переменная сидит в толстом варианте:
cargo +nightly rustc --release -- -Zprint-type-sizes | grep -A20 "async fn body"
Дальше:
Ограничить область видимости всего тяжёлого, что не нужно после ожидания. Та самая скобка. Работает, когда буфер, распарсенный JSON или временный вектор нужны до запроса и не нужны после.
Переставить порядок: сперва дождаться, потом заводить тяжёлое.
Не тащить ссылку через ожидание, если можно взять её заново после.
Убрать редкую тяжёлую ветку в кучу через Box::pin. Тогда в родительской машине состояний останется указатель на восемь байт вместо всего содержимого. Платим аллокацией, поэтому в каком-то критичном пути приём сомнительный, а на редкой ветке вполне разумный.
Здесь, кстати, водится мой любимый вид карго-культа: боксить футуры рефлекторно, потому что «они большие!». Большие они или нет, выясняется одной командой, и в половине случаев выясняется, что нет. Аллокация на каждый вызов ради экономии сорока байт — так себе идейка, и я бы предпочёл, чтобы этот приём доставали после замера, а не вместо него. Для рекурсивных асинхронных функций это к тому же единственный путь, компилятор прямо отказывается их компилировать без косвенности, потому что иначе future содержала бы саму себя и размер вышел бы бесконечным.
Оговорюсь про масштаб, чтобы не продать вам лишнего. Всё это имеет смысл там, где задач много: десятки тысяч соединений, микроконтроллер с сотней килобайт памяти, длинные очереди. Если у вас сотня задач на сервере с гигабайтами оперативки, лишние два килобайта на задачу не заметит вообще никто, и переписывать ради них рабочий код я бы не стал.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.


