Программирование RustКонкурентность и asyncРазработчик Rust для backend-систем

Рекурсивный async обход вызывает сам себя; объясните причину ошибки компиляции и способ сделать future коне...

Рекурсивный async-обход вызывает сам себя; объясните причину ошибки компиляции и способ сделать future конечного размера.

async fn walk(depth: u32) {
    if depth > 0 {
        walk(depth - 1).await;
    }
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Ошибка возникает потому, что async fn возвращает future, а future walk напрямую содержит future следующего рекурсивного вызова. Получается тип бесконечного размера: future содержит себя, который снова содержит себя. Нужно добавить косвенность, обычно через Box::pin.

async fn walk(depth: u32) { if depth > 0 { Box::pin(walk(depth - 1)).await; } }

Исторический контекст

Асинхронные функции в Rust компилируются в state machine, реализующую Future. Такой подход позволяет приостановить задачу на .await, сохранив её состояние, но не занимая поток операционной системы.

Для эффективного размещения state machine компилятору обычно нужен конечный размер каждого состояния. Обычный рекурсивный вызов создаёт самоссылочную структуру, поэтому автоматический тип future нельзя представить как конечный объект без дополнительной косвенности.

Постановка проблемы

До первого рекурсивного вызова функция выглядит нормально, но walk(depth - 1).await требует встроить future walk внутрь текущего future. Вложенность не ограничена на уровне типа, даже если конкретный аргумент depth при выполнении конечен.

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

Подробное решение

Box::pin размещает рекурсивный future в куче и оставляет внутри внешнего future только указатель фиксированного размера. Pin гарантирует, что размещённый future не будет перемещён после начала его polling; это нужно для корректной работы self-referential state machine.

Косвенность должна находиться именно на рекурсивном пути. Простое присваивание результата в переменную или передача его в другую generic-функцию не создаёт подходящей границы размера: компилятор всё ещё может видеть рекурсивный future как часть внешнего типа.

Цена решения — аллокация и косвенный доступ на каждом рекурсивном уровне. Если future передаётся в многопоточный runtime, его тип также должен удовлетворять требованиям runtime, например Send и подходящему времени жизни; Box::pin сам по себе эти свойства не добавляет.

Для глубокой или производительно-критичной рекурсии альтернативой может быть явный стек, например Vec с узлами обхода. Он часто уменьшает число аллокаций и позволяет лучше контролировать глубину, но требует вручную моделировать состояния, которые компилятор обычно создаёт для async-кода.

Ситуация из практики

Сервис асинхронно обходит вложенную структуру каталогов и для каждого узла выполняет сетевой запрос. Прямой рекурсивный async fn не компилируется из-за бесконечного размера future.

Вариант с Box::pin проще всего внедрить и сохраняет естественную структуру кода, но создаёт heap-аллокацию на рекурсивном вызове. Запуск каждого узла через spawn не является эквивалентной заменой: он меняет модель владения, требует подходящих Send и 'static, добавляет планирование задач и усложняет обработку ошибок.

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

Что кандидаты часто упускают

  1. Достаточно ли написать Box<dyn Future> вместо Box::pin?

    Нет. Box<dyn Future> создаёт объект в куче, но не означает, что его можно безопасно перемещать после начала выполнения. Для polling обычно требуется Pin<Box<dyn Future<...>>>; Box::pin одновременно выполняет размещение и создаёт pinned-обёртку.

  2. Сделает ли Box::pin рекурсивный future Send?

    Нет. Send определяется содержимым future и значениями, которые оно удерживает между точками .await. Если внутри есть Rc, RefCell или другой несоответствующий тип, Pin<Box<...>> не исправит это. Для передачи future в многопоточный runtime сам future и его захваченные данные должны удовлетворять требованиям Send.

  3. Почему конечное значение depth не позволяет компилятору вывести конечный размер?

    Размер типа вычисляется статически, а не по фактическому значению аргумента во время выполнения. Даже если при depth == 10 будет всего десять вызовов, тип функции один и тот же для всех значений u32; без косвенности он должен был бы описывать рекурсивное вложение неограниченной глубины.