Программирование RustКонкурентность и asyncРазработчик Rust с фокусом на асинхронные системы

Разберите ошибку в пользовательском future: почему runtime не сможет гарантированно продолжить выполнение з...

Разберите ошибку в пользовательском future: почему runtime не сможет гарантированно продолжить выполнение задачи после первой проверки?

Проходите собеседования с ИИ помощником Hintsage

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

Poll::Pending означает не только «результат пока не готов», но и «future сообщил исполнителю, когда его нужно проверить снова». В коде NeverReady возвращает Pending, но не использует Waker из Context, поэтому runtime не получает уведомление о возможности нового прогресса и задача может зависнуть навсегда.

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

Future отделяет описание асинхронной операции от её выполнения. Исполнитель не обязан постоянно опрашивать все futures: такой busy polling расходовал бы процессор, особенно когда операция ожидает сеть, таймер или сообщение канала.

Для решения этой проблемы в протокол Future::poll включили Waker. Ожидающая операция регистрирует waker, а затем вызывает его, когда появляется возможность продолжить работу. Runtime после этого планирует повторный вызов poll.

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

В примере future всегда возвращает Poll::Pending, но не связывает это состояние ни с событием, ни с waker. Runtime может опросить задачу один раз и убрать её из очереди готовых задач.

Если future действительно ждёт внешний ресурс, отсутствие уведомления приводит к зависанию задачи. Если вместо этого постоянно возвращать Pending и немедленно будить себя, задача будет прогрессировать, но может создать busy loop и лишить процессор времени другие задачи.

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

Контракт poll можно рассматривать так:

  • Poll::Ready(value) — операция завершена;
  • Poll::Pending — операция ещё не завершена, а future подготовил механизм уведомления о следующей попытке.

Ожидающий future должен получить cx.waker(), передать его объекту, который отслеживает событие, и вернуть Pending. Когда событие произойдёт, этот объект вызывает wake или wake_by_ref. Runtime ставит соответствующую задачу в очередь, после чего снова вызывает её poll.

Минимальный пример правильного уведомления выглядит так:

use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; struct YieldOnce(bool); impl Future for YieldOnce { type Output = (); fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { if self.0 { Poll::Ready(()) } else { self.0 = true; cx.waker().wake_by_ref(); Poll::Pending } } }

Здесь future сначала сообщает Pending, но сразу планирует новый poll. При следующем вызове он возвращает Ready. В реальном сетевом или таймерном future waker обычно сохраняется вместе с состоянием операции и вызывается из обработчика завершения события, а не немедленно.

Важно, что waker может измениться между вызовами poll: задача может быть перепланирована другим executor или с другим контекстом. Поэтому асинхронный примитив должен корректно обновлять сохранённый waker, обычно заменяя старый эквивалентным новым.

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

Runtime не обязан повторно опрашивать future только потому, что тот вернул Pending. Повторный poll может произойти случайно из-за другого события, но полагаться на это нельзя. Нарушение контракта приводит к зависанию, а чрезмерные самопробуждения — к лишним переключениям и высокой загрузке CPU.

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

Команда реализует future для ожидания ответа от сокета. Первый вариант после отправки запроса возвращает Pending, но waker сохраняет только runtime сетевой библиотеки, а не waker конкретной async-задачи. В результате ответ приходит, но пользовательская задача не просыпается.

Рассматривались три варианта:

  • регулярно опрашивать сокет через таймер — просто реализовать, но это создаёт задержку и лишнюю нагрузку;
  • выделить отдельный поток для каждого запроса — может обойти проблему polling, но плохо масштабируется;
  • зарегистрировать waker в обработчике готовности сокета — требует аккуратной синхронизации, зато сохраняет событийную модель и масштабируется лучше.

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

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

  1. Достаточно ли вызвать wake до возврата Poll::Pending?

    Да, это допустимо: runtime получит уведомление и сможет запланировать новый poll. Однако это не означает, что future уже готов вернуть Ready; повторный poll должен снова проверить состояние операции.

    На практике немедленное самопробуждение полезно для разбиения работы на шаги, но бесконтрольное его применение превращает future в busy loop. Для ожидания внешнего события waker следует вызывать именно при наступлении или обнаружении этого события.

  2. Обязан ли future вызывать wake при каждом возврате Pending?

    Нет. Он обязан обеспечить корректное уведомление, когда появится возможность прогресса. Если waker уже зарегистрирован у надёжного источника события, повторный вызов для каждого poll не нужен.

    Например, future чтения из сокета может один раз зарегистрировать waker, вернуть Pending, а затем дождаться уведомления от подсистемы ввода-вывода. После пробуждения он снова проверяет сокет и либо возвращает Ready, либо повторно регистрирует ожидание.

  3. Что произойдёт, если future возвращает Ready, но продолжает будить задачу?

    Это ошибка реализации. После Ready future считается завершённым, а runtime больше не должен его опрашивать; сохранённые внешние уведомления могут привести к ненужным пробуждениям, утечкам ресурсов или обращениям к уже недействительному состоянию.

    Поэтому после завершения нужно отменить регистрацию внешнего события или сделать обработчик безопасным для поздних уведомлений. Сам Future должен соблюдать правило: после возврата Ready дальнейший вызов poll не является нормальным рабочим сценарием и не должен использоваться как способ продолжить операцию.