Раздел 23 · Rust

Async-десахаринг: машина состояний

senior~50 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Async-десахаринг: машина состояний

Прошлый урок закрыл проект NES: ты собрал эмулятор, где один синхронный цикл крутил CPU, PPU и APU такт за тактом. Это была машина, которой никто не мешал: она владела потоком целиком и считала кадр до конца. Async решает обратную задачу: как на одном потоке вести тысячу дел сразу, ни одно не блокируя. Открываем блок «Async вглубь». Сегодня снимаем с async всю магию и смотрим, во что компилятор разворачивает async fn. Спойлер: в ту же машину состояний, что ты руками писал для эмулятора.

Идея

Когда ты пишешь async fn, кажется, что появляется какой-то фоновый поток, который сам всё сделает. Это не так. Future в Rust это не запущенная задача, а рецепт задачи: объект, который умеет делать один шаг работы, когда его об этом попросят. Попросить значит вызвать метод poll. Пока никто не зовёт poll, футура не делает ничего вообще.

Это первое, что ломает интуицию из JavaScript. Там промис жадный: как только ты написал fetch(url), запрос уже полетел, даже если ты никогда не сделаешь await. В Rust футура ленивая: read_file(path) без .await это всего лишь описание, которое никто не исполнял. Если хочешь сравнить модели, вернись к событийному циклу в JS: там это было видно со стороны рантайма, здесь мы залезаем внутрь.

Разница глубже, чем кажется, и идёт от устройства языка. В Node встроен скрытый событийный цикл: он сам подхватывает промисы и двигает каждый до конца, смотришь ты на это или нет. Rust не поставляет событийного цикла вообще, и это сделано нарочно. В языке нет ничего, что крутилось бы в фоне и исполняло твой async-код. Нужен такой движок, ты приносишь его сам (рантайм вроде tokio) или, как в этом блоке, пишешь руками.

Есть и тонкость в самом сравнении с промисом. У промиса две стороны: сторона записи (в JS это resolve внутри new Promise, в неё кладут результат, когда работа кончилась) и сторона чтения (та ручка, которую ты держишь и await-ишь). Когда ты пишешь await promise, ты держишь именно сторону чтения. Rust называет эту сторону Future, потому что читать её приходится тебе самому, вызывая poll. Так что каждый раз, встречая слово «футура», представляй промис, который ты обязан вычитывать руками.

Кто же зовёт poll? Executor: часть рантайма (tokio, smol и подобные), которая держит очередь задач и крутит цикл «возьми футуру, полей её, повтори». В этом блоке мы сами такой напишем. А пока запомни расстановку сил: компилятор превращает твой async fn в футуру, executor её двигает, а ты пишешь обычный async/await и про машинерию не думаешь. Сегодня мы эту машинерию вскрываем.

Сколько стоит ждущая задача

Прежде чем лезть внутрь, поймём, ради чего всё затевается. Классическая модель «поток на соединение» упирается в стоимость потока. Стек потока в Linux по умолчанию это 8 МБ виртуальной памяти; даже если резидентно занято мало, на сотне тысяч потоков ты упираешься в лимиты ОС, а планировщик ядра захлёбывается на переключениях контекста. Async переносит многозадачность в пространство пользователя: несколько рабочих потоков кооперативно жонглируют сотнями тысяч задач, и переключение между задачами это не системный вызов, а обычный возврат из функции.

Цифры, ради которых всё это. Пустая async-задача занимает порядка сотни байт плюс размер своей машины состояний. Поэтому миллион одновременных задач реально живёт в сотнях мегабайт, а не в терабайтах под стеки. Спящая задача стоит почти ноль: её никто не опрашивает, пока не пришло событие.

ПараметрПоток на задачуAsync-задача
Память на единицуоколо 8 МБ вирт. стекасотни байт плюс размер футуры
100 тысяч единицупираешься в лимиты ОСдесятки МБ
Переключениесистемный вызов, планировщик ядравозврат из функции
Кто будитядро по таймеру или сигналуWaker по событию

Цифры приблизительные и зависят от настроек, но порядок именно такой: разница не в проценты, а в тысячи раз по памяти и на порядок по стоимости переключения. Вот ради чего нужен рантайм, и дальше в блоке мы соберём его руками.

Трейт Future

Весь контракт умещается в один трейт:

use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Разберём по частям. Output это тип результата: для async fn run() -> u32 это u32. Метод poll это «сделай шаг работы и скажи, чем кончилось». Возвращает он Poll:

pub enum Poll<T> {
    Ready(T), // готово, вот результат
    Pending,  // ещё нет, разбуди меня позже
}

Про Pin<&mut Self> и cx: &mut Context пока думай так: Pin это обещание «я не буду двигать футуру в памяти» (почему это важно, разберём в следующем уроке), а Context несёт внутри Waker, способ сказать «я снова готова, полей меня».

Самая простая футура это та, что готова сразу:

pub struct Ready<T>(Option<T>);

pub fn ready<T>(value: T) -> Ready<T> {
    Ready(Some(value))
}

impl<T: Unpin> Future for Ready<T> {
    type Output = T;

    fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<T> {
        let value = self.0.take().expect("Ready нельзя полить после Ready");
        Poll::Ready(value)
    }
}

Она хранит значение в Option, чтобы при повторном poll после Ready запаниковать понятным сообщением. Это важная часть контракта: опросить футуру после того, как она вернула Ready, нельзя. Один результат, один раз.

Один poll: продвинься, насколько можешь

Ready всегда готова, на ней не видно сути poll. Чтобы «сделай шаг работы» перестало быть абстракцией, возьмём футуру с настоящей задачей: оформить заказ. async fn checkout грузит корзину из базы, потом зовёт платёжный API списать деньги, потом возвращает подтверждённый заказ. Ни база, ни платёж не отвечают мгновенно, поэтому футура вынуждена остановиться в каждой из двух точек. Посмотрим, что делает каждый poll.

  • Первый poll. Футура стартует, отправляет запрос в базу за корзиной. Ответа ещё нет, дальше не пройти. Она запоминает, где встала, и отвечает Pending.
  • Второй poll, когда база ответила. Футура продолжает ровно с того места, где заснула: забирает корзину, бежит дальше до вызова платёжного API. Списание ещё в полёте, она снова отвечает Pending.
  • Третий poll, когда платёж прошёл. Футура добегает до конца функции, собирает подтверждённый заказ и отвечает Ready(order).

Вот что значит «продвинься, насколько можешь»: подними работу с того места, где в прошлый раз встал, и беги вперёд, пока не упрёшься в следующее ожидание или не закончишь. Ранние poll упираются в ожидание и отдают Pending. Последнему упираться не во что, он добегает до конца и отдаёт Ready. Большинство poll отвечают «ещё нет», и ровно один, в конце, отвечает «готово».

И тут ловушка для тех, кто пришёл из JS: poll не «проверяет» футуру со стороны, он её двигает. Вызов async fn не исполнил ни строки тела. Первый poll его запускает, каждый следующий несёт на отрезок дальше. Правило жёсткое: нет poll, нет прогресса. Футуру, которую никто не полит, не исполнит ни одной строки. В фоне её ничто не двигает, двигаешь только ты, зовя poll снова.

Pending и пробуждение

Ready неинтересна: она никогда не ждёт. Вся суть async в том, что футура умеет сказать «я ещё не готова, верни мне управление потом». Вот минимальная футура, которая один раз уступает ход:

#[derive(Default)]
pub struct YieldNow {
    yielded: bool,
}

impl Future for YieldNow {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        if self.yielded {
            Poll::Ready(())
        } else {
            self.yielded = true;
            // Сразу кладём себя обратно в очередь готовых.
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

Прочитай эту логику глазами executor’а. Первый poll: футура ставит флаг, зовёт cx.waker().wake_by_ref() (это и есть «верни меня в очередь готовых») и возвращает Pending. Executor видит Pending, откладывает задачу, но wake уже пометил её готовой, поэтому на следующем витке цикла он полит её снова. Второй poll: флаг стоит, возвращаем Ready(()).

Здесь виден главный принцип async, его называют «не звони мне, я позвоню тебе». Футура, которая ждёт по-настоящему (данных из сокета, срабатывания таймера), не крутит цикл «а готово ли уже?». Она отдаёт Waker тому, кто знает про готовность (это reactor, к нему придём в уроке про исполнитель), возвращает Pending и засыпает. Когда данные придут, ОС разбудит reactor, тот позовёт wake, и задача вернётся в очередь. Ноль холостого опроса.

YieldNow тут жульничает: будит себя сама, сразу. Но именно так устроен tokio::task::yield_now, и для понимания механики этого достаточно.

async fn это машина состояний

Теперь главное. Возьмём обычный async fn с двумя точками ожидания:

async fn run(start: u32) -> u32 {
    let a = step_a(start).await;
    step_b(a).await
}

Каждый .await это место, где функция может приостановиться: под-футура вернула Pending, и run обязана отдать управление, не потеряв, где именно она встала. Когда её опрашивают снова, она должна продолжить с той же строки, с теми же живыми переменными.

Как помнить «ту же строку»? На обычном стеке так нельзя: стоит вернуть управление executor’у, и кадр стека run исчезнет вместе с переменной a. Поэтому компилятор не использует стек. Он превращает функцию в enum, где каждый вариант это участок кода между двумя await, а живые переменные становятся полями этого варианта. Вот ровно это, написанное руками.

Сначала две под-футуры. step_a пусть приостанавливается один раз (возвращает Pending, потом Ready), чтобы показать настоящую остановку; step_b готова сразу:

pub struct StepA {
    input: u32,
    polled_once: bool,
}

impl Future for StepA {
    type Output = u32;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
        if self.polled_once {
            Poll::Ready(self.input + 1)
        } else {
            self.polled_once = true;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

pub struct StepB {
    input: u32,
}

impl Future for StepB {
    type Output = u32;

    fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<u32> {
        Poll::Ready(self.input * 2)
    }
}

А вот сама развёрнутая run. Это и есть то, что компилятор генерирует за тебя:

pub enum RunFuture {
    Start { start: u32 },        // код до первого await
    AwaitingA { fut: StepA },    // застряли на step_a(start).await
    AwaitingB { fut: StepB },    // застряли на step_b(a).await
    Done,                        // функция завершилась
}

pub fn run(start: u32) -> RunFuture {
    RunFuture::Start { start }
}

impl Future for RunFuture {
    type Output = u32;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
        // Все поля Unpin (числа, bool), поэтому работаем через &mut.
        let this = self.get_mut();

        loop {
            match this {
                RunFuture::Start { start } => {
                    // Код до первого await: завели под-футуру step_a.
                    let fut = StepA { input: *start, polled_once: false };
                    *this = RunFuture::AwaitingA { fut };
                }
                RunFuture::AwaitingA { fut } => {
                    // step_a(start).await: полим вложенную футуру.
                    match Pin::new(fut).poll(cx) {
                        Poll::Pending => return Poll::Pending,
                        Poll::Ready(a) => {
                            // Получили a, переходим к step_b(a).
                            *this = RunFuture::AwaitingB { fut: StepB { input: a } };
                        }
                    }
                }
                RunFuture::AwaitingB { fut } => {
                    // step_b(a).await: последний await, его результат это
                    // результат всего async fn.
                    match Pin::new(fut).poll(cx) {
                        Poll::Pending => return Poll::Pending,
                        Poll::Ready(b) => {
                            *this = RunFuture::Done;
                            return Poll::Ready(b);
                        }
                    }
                }
                RunFuture::Done => panic!("RunFuture полили после завершения"),
            }
        }
    }
}

Прочитай poll глазами executor’а, который зовёт его раз за разом.

Первый poll. Состояние Start. Создаём StepA, переключаемся в AwaitingA. Цикл loop не выходит, а сразу же полит StepA (executor не должен возвращаться зря, если можно продвинуться). StepA в первый раз отвечает Pending. Мы возвращаем Pending наружу. Заметь: состояние осталось AwaitingA, под-футура лежит в поле fut, её прогресс сохранён.

Второй poll. Состояние всё ещё AwaitingA. Полим StepA снова, теперь она отвечает Ready(11). Это и есть let a = ...: значение a = 11 мы тут же кладём во вход StepB и переходим в AwaitingB. Цикл продолжается, полим StepB, она готова сразу: Ready(22). Переходим в Done и возвращаем Ready(22).

Вот и весь async. Никакого фонового потока: одна структура, один метод, который двигают снаружи, и enum-поле, помнящее, где мы встали.

Почему именно enum

Останови взгляд на варианте AwaitingB. В нём нет поля a. Куда оно делось? Переменная a была живой ровно до step_b(a), дальше она не нужна, поэтому компилятор не тащит её через всё состояние, а отдаёт во вход StepB. Это общее правило: в поля состояния попадают только те переменные, что живут через точку await. Всё остальное живёт на обычном стеке внутри одного poll и исчезает между вызовами.

Отсюда же растёт размер футуры. Размер enum это размер самого большого варианта. Если внутри async fn есть толстый локальный буфер, живущий через await, он раздувает каждую футуру, которая эту функцию содержит. Поэтому большие футуры иногда кладут в Box: чтобы на стеке лежал указатель, а не вся машина состояний.

И ещё одно, ради чего весь следующий урок. Что, если живая через await переменная это ссылка на другую живую переменную из того же состояния? Тогда enum ссылается сам на себя: одно поле хранит указатель на другое поле той же структуры. А такую структуру нельзя двигать в памяти, иначе указатель повиснет. Вот зачем в сигнатуре poll стоит Pin<&mut Self>. Но это уже урок про Pin.

Что именно делает .await

В RunFuture мы вручную написали одну и ту же конструкцию дважды: взять под-футуру, опросить её, на Pending выйти наружу, на Ready достать значение и пойти дальше. Это не случайность. Ровно в это компилятор и разворачивает каждый .await. Если расписать let a = step_a(start).await; без сахара, получится примерно так:

// let a = step_a(start).await;  разворачивается в:
let mut fut = IntoFuture::into_future(step_a(start));
let a = loop {
    // SAFETY: fut живёт в поле машины состояний и не двигается.
    match Future::poll(unsafe { Pin::new_unchecked(&mut fut) }, cx) {
        Poll::Ready(value) => break value, // готово: значение это и есть `a`
        Poll::Pending => {
            // вернуть Pending из объемлющего poll, запомнив, что мы здесь;
            // executor полит нас снова, и loop продолжится с того же fut.
        }
    }
};

Два наблюдения. Первое: .await не зовёт poll один раз, он крутит poll в цикле, пока под-футура не отдаст Ready. Между витками управление уходит наружу через Pending, но логически это один цикл ожидания. Именно его роль в RunFuture играет внешний loop плюс хранение fut в поле: выход через return Poll::Pending это и есть «вернуть Pending наружу, запомнив место».

Второе, неочевидное: первая строка зовёт не poll, а IntoFuture::into_future. IntoFuture это причина, по которой .await работает не только над Future, но и над любым типом, который умеет в футуру превратиться. Сам Future реализует IntoFuture тождественно, поэтому обычный .await над футурой берёт её саму.

Когда await в цикле

Раз вариант enum это «место между await», возникает вопрос: что будет с циклом, в котором есть await? Не вырастет ли число состояний до числа итераций? Нет, и это важно для размера футуры. Возьмём:

async fn sum_first(n: u32) -> u32 {
    let mut total = 0;
    for i in 0..n {
        total += step(i).await; // единственная точка ожидания на весь цикл
    }
    total
}

Точка await тут одна, в теле цикла. Значит и состояние ожидания одно, его переиспользуют каждую итерацию, меняя счётчики. Развёрнутая машина выглядит так:

pub enum SumFirst {
    Start { n: u32 },
    // одна точка await -> один вариант ожидания, в него сложены живые
    // через await переменные: счётчик цикла i, аккумулятор total, под-футура.
    AwaitingStep { n: u32, i: u32, total: u32, fut: Step },
    Done,
}

Каждый виток цикла это переход AwaitingStep -> AwaitingStep с новыми i и total, а не новый вариант. Число вариантов enum ограничено числом точек await в исходнике, а не числом проходов в рантайме. Миллион итераций крутится через одно и то же состояние.

Отсюда практический вывод про размер. Размер футуры это размер её enum, то есть размер самого толстого варианта, то есть максимум живого набора переменных по всем точкам await. Один жирный буфер, переживающий await, раздувает футуру навсегда. И ещё: async fn, которая .await-ит сама себя (рекурсия), дала бы enum, содержащий себя же, а это бесконечный размер. Поэтому асинхронную рекурсию заворачивают в Box: Box::pin(...) кладёт вложенную футуру на кучу, и в состоянии лежит указатель фиксированного размера, а не вся машина целиком.

Практика

Три задачи, чтобы механика осела руками. Везде только std, без рантайма: ты сам и компилятор, и executor.

Что унести из урока

Async в Rust это не магия и не фоновый поток, а три ясные вещи. Первое: Future это объект с методом poll, который снаружи двигают, пока он не вернёт Ready; сама футура ленивая и без poll не делает ничего. Второе: async fn компилятор разворачивает в enum-машину состояний, где вариант это место между await, а поля это переменные, живые через await. Третье: пробуждение работает по принципу «не звони мне, я позвоню тебе»: футура отдаёт Waker тому, кто знает про готовность, и засыпает без холостого опроса.

Дальше в блоке мы соберём недостающие части руками: Waker через сырую vtable, свой executor, который крутит очередь задач, и reactor поверх epoll. Но сначала закроем вопрос, почему машину состояний нельзя двигать в памяти.

Домашка