Проверка таймаутов Swift: отмена и выход из группы задач
При передаче работы над асинхронным кодом на облачном Mac между командами в разных часовых поясах особенно трудно воспроизвести одну ситуацию: интерфейс уже сообщает о таймауте, а тест всё ещё не завершается. Обычно дело не в неисправном таймере, а в том, что нижележащая операция не реагирует на отмену. Поместив работу и таймер в группу задач, мы лишь организуем гонку между ними, но не получаем способ принудительно прервать произвольный код.
Ниже мы построим исполняемую базовую реализацию на основе структурированной конкурентности Swift, а затем отдельно проверим результат, отмену и фактическое завершение. Для примера нужен инструментарий с поддержкой Duration и Task.sleep(for:); запуск из командной строки требует macOS 13 или новее. При передаче работы другой команде следует зафиксировать фактически использованную версию компилятора.
Сначала определите контракт таймаута, а не пишите таймер
Зафиксируйте три правила: если первой завершается работа, возвращается её результат; если первым наблюдается завершение ветви таймера, выбрасывается ошибка таймаута; отмена родительской задачи распространяется дальше и не преобразуется в таймаут прикладной операции. Если обе ветви завершаются почти одновременно, заранее определённый победитель не гарантируется.
Также нужно различать относительное ожидание и жёсткий предельный срок. В приведённом ниже примере отсчёт начинается, когда ветвь таймера приступает к ожиданию. Это не абсолютный предел времени ответа, отсчитываемый с момента входа в вызов. Очень короткий интервал ожидания тоже не устраняет задержки планирования.
Срабатывание таймаута, отправка запроса на отмену и завершение нижележащей операции — три разных события. Только после последнего из них группа задач получает возможность завершиться.
Если бизнес-требования предписывают вернуть управление немедленно по истечении срока, нельзя одновременно утверждать, что вся работа остаётся в рамках этой группы задач. Потребуется пересмотреть интерфейс отмены нижележащей операции либо вынести непрерываемую работу в отдельный процесс и управлять его жизненным циклом.
Реализуйте гонку с подменяемой логикой ожидания
Сохраните следующий код в Timeout.swift. Замыкание ожидания позволяет использовать реальный таймер, а в тестах — заменить его управляемым барьером. Результат работы должен удовлетворять требованиям передачи между задачами.
import Foundation
enum TimeoutFailure: Error {
case expired
}
func raceTimeout<Value: Sendable>(
wait: @escaping @Sendable () async throws -> Void,
operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
try Task.checkCancellation()
return try await withThrowingTaskGroup(of: Value.self) { group in
defer { group.cancelAll() }
group.addTask {
try Task.checkCancellation()
return try await operation()
}
group.addTask {
try Task.checkCancellation()
try await wait()
try Task.checkCancellation()
throw TimeoutFailure.expired
}
guard let value = try await group.next() else {
throw CancellationError()
}
try Task.checkCancellation()
return value
}
}
Сначала проверьте типы и ограничения конкурентности командой компиляции, затем подключайте реализацию к тестам проекта:
xcrun swiftc -swift-version 6 -strict-concurrency=complete \
-parse-as-library -emit-module \
-module-name Timeout Timeout.swift \
-emit-module-path /tmp/Timeout.swiftmodule
Пример вызова с реальным таймером приведён ниже. Вызывающий код должен выполняться в асинхронном контексте:
let result = try await raceTimeout(
wait: { try await Task.sleep(for: .seconds(2)) },
operation: {
try await Task.sleep(for: .milliseconds(20))
return "ready"
}
)
Блок defer гарантирует, что запрос на отмену оставшейся ветви будет отправлен и при успехе, и при ошибке. При отмене таймер выбрасывает ошибку. Не подавляйте её с помощью try?: иначе выполнение может продолжиться, и отмена будет ошибочно представлена как таймаут.
Управляйте порядком через барьеры, а не случайные задержки
Основные модульные тесты не должны строиться по принципу «немного подождём и угадаем, какая ветвь запустилась первой». Создайте два тестовых барьера, отдельно управляющих завершением работы и завершением отсчёта таймера. Дополнительно предусмотрите сигналы запуска: открывать нужный барьер следует только после подтверждения, что обе ветви уже перешли к ожиданию.
Барьер должен сохранять состояние отмены и освобождать ожидающую задачу через обработчик отмены. Если внутри используется continuation, необходимо гарантировать его возобновление ровно один раз. Нужно также проверить порядок, при котором отмена приходит раньше регистрации ожидающей задачи. Иначе сама тестовая оснастка станет причиной зависаний.
Сохраните как минимум следующие четыре набора проверок:
- Побеждает работа: возвращается заданное значение, а ожидание таймера завершается, что подтверждается наблюдением.
- Побеждает таймер: получена ошибка
TimeoutFailure.expired, а работа завершается после получения отмены. - Работа завершается с ошибкой: исходная прикладная ошибка сохраняется и не подменяется таймаутом.
- Отменяется родительская задача: после запуска обеих ветвей отмените родительскую задачу и проверьте распространение отмены и все сигналы завершения.
В каждом наборе нужно не только проверить результат вызова, но и дождаться отметок завершения обеих ветвей. Правильный результат при незавершившейся дочерней задаче ещё не означает, что проверка пройдена.
Отдельно докажите, что игнорирование отмены мешает завершению
Добавьте барьер работы, который намеренно игнорирует отмену, но может быть открыт тестом. Сначала дайте победить ветви таймера. Затем убедитесь, что работа уже получила запрос на отмену, а внешний вызов всё ещё не завершился. В конце откройте барьер работы и подтвердите, что только после этого внешний вызов выбрасывает ошибку таймаута.
Этот сценарий демонстрирует семантику ожидания в структурированной конкурентности, а не рекомендует игнорировать отмену. В тесте обязательно должен быть предусмотрен гарантированный способ открыть барьер. Нельзя имитировать зависание бесконечным циклом или оставлять continuation, который никогда не будет возобновлён после провала проверки.
В производственном коде нужно проверить всю цепочку вызовов: выполняют ли длинные циклы периодически Task.checkCancellation(); вызывает ли адаптер обратных вызовов нижележащий механизм отмены; способны ли блокирующие вызовы вернуть управление. Однократная проверка при входе в операцию не покрывает последующую длительную работу.
Передавайте следующей смене и подтверждения завершения
При запуске на облачном Mac записывайте во вложения к тестам идентификатор коммита, версию компилятора, имя сценария, победившую ветвь, наблюдение отмены и две отметки завершения. Длительность вычисляйте по монотонным часам, а не вычитанием показаний системного времени на компьютерах разных участников команды.
Тесты с реальными часами должны проверять только правильность подключения производственного замыкания ожидания. Оставляйте запас на планирование и не требуйте точности до конкретной миллисекунды. Самому тестовому процессу также нужен независимый лимит времени выполнения: незавершающаяся группа задач не может спасти себя собственным дочерним таймером.
В завершение убедитесь, что обработка ошибок не превращает все случаи CancellationError в таймаут, замыкание ожидания не подавляет ошибки, а пути с ошибками не теряют подтверждения завершения. Критерий готовности к передаче — не «таймер сработал», а объяснимый результат, наблюдаемая отмена и завершившаяся оставшаяся работа.
Часто задаваемые вопросы
Функция сразу вернётся после ошибки в задаче таймера?
Не обязательно. Перед выходом группа ждёт завершения всех дочерних задач. cancelAll лишь запрашивает отмену и не останавливает операцию принудительно.
Нужно ли ждать несколько секунд в каждом тесте?
Нет. В основных тестах внедрите управляемое ожидание и задавайте порядок завершения через тестовые барьеры. Для немногочисленных проверок реального времени установите отдельный лимит выполнения.
Выберите облачный Mac для следующей задачи
Выберите сервер в регионе, где находится основная часть команды, и получите в полное распоряжение физический Mac mini. Для удалённого рабочего стола в браузере нужны современный браузер и стабильное подключение к интернету. Перед заказом проверьте конфигурацию, период оплаты и фактическую доступность.