В многопоточном Tokio одна задача выполняет длинные серии быстрых операций внутри одного poll; как кооперативный бюджет предотвращает голодание остальных задач?
Кооперативный бюджет ограничивает объём работы, который задача может выполнить за один проход poll. Когда бюджет исчерпан, поддерживаемая Tokio операция добровольно возвращает Poll::Pending, поэтому исполнитель получает возможность запланировать другие задачи.
Это не аппаратное прерывание: задача без точек кооперативного уступания, например с бесконечным чистым циклом CPU, по-прежнему может заблокировать рабочий поток.
Асинхронные задачи в Rust используют кооперативное планирование: исполнитель переключает задачу, когда её future возвращает Pending. Такой подход дешевле вытесняющего переключения потоков и позволяет сохранять состояние задачи в future.
Проблема возникает, когда future постоянно готова немедленно. Ожидание формально присутствует, но не приводит к реальной паузе, поэтому одна задача может получать непропорционально много времени. Tokio применяет кооперативное ограничение, чтобы повысить справедливость выполнения.
Future может выполнять цикл обработки сообщений или элементов потока, а операции чтения в нём почти всегда завершаться сразу из-за данных в буфере. В таком случае каждое ожидание возвращает готовый результат, и задача продолжает выполняться без фактической передачи управления.
В многопоточном runtime это обычно блокирует один рабочий поток, а не весь процесс. Однако другие задачи, закреплённые за ним, могут испытывать задержки; work stealing не исправляет ситуацию, пока задача не станет доступной для перепланирования.
Tokio ведёт бюджет кооперативной работы для текущей задачи. Бюджет уменьшается только в операциях, интегрированных с механизмом кооперативности, например в некоторых операциях каналов, потоков и сетевого ввода-вывода.
После исчерпания бюджета такая операция перестаёт немедленно возвращать готовый результат и сигнализирует Pending. Исполнитель сохраняет waker и позже снова опрашивает future, уже с восстановленным бюджетом. Это создаёт ограничение на непрерывную работу одной задачи без вытеснения её системным потоком.
Механизм не является общей гарантией Rust и не анализирует произвольные вычисления внутри пользовательского future. Если код выполняет только CPU-операции и не вызывает кооперативные точки, runtime не может безопасно прервать его посреди инструкции или произвольной критической секции.
Для длинного пользовательского цикла применяют явные точки уступания, например yield_now().await или consume_budget().await, разбивают работу на ограниченные порции либо переносят CPU-bound вычисление в spawn_blocking. yield_now улучшает отзывчивость, но не обещает строгий порядок выполнения задач; spawn_blocking изолирует блокирующую работу, однако её отмена после начала выполнения не является принудительным прерыванием.
Кооперативный бюджет также не гарантирует равенство времени между задачами. Его точный размер, набор бюджетируемых операций и детали планирования относятся к реализации runtime и могут меняться. Поэтому latency-критичный код должен сам ограничивать размер порций и измерять задержки.
Сервис читает записи из async-потока и для каждой выполняет быстрый разбор. При высокой нагрузке чтение почти всегда готово немедленно, а обработчик долго не даёт обслуживать heartbeat-задачи.
Вариант с одним большим циклом без уступания прост, но допускает starvation. Безусловный yield_now после каждой записи улучшает отзывчивость, однако добавляет лишние переключения и не задаёт строгой справедливости. Перенос всей обработки в spawn_blocking разгружает async worker, но требует контроля размера пула и стоимости передачи данных.
Практичное решение — обрабатывать ограниченную порцию записей, периодически проверять кооперативный бюджет и уступать управление. Если разбор действительно CPU-bound и занимает заметное время, его выносят в spawn_blocking; результатом становятся предсказуемее задержки async-задач и сохранение пропускной способности.
await автоматически передаёт управление другой задаче?Нет. await опрашивает вложенный future. Если тот сразу возвращает Ready, внешняя задача может продолжить выполнение в том же проходе poll. Реальная уступка возникает только при возврате Pending, явной точке yield либо срабатывании поддерживаемого кооперативного механизма.
Нет. Она позволяет выполнять разные задачи на разных worker-потоках, но задача, долго занимающая один worker, всё равно задерживает задачи, попавшие на него. Перераспределение помогает только после того, как задача уступила управление или стала перепланируемой; это не вытесняющее прерывание.
Tokio не сможет автоматически прервать произвольный CPU-код. Если цикл не содержит await, явного yield или другой точки кооперации, его нужно переписать с периодической уступкой, разбить на порции или выполнить в блокирующем пуле. Иначе одна задача может полностью занять worker-поток независимо от наличия кооперативного бюджета.