В цикле async задачи регулярно вызывают Task.yield : какой гарантии это не даёт?

В цикле async-задачи регулярно вызывают Task.yield(): какой гарантии это не даёт?

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

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

Task.yield() не гарантирует, что другая конкретная задача немедленно получит процессор, начнёт выполняться или завершится. Он лишь добровольно приостанавливает текущую задачу и предоставляет планировщику возможность запустить другие готовые задачи.

Это средство кооперативного планирования, а не синхронизации. Оно не заменяет акторы, блокировки, ожидание результата или другие механизмы защиты данных.

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

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

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

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

Рассмотрим вычислительный цикл, который не содержит естественных операций await: например, обработку большого массива или перебор пространства поиска. Если такой цикл надолго не приостанавливается, он может задерживать выполнение других задач на том же исполнителе.

Добавление Task.yield() уменьшает риск монополизации исполнителя, но не устанавливает порядок выполнения. После уступки текущая задача может снова оказаться выбранной первой, а другая задача может быть занята, отменена, иметь меньший приоритет или вообще отсутствовать.

Ошибочно использовать yield как способ дождаться изменения состояния, сделать операцию атомарной или гарантировать, что другая задача уже выполнила действие. Такое решение создаёт гонки и нестабильное поведение, зависящее от планировщика.

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

Вызов Task.yield() создаёт точку приостановки текущей задачи. Планировщик получает возможность выбрать другую готовую работу, однако выбор остаётся за ним: нет гарантии конкретной задачи, количества переключений или фактического прогресса другого кода.

Task.yield() также не делает последующие операции атомарными. Если две задачи обращаются к общему изменяемому состоянию, уступка не защищает его и не устраняет гонку. Для безопасной изоляции состояние следует поместить в actor, использовать другой подход, проверенный моделью конкурентности Swift, либо синхронизировать доступ подходящим примитивом.

Уступка не является ожиданием результата и не передаёт автоматически отмену в произвольную операцию. Отмена в Swift кооперативная: после отмены задача должна сама проверять состояние отмены или вызывать API, которое реагирует на отмену. Task.yield() не превращает вычислительный цикл в автоматически отменяемый.

func process(_ values: [Int]) async -> Int { var result = 0 for (index, value) in values.enumerated() { result += value * value if index % 1_000 == 0 { await Task.yield() } } return result }

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

Частота уступок — компромисс. Слишком редкие вызовы ухудшают отзывчивость, слишком частые увеличивают накладные расходы и могут снизить пропускную способность. Границу обычно выбирают по измерениям, размеру порции и требованиям к задержке.

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

Сервис индексирует десятки миллионов записей в async-задаче. Без уступок интерфейс и фоновые операции получают меньше возможностей для выполнения. Команда добавляет Task.yield() после каждой записи, но измерения показывают чрезмерные накладные расходы.

Первый вариант — уступать после каждой записи. Он улучшает потенциальную отзывчивость, но слишком часто передаёт управление планировщику. Второй вариант — не уступать вовсе: пропускная способность выше, однако длинные участки вычислений могут задерживать другие задачи.

Выбранный вариант — обрабатывать фиксированные порции и вызывать Task.yield() между ними, например после нескольких тысяч записей. Дополнительно цикл явно проверяет отмену, а разделяемая статистика изолирована в actor. Так yield используется для распределения вычислительного времени, а не ошибочно применяется для синхронизации данных.

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

  1. Обеспечивает ли Task.yield() честное чередование двух задач?

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

  1. Можно ли с помощью Task.yield() исправить гонку при увеличении общего счётчика?

Нет. Уступка может даже сделать межфазное переключение более вероятным, но не делает чтение, изменение и запись счётчика единой атомарной операцией. Нужна изоляция состояния, например actor, либо специализированная синхронизация, совместимая с конкретным сценарием.

  1. Достаточно ли Task.yield() для отмены долгого вычисления?

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