Асинхронные трейты могут напрямую использовать ручные реализации Future

— 2025-05-26 источник Yoshua Wuyts

  • введение
  • наивная поддержка AFIT ручными реализациями Future
  • прямая поддержка AFIT ручными реализациями Future
  • простые встроенные пол-стэйт-машины
  • заключение
  • благодарности

Введение

Есть интересный факт, о котором, кажется, большинство людей не знает, когда пишет асинхронные функции в трейтах (AFIT): они позволяют напрямую возвращать фьючерсы из методов. Звучит запутанно? Позвольте проиллюстрировать, что я имею в виду, на простом примере. Предположим, у нас есть трейт AsyncIterator с асинхронным методом async fn next, определённым следующим образом:

#![allow(unused)]
fn main() {
trait AsyncIterator {
    type Item;
    async fn next(&mut self) -> Option<Self::Item>;
}
}

Если бы мы захотели определить итератор, который выдаёт элемент ровно один раз, мы бы, вероятно, написали его так. Он хранит значение T в Option, и при вызове next мы извлекаем его и возвращаем Some(T), если значение есть, и None — если нет:

#![allow(unused)]
fn main() {
/// Выдаёт элемент ровно один раз
pub struct Once<T>(Option<T>);
impl<T> AsyncIterator for Once<T> {
    type Item = T;
    async fn next(&mut self) -> Option<T> {
        self.0.take()
    }
}
}

Просто, верно? Теперь попробуем написать этот фьючерс вручную. Rust — это язык системного программирования, и важно, чтобы мы могли опускаться на более низкие уровни абстракции для получения дополнительного контроля, когда это нужно. Я считаю пробелом в языке ситуации, когда мы не можем пробиться через абстракцию к её составным частям. Давайте посмотрим, как это будет выглядеть.

Наивная поддержка AFIT ручными реализациями Future

Хорошо, что такое составные части async fn? В основном, это трейт Future. Что мы хотим сделать — это определить свой собственный фьючерс и использовать его в качестве основы нашей реализации. Всё, что мы делаем, — это разыменовываем Option и забираем его внутреннее значение, что делает задачу довольно простой. Наивно мы бы, вероятно, написали что-то вроде этого:

#![allow(unused)]
fn main() {
/// Внутренняя реализация `Future` для нашего итератора `Once`
struct OnceFuture<'a, T>(&'a mut Once<T>);
impl<'a, T> Future for Once<'a, T> {
    type Output = T;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'a>) -> Poll<Self::Output> {
        // SAFETY: мы проецируемся в непроколотое поле
        let this = unsafe { Pin::into_inner_unchecked(self) };
        Poll::Ready((&mut this.0.0).take())
    }
}

/// Выдаёт элемент ровно один раз
pub struct Once<T>(Option<T>);
impl<T> AsyncIterator for Once<T> {
    type Item = T;
    async fn next(&mut self) -> Option<T> {
        // Делегируем реализации `OnceFuture`
        OnceFuture(self).await
    }
}
}

Внезапно стало много всего нового, правда? Это низкоуровневая версия того, что мы написали ранее. Или нет? Если присмотреться, можно заметить, что здесь мы имеем дело с одним дополнительным уровнем косвенности. В теле нашего итератора мы написали: OnceFuture(self).await. В простом бенчмарке компилятор с радостью это оптимизирует. Но в более сложных программах — может и нет. И это проблема, потому что это означает, что переход на низкоуровневую абстракцию может дать худшую производительность.

Если бы это было лучшее, что мы можем сделать, это, вероятно, означало бы смерть AFIT в стандартной библиотеке. Это бы означало, что AFIT полезны для высокоуровневых API, которые мы можем реализовать, используя async fn, и быть довольными этим. Но не для серьёзных реализаций, которые нужно оптимизировать до конца, таких как в стандартной библиотеке. И это указывало бы нам на вещи вроде трейтов на основе poll_*, которые мы можем реализовывать вручную более непосредственно.

Прямая поддержка AFIT ручными реализациями Future

К счастью, мы можем легко убрать промежуточный вызов .await из нашего предыдущего примера, используя малоизвестную особенность AFIT: возможность напрямую возвращать фьючерсы. Вместо того чтобы писать async fn next, мы можем написать ту же функцию как fn next() -> impl Future. Это почти одно и то же, с небольшими различиями:

#![allow(unused)]
fn main() {
/// Внутренняя реализация `Future` для нашего итератора `Once`
struct OnceFuture<'a, T>(&'a mut Once<T>);
impl<'a, T> Future for Once<'a, T> {
    type Output = T;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'a>) -> Poll<Self::Output> {
        // SAFETY: мы проецируемся в непроколотое поле
        let this = unsafe { Pin::into_inner_unchecked(self) };
        Poll::Ready((&mut this.0.0).take())
    }
}

/// Выдаёт элемент ровно один раз
pub struct Once<T>(Option<T>);
impl<T> AsyncIterator for Once<T> {
    type Item = T;
    // Напрямую возвращает реализацию `OnceFuture`
    fn next(&mut self) -> impl Future<Output = Option<T>> {
        OnceFuture(self) // ← больше нет `.await`
    }
}
}

Видите реализацию fn next? Теперь она напрямую возвращает OnceFuture. И мы можем это делать, даже несмотря на то, что определение трейта явно определяло метод трейта как async fn next. Это значит, что для того, чтобы основать наш трейт на основе AFIT на ручной реализации фьючерса, не требуется промежуточного вызова .await. А это значит, что мы больше не зависим от компилятора в оптимизации этого для достижения эквивалентной производительности, соответствуя нашим целям возможности вручную разобрать высокоуровневую нотацию на составные части, которыми мы можем управлять вручную.

Простые встроенные пол-стэйт-машины

Стандартная библиотека Rust предоставляет удобную функцию future::poll_fn, которая позволяет создавать stateless фьючерсы (т.е. не хранящие собственное состояние между вызовами poll). Но поскольку она принимает замыкание FnMut, оно может ссылаться на внешнее состояние, доступное ему через upvars. Это позволяет нам переписать громоздкую ручную реализацию фьючерса из наших последних двух примеров в виде изящного встроенного вызова poll_fn:

#![allow(unused)]
fn main() {
/// Выдаёт элемент ровно один раз
pub struct Once<T>(Option<T>);
impl<T> AsyncIterator for Once<T> {
    type Item = T;
    fn next(&mut self) -> impl Future<Output = Option<T>> {
        future::poll_fn(|_cx| /* -> Poll<Option<T>> */ {
            Poll::Ready((&mut self.value).take())
        })
    }
}
}

Это невероятно удобно, потому что позволяет быстро писать оптимизированный встроенный код стейт-машин на основе poll внутри реализаций трейтов. Или, в общем-то, в любом асинхронном контексте. Мне нравится думать о poll_fn как о способе быстро перейти в «низкоуровневый асинхронный» режим, вроде того как unsafe {} позволяет перейти в «режим работы с сырой памятью».

Это также чётко делает трейт Future фундаментальным строительным блоком всего асинхронного кода. Это более простая и надёжная модель, чем альтернативная модель fn poll_*; в рамках которой асинхронные операции могут быть реализованы либо через Future::poll, AsyncIterator::poll_next, AsyncRead::poll_read и так далее.

Заключение

Я написал этот пост потому, что большинство людей, кажется, не знают, что AFIT можно использовать таким образом. Хотя, возможно, это одна из их самых важных возможностей. И это важно, потому что, как я сказал ранее в этом посте: было бы очень плохо, если бы пространство асинхронных трейтов разделилось на:

  • Трейты на основе AFIT: которые удобно реализовывать, но имеют худшую производительность из-за отсутствия контроля.
  • Трейты на основе poll: которые неудобно реализовывать, но имеют лучшую производительность из-за предоставляемого контроля.

Этот пост показывает, что трейты на основе AFIT одновременно удобны в реализации и, при необходимости, предоставляют контроль, требуемый для гарантии производительности через ручные реализации пол-стейт-машин. Это унифицирует дизайн-пространство для асинхронных трейтов, убирая выбор между «удобным API» и «быстрым API». С AFIT удобный API — это быстрый API.

Хотя в этом посте в качестве примера мы использовали трейт AsyncIterator, ничто изложенное здесь не является специфичным для AsyncIterator. Возможность контролировать стейт-машины фьючерсов для AsyncRead, AsyncWrite и так далее не менее важна. Но если мы рассмотрим AsyncIterator на основе AFIT в сочетании с функцией async gen {}, мы увидим, что он предоставляет в общей сложности три уровня контроля, тогда как трейт AsyncIterator на основе poll предоставляет только два1:

На основе AFITНа основе poll_*
async gen {}
async fn
fn пол-стейт-машина

И все три уровня абстракции важны для удобной реализации. Было бы неправильно выбирать только некоторые. Возможность реализовывать асинхронные трейты на основе async fn важна в случаях, когда высокоуровневая конструкция вроде gen {} недоступна, как с AsyncRead и AsyncWrite. Но также важно, чтобы для AsyncIterator можно было реализовывать подтрейты вроде ExactSizeIterator и можно было реализовывать предоставленные методы вроде fn size_hint. async gen {} не предоставляет возможности делать ни то, ни другое2, и пользователей не следует заставлять писать пол-стейт-машины только ради этого.

Благодарности

Я хотел бы поблагодарить Оли Шерера (Oli Scherer) за то, что он в конце прошлого года нашёл время поработать со мной над серией примеров AFIT, использующих ручные реализации фьючерсов, и объяснить, как они разрешаются и оптимизируются компилятором. Этот разговор дал мне уверенность делать утверждения в этом посте со степенью определённости, которой у меня иначе не было. Однако Оли не рецензировал этот пост заранее, так что я определённо не говорю от его имени, и любые ошибки в этом посте будут моими собственными.

Примечания


  1. Уверен, что с достаточным количеством языковой магии мы могли бы предоставить какой-то способ, позволяющий реализовывать трейты на основе fn poll_* через async fn. Но для этого потребовалась бы совершенно новая языковая функция, и всё только для того, чтобы в итоге получить менее согласованную и более сложную модель, чем если бы мы напрямую основали все асинхронные трейты (кроме Future) на async fn.

  2. Споры о возможностях (capability-based) в стороне, я также считаю, что реализация трейтов должна быть лёгкой. То есть, что значит, реализация неблокирующего итератора вручную требует докторской степени по Rustology? Rust призван делать системное программирование доступным и понятным. И это должно применяться на каждом уровне абстракции.