Проверяем переход от callback к async в Swift на облачном Mac
В локальных тестах callback завершения старого SDK работал без сбоев. Но после переноса на облачный Mac и запуска операций параллельно continuation изредка возобновляется повторно, а после отмены задача иногда так и не получает результат. Обычно дело не в производительности машины, а в двух предположениях адаптера: «callback вызывается только один раз» и «callback всегда приходит асинхронно». Ниже уберём оба предположения и проверим граничные случаи отдельной программой на Swift — без реальной сети и ожиданий наугад.
Сначала определим, что задаёт окончательный результат
Здесь рассматривается одноразовая операция: из успеха, ошибки и отмены действует тот результат, который первым зафиксирован в машине состояний. Последующие уведомления уже не меняют результат и не могут повторно возобновить ожидающую задачу. Для потоковых интерфейсов и интерфейсов с многократными обновлениями такой контракт не подходит.
Нужно хранить состояние двух составляющих: установлен ли continuation и определён ли окончательный результат. Отмена может произойти до установки continuation, поэтому одной опциональной переменной недостаточно, чтобы понять, завершена ли задача.
Checked continuation выявляет ошибки использования, но не устраняет повторные уведомления и не превращает отмену задачи в отмену нижележащей операции автоматически.
Здесь действует правило «первый зафиксированный результат побеждает», а не «отмена всегда имеет приоритет». Если успех уже зафиксирован, последующая отмена не может его заменить, даже если возобновлённая задача ещё не получила время на выполнение. Если бизнес-логика требует более строгого приоритета отмены, нужно отдельно определить точку фиксации результата, а не добавлять произвольные проверки вокруг возвращаемого значения.
Защитим состояние завершения короткими критическими секциями
Сохраните следующий код в BridgeCheck.swift. Для примера нужны Swift 6 и macOS SDK с поддержкой NSLock.withLock. Блокировка защищает только чтение и изменение состояния. Внешние callback, запуск запроса и возобновление continuation выполняются вне блокировки.
import Foundation
final class CompletionGate<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var outcome: Result<Value, any Error>?
private var waiter: CheckedContinuation<Value, any Error>?
func install(_ continuation: CheckedContinuation<Value, any Error>) {
let ready: Result<Value, any Error>? = lock.withLock {
if let outcome {
return outcome
}
precondition(waiter == nil)
waiter = continuation
return nil
}
if let ready {
continuation.resume(with: ready)
}
}
func finish(_ result: Result<Value, any Error>) {
let continuation: CheckedContinuation<Value, any Error>? =
lock.withLock {
guard outcome == nil else {
return nil
}
outcome = result
let current = waiter
waiter = nil
return current
}
continuation?.resume(with: result)
}
}
func bridge(
start: @Sendable (
@escaping @Sendable (Result<Int, any Error>) -> Void
) -> Void
) async throws -> Int {
let gate = CompletionGate<Int>()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Int, any Error>) in
gate.install(continuation)
start { result in
gate.finish(result)
}
}
} onCancel: {
gate.finish(.failure(CancellationError()))
}
}
@unchecked Sendable не означает, что проверки можно отключить и забыть о безопасности. Эта аннотация — обещание со стороны разработчика: все общие изменяемые поля защищены одной и той же блокировкой, а значение результата соответствует Sendable. Если позже появятся счётчики или дескрипторы, их синхронизацию тоже необходимо проверить.
Установка continuation и завершение могут произойти только в двух порядках. Если сначала установлен continuation, завершение извлекает его из состояния и возобновляет. Если сначала пришло завершение, результат сохраняется, а continuation возобновляется при установке. Очистка поля ожидающего continuation позволяет своевременно освободить ссылку, но признаком завершения всегда остаётся состояние результата. Пустое поле не означает, что операция снова открыта для завершения.
Проверим ключевые порядки событий детерминированными примерами
Добавьте следующую точку входа в тот же файл. Первый пример намеренно вызывает callback синхронно два раза. Второй запускает операцию, которая никогда не вызывает callback, а затем отменяет ожидающую задачу. Ни в одном случае не используется пауза в попытке угадать порядок работы планировщика.
@main
struct BridgeCheck {
static func main() async throws {
let value = try await bridge { complete in
complete(.success(7))
complete(.success(9))
}
precondition(value == 7)
let pending = Task {
try await bridge { _ in }
}
pending.cancel()
do {
_ = try await pending.value
preconditionFailure("Expected cancellation")
} catch is CancellationError {
}
}
}
xcrun swiftc -parse-as-library -swift-version 6 \
BridgeCheck.swift -o BridgeCheck
./BridgeCheck
При штатном выполнении программа должна завершиться с нулевым кодом выхода. Синхронное завершение выявляет ошибочную реализацию «сначала запустить, потом сохранить continuation». Повторное уведомление обнаруживает незащищённое повторное возобновление. Проверка отмены показывает, может ли ожидающая задача завершиться, если callback не приходит.
После добавления этих проверок в тесты проекта нужно также напрямую проверить машину состояний в порядке «сначала завершение, потом установка» и отдельно выполнить два варианта: успех перед ошибкой и ошибку перед успехом. Нагрузочные тесты с конкурентным выполнением расширяют покрытие, но не заменяют сценарии с явно заданным порядком событий.
Отмена ожидания не останавливает нижележащую операцию
Этот адаптер гарантирует завершение ожидающей задачи, но не останавливает нижележащую работу. Если отмена пришла первой, start всё равно может выполниться, а последующие уведомления будут проигнорированы. Это намеренно оставленная граница ответственности: пример нельзя использовать как готовый полноценный сетевой клиент.
Как расширить решение при наличии дескриптора отмены
Если реальный интерфейс возвращает дескриптор отмены, его нужно регистрировать в том же защищённом состоянии. Если отмена пришла раньше, сохраните соответствующий флаг; когда дескриптор вернётся, сразу извлеките его и отмените операцию. Если дескриптор получен первым, его извлекает путь обработки отмены. Сам вызов отмены по-прежнему должен выполняться вне блокировки, поскольку он может синхронно вызвать callback завершения.
Однократной проверки отмены перед запуском недостаточно. Между успешной проверкой и возвратом дескриптора всё ещё остаётся гонка. Нельзя и вызывать отмену под блокировкой: если синхронный callback повторно войдёт в машину состояний, возможна взаимная блокировка.
Если нижележащая операция никогда не присылает уведомление, а вызывающая сторона не отменяет задачу, ожидание продолжится. Тайм-аут, предусмотренный бизнес-логикой, должен проходить через ту же точку завершения и одновременно обеспечивать освобождение нижележащих ресурсов. Нельзя рассчитывать на то, что у continuation есть встроенный тайм-аут.
Включим проверку в процесс выполнения на облачном Mac
Компилируйте и запускайте эту проверку на облачном Mac перед основной сборкой, сохраняя версию компилятора, идентификатор коммита и код выхода. Примеру не нужен графический сеанс, поэтому его удобно многократно запускать из командной строки.
Перед слиянием проверьте четыре условия: у каждого вызова своя машина состояний; синхронный callback не теряется; повторные уведомления не приводят к повторному возобновлению; после отмены не остаётся зависшей ожидающей задачи. Для реального интерфейса с дескриптором добавьте отдельный тест «отмена раньше возврата дескриптора».
В завершение проверьте, какие объекты удерживаются замыканиями. Даже после окончания ожидания нижележащая операция может продолжать хранить замыкание завершения. Устранение повторных завершений в машине состояний не решает всех проблем жизненного цикла. Асинхронную обёртку можно считать законченной только после раздельной проверки контракта завершения, контракта остановки и освобождения ресурсов.
Часто задаваемые вопросы
Checked continuation игнорирует повторный callback?
Нет. Повторный resume нарушает контракт и может вызвать ошибку времени выполнения. Нужно выбрать единственный результат под блокировкой, а resume выполнить после её освобождения.
Отмена задачи останавливает исходный запрос?
Не автоматически. Адаптер завершает ожидание ошибкой отмены. Для остановки запроса нужен его собственный механизм отмены с учётом момента получения дескриптора.
Поручите следующую сборку выделенному облачному Mac
MacFTP предоставляет выделенные физические компьютеры на Apple Silicon с оригинальной macOS. Выберите срок аренды: день, неделя, месяц или квартал. Сначала проверьте конфигурацию, затем подключите к узлу привычные инструменты. Фактический статус доступности отображается в панели управления.