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

В однопоточном async runtime CPU bound future не содержит точек ожидания: почему она может блокировать выпо...

В однопоточном async runtime CPU-bound future не содержит точек ожидания: почему она может блокировать выполнение других задач?

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

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

Async-задачи планируются кооперативно: runtime получает управление только во время вызова poll, а задача возвращает его при Pending или завершении. Если CPU-bound future долго выполняется внутри одного poll и не достигает .await, другие задачи на этом потоке не получают процессорное время.

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

Асинхронная модель появилась как способ обслуживать большое число операций ввода-вывода без выделения отдельного системного потока на каждую операцию. Future обычно выполняется небольшими фрагментами: runtime вызывает её, она быстро доходит до ожидания события, возвращает Pending, а затем продолжает работу после уведомления.

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

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

CPU-bound код — например, перебор большого диапазона, сжатие данных или вычисление хеша — может долго выполняться без ожидания ввода-вывода. В однопоточном runtime такой код занимает поток, на котором также должны выполняться сетевые события, таймеры и другие async-задачи.

Последствиями становятся рост задержек, просроченные таймеры, заполнение буферов и снижение пропускной способности всего runtime. Добавление async перед функцией само по себе не делает вычисление прерываемым или параллельным.

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

Runtime вызывает poll future. Пока future синхронно выполняет CPU-bound участок, runtime не может вызвать poll другой задачи на том же потоке. Передача управления происходит только после возврата из poll, обычно через Pending на .await, либо после Ready.

Практический способ разделить вычисление на части — периодически добровольно уступать управление:

async fn calculate() -> u64 { let mut sum = 0; for part in 0..1_000_000 { sum += part; if part % 10_000 == 0 { tokio::task::yield_now().await; } } sum }

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

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

На многопоточном runtime CPU-bound задача может занять один worker-поток, тогда как другие задачи продолжат выполняться на остальных. Однако это не устраняет проблему полностью: большое число таких задач способно исчерпать worker-пул, а одна задача без точек уступки всё равно ухудшает локальную справедливость на занятом потоке.

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

Сервис принимает HTTP-запросы и после каждого запроса вычисляет крупный хеш. После добавления async к обработчику средняя загрузка CPU не изменилась, но задержка запросов резко выросла: вычисление выполнялось внутри одного вызова poll и задерживало сетевые задачи.

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

Был выбран ограниченный пул CPU-задач, а размер очереди связали с допустимой задержкой сервиса. В результате сетевой runtime сохранил отзывчивость, а перегрузка стала выражаться контролируемым отклонением новых вычислений, а не блокировкой всех запросов.

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

  1. Достаточно ли добавить один .await в конец CPU-bound функции?

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

  1. Устраняет ли многопоточный runtime проблему полностью?

Нет. Он позволяет другим задачам выполняться на свободных worker-потоках, но число таких потоков ограничено. Несколько CPU-bound задач могут занять весь пул, а задачи, закреплённые за конкретным потоком или зависящие от его событий, всё равно столкнутся с задержками.

  1. Почему частая передача управления не всегда является лучшим решением?

Каждая уступка требует повторного планирования задачи и может ухудшить использование CPU из-за лишних переключений. Кроме того, уступка не создаёт дополнительной вычислительной мощности и не предотвращает накопление большого числа CPU-bound задач. Если работа длительная и независимая от async-состояния, обычно лучше использовать ограниченный пул вычислительных потоков; уступку следует применять для небольших участков, когда важно сохранить выполнение в async-контексте.