LINUX.ORG.RU

Асинхронная рекурсия в Rust

 ,


1

4

Минимальный пример:

struct Tst;

impl Tst {
	async fn foo(&self) {
		self.bar().boxed().await;
	}
	
	async fn bar(&self) {
		self.foo().await;
	}
}

В реальном коде, конечно же, и foo, и bar совершают полезную работу, а рекурсия условная, а не бесконечная.

Но ошибка одна и та же:

future cannot be sent between threads safely
future returned by `bar` is not `Send`
Note: cannot satisfy `impl futures_util::Future<Output = ()>: std::marker::Send`
Note: future is not `Send` as it awaits another future which is not `Send`
Note: required by a bound in `futures_util::FutureExt::boxed`

Ну а без .boxed() стандартная ошибка компилятора о недопустимости рекурсии без упаковки фич.

С помощью нейросетей нашёл вот такой рабочий work-around:

use futures_util::future::BoxFuture;
use futures_util::FutureExt;

struct Tst;

impl Tst {
	fn foo(&'_ self) -> BoxFuture<'_, ()> {
		async {
			self.bar().await;
		}.boxed()
	}
	
	async fn bar(&self) {
		self.foo().await;
	}
}

Любую одну функцию в рекурсивной цепочке переписать из async fn в обычную fn возвращающую BoxFuture. Тогда код начинает компилироваться.

Но выглядит уродливо.

Есть ли варианты лучше?

★★★★★

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

anonymous
()
Ответ на: комментарий от anonymous

Об этом упомянуто в ОП, проблема не в том, что вычисление нужно боксить, а в том что оно не боксится простым вызовом .boxed() на результе одной из функций, а только боксингом async блока в функции явно возвращающей BoxFuture. То есть на цепочке рекурсивных вызовов хотя бы одна функция должна быть не async, а обычной fn возвращающей BoxFuture, что на мой скромный взгляд уродливо (хочется чтобы все функции были async).

KivApple ★★★★★
() автор топика
Последнее исправление: KivApple (всего исправлений: 2)
Ответ на: комментарий от anonymous

Так тоже скомпилируется:

fn foo(&self) -> BoxFuture<()> {
	async {
		self.bar().await;
	}.boxed()
}

Просто RustRover выдаёт предупреждение, что без явного lifetime «недостаточно читаемо».

KivApple ★★★★★
() автор топика
Последнее исправление: KivApple (всего исправлений: 2)
Ответ на: комментарий от KivApple

что на мой скромный взгляд уродливо (хочется чтобы все функции были async)

Мне как пишущему много на C++ за зарплату этот кусок кажется, наоборот, кристально чистым с явными намерениями, за что и ценю особо Rust.

Тут такое дело, что объекты вычислений создаются на стеке. Чтобы разобрать взаимную рекурсию, нужно один из объектов поместить в динамическую память. Ну, мы это уже обсудили, что ты и сам об этом написал.

Вопрос в том, почему Rust автоматически не делает боксинг? Подозреваю, что из соображений производительности. Объекты-оболочки для вычислений Future создаются на стеке. Поэтому они производительны, где весь этот слой абстракций из оболочек убирается компилятором при инлайне.

Видимо, разработчики языка Rust предпочли выбрать производительность, где боксинг (то есть, выведение объекта вычисления в динамическую память) предпочли сделать явным для программиста.

Как я написал. Все кристально чисто и понятно. Никаких неявных преобразований. И при этом максимально производительно одновременно.

anonymous
()
Ответ на: комментарий от anonymous

Да не в боксинге вопрос. А в том, что я не могу написать:

async fn foo(&self) {
	self.bar().boxed().await;
}

Я должен писать:

fn foo(&self) -> BoxFuture<()> {
	async {
		self.bar().await;
	}.boxed()
}

Боксинг и там и там, но во втором случае дополнительный async блок и BoxFuture.

Я не очень доверяю ИИ, но он вообще подсказывает, что тут проблема не в боксинге, а в выводе типов. Что Rust не способен определить в случае рекурсивного типа (даже если рекурсию обеспечивает Box, что допустимо, у нас нет структуры бесконечного размера, иначе бы была другая ошибка), является ли он Send в данном случае (при явном указании BoxFuture компилятор знает, что результат точно Send, потому что BoxFuture реализует Send).

KivApple ★★★★★
() автор топика
Последнее исправление: KivApple (всего исправлений: 1)
Ответ на: комментарий от anonymous

Как я написал. Все кристально чисто и понятно. Никаких неявных преобразований. И при этом максимально производительно одновременно.

Если без алиасов и всяких там скрытых трейтов-расширений переписать, то там вообще всё просто и понятно станет:

fn foo<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
    Box::pin(async move {
        self.bar().await;
    })
}

Вот и всё, намерения сразу ясны, при этом максимальная производительность в рантайме одновременно. Все бы языки были такими — индустрия стала бы лучше. А то понапишут свои

async fn foo(self) {
    self.bar();
}

и потом кто поймёт, кто кого вызывает и что передаёт? Никто.

byko3y ★★★★
()

Лучше перепиши алгоритм вместо рекурсивного вызова в обычный цикл сохраняя значения в какой нибуть Vec, тогда future не нужно вообще будет боксировать, а сами значения можно даже будет впихнуть SmallVec и тогда вся функция все будет вообще на стеке хранить , а если значение превысит лимит SmallVec, то SmallVec сам переедет в кучу. Так и дебажить если что легче, т.к. стек значений будет обычным массивом. Если в языке нет оптимизации хвостовой рекурсии, то писать рекурсией лучше не надо, т.к. чревато разными проблемами

anonymous
()
Ответ на: комментарий от KivApple

Я не очень доверяю ИИ, но он вообще подсказывает, что тут проблема не в боксинге, а в выводе типов. Что Rust не способен определить в случае рекурсивного типа (даже если рекурсию обеспечивает Box, что допустимо, у нас нет структуры бесконечного размера, иначе бы была другая ошибка), является ли он Send в данном случае (при явном указании BoxFuture компилятор знает, что результат точно Send, потому что BoxFuture реализует Send).

Проблема в том, что гениальные архитекторы раста решили, что алокации в кучу, как делают отсталые медленные языки, вроде C++ — это слишком просто и неуклюже. Как и во всех остальных ЯП, в Rust асинхронная функция является структурой со всеми-привсеми локальными переменными — это и есть скрытый тип возврата, типа foo_state_machine. Но твой рекурсивный вызов — это ещё одно поле в этой самой структуре, аля struct foo_state_machine {state: MachineState; bar: bar_state_machine} — потому что, опять же, async fn bar является скрытой структурой, выделяемой прямо в стэке функции foo. Здесь под словом «стэк» имеется в виду именно «стэк foo» — его фактическое место хранения будет зависеть от того, где выделять стэк foo, он может быть в кучу (то есть Box, Rc, Arc) или же в физическом стэке.

Когда ты делаешь вызовы foo()=>bar()=>foo(), то ты как бы говоришь мудрому компилятору Rust, что тебе нужно

struct foo_state_machine {
    state: MachineState;
    bar: bar_state_machine;
};

struct bar_state_machine {
    state: MachineState;
    foo: foo_state_machine;
};

Вуаля, у нас структура, которая содержит другую структуру, содержащую первую структуру. Заслуга Rust здесь в том, что он никогда не полезет сам алоцировать данные в куче, если ты его специально не попросишь.

К слову, если нужно сделать foo и bar одинаковыми, то можно просто сделать BoxFuture в обоих.

byko3y ★★★★
()

Есть ли варианты лучше?

Нет.

Этот код:

struct Tst;

impl Tst {
	async fn foo(&self) {
		self.bar().await;
	}
	
	async fn bar(&self) {
		self.foo().await;
	}
}

Является синтаксическим сахаром для вот этого кода:

struct Tst;

impl Tst {
	fn foo(&self) -> impl Future<()> {
		self.bar().await;
	}
	
	fn bar(&self) -> impl Future<()> {
		self.foo().await;
	}
}

impl Future<()> - безымянная структура с FSM внутри, т.к. корутины в Rust не имеют стека. Когда ты делаешь рекурсию, размер этого объекта невозможно вычислить во время компиляции. Это как в C писать нечто вроде:

struct StateA {
    struct StateB state;
};

struct StateB {
    struct StateA state;
};
anonymous
()

Ошибка с BoxFuture вроде как про другое, тип не реализует Send.

А так не работает?

async fn foo(&self) {
    Box::pin(self.bar()).await;
}

Пы.Сы.

Просто сделал рекомендацию из сообщения об ошибке. :)

   = note: a recursive `async fn` call must introduce indirection such as `Box::pin` to avoid an infinitely sized future
numas13
()

В реальном коде, конечно же, и foo, и bar совершают полезную работу, а рекурсия условная, а не бесконечная.

Но ошибка

Rust не любит недоделок. У тебя их две:

  1. Бесконечный цикл.
  2. Не раскрыта рекурсия.

Избавься от обоих и не надо никаких BoxFuture.

Тупо пример:

pub struct Tst {}

enum TstWorkState {
    Foo,
    Bar,
}

impl Tst {
    pub async fn work(&self, count: u32) {
        let mut state = TstWorkState::Foo;
        for _ in 0..count {
            match state {
                TstWorkState::Foo => self.work_foo(&mut state).await,
                TstWorkState::Bar => self.work_bar(&mut state).await,
            }
        }
    }

    async fn work_foo(&self, state: &mut TstWorkState) {
        println!("foo");
        *state = TstWorkState::Bar;
    }

    async fn work_bar(&self, state: &mut TstWorkState) {
        println!("bar");
        *state = TstWorkState::Foo;
    }
}

#[tokio::main]
async fn main() {
    let tst = Tst {};
    tst.work(10).await;
}
AlexVR ★★★★★
()