Обработка изображений обычно состоит из трёх этапов: скачать, преобразовать, записать в каталог. Но бывает, что загрузка завершается ошибкой, а задача преобразования всё равно запускается. Или пользователь нажимает «Отмена», а запись файла продолжается. Чаще всего дело не в неисправности очереди, а в смешении трёх разных понятий: завершения зависимости, успешного результата и запроса отмены. В этой статье мы воспроизведём эти пограничные ситуации в отдельной программе на Swift на облачном Mac, а затем перенесём проверки в тестовый таргет iOS. Доступ к сети и учётные данные для подписи не понадобятся.
Сначала определим, за что очередь не отвечает
Зависимости в OperationQueue требуют лишь того, чтобы предшествующая операция перешла в завершённое состояние. Очередь не понимает, завершилась ли загрузка или обработка успешно с точки зрения прикладной логики. Вызов cancel() тоже лишь устанавливает флаг отмены, а не принудительно останавливает выполняющееся замыкание.
Для каждого этапа обработки задайте четыре конечных состояния: успех, ошибка, пропуск и отмена. Если зависимость завершилась ошибкой, фиксируйте пропуск. Если этап уже выполняется и получает запрос отмены, завершайте его в точке, где остановка безопасна. Не используйте один логический флаг одновременно для обозначения «не выполнялось» и «выполнилось с ошибкой».
isFinishedозначает, что жизненный цикл операции завершён, а не что обработка прошла успешно.isCancelledозначает, что получен запрос отмены, а не что работа уже остановлена.
В примере используются только синхронные BlockOperation. Если замыкание запускает асинхронный запрос и сразу возвращает управление, очередь считает выполнение замыкания завершённым. В таком случае описанные здесь зависимости не позволяют дождаться обратного вызова запроса. Для асинхронных операций нужна отдельная реализация с корректным управлением состояниями жизненного цикла.
Явный результат не даст продолжить работу после сбоя
Сохраните следующий код в QueueContract.swift. Чтение и запись результатов защищены блокировкой, чтобы добавление параллельных этапов в дальнейшем не привело к гонке данных. Пример не полагается на случайно сложившийся порядок запуска задач.
import Foundation
enum Outcome: Equatable {
case pending
case succeeded
case failed
case skipped
}
final class State: @unchecked Sendable {
private let lock = NSLock()
private var values: [String: Outcome] = [:]
func set(_ key: String, _ value: Outcome) {
lock.lock()
defer { lock.unlock() }
values[key] = value
}
func get(_ key: String) -> Outcome {
lock.lock()
defer { lock.unlock() }
return values[key] ?? .pending
}
}
let state = State()
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 2
let download = BlockOperation {
state.set("download", .failed)
}
let convert = BlockOperation {
guard state.get("download") == .succeeded else {
state.set("convert", .skipped)
return
}
state.set("convert", .succeeded)
}
convert.addDependency(download)
queue.addOperations([convert, download], waitUntilFinished: true)
precondition(state.get("download") == .failed)
precondition(state.get("convert") == .skipped)
precondition(download.isFinished && convert.isFinished)
Скомпилируйте и запустите программу в терминале:
xcrun swiftc QueueContract.swift -o QueueContract
./QueueContract
Нулевой код выхода означает, что проверки пройдены; выводить сообщение об успехе необязательно. Если изменить результат загрузки на успешный, нужно также проверить успешное преобразование — так получатся два сценария: успешный и ошибочный. @unchecked Sendable — лишь ручная гарантия потокобезопасности со стороны разработчика. Она обоснованна, только если любой доступ к общему состоянию защищён блокировкой. Предоставлять доступ к внутреннему словарю извне нельзя.
Отмену в очереди и отмену во время выполнения проверяем отдельно
Сначала проверим задачу, которая ещё не начала выполняться. Добавьте следующий фрагмент в тот же файл и перекомпилируйте программу. Отдельная очередь исключает влияние на предыдущий сценарий.
let heldQueue = OperationQueue()
heldQueue.isSuspended = true
let cancelledState = State()
let waiting = BlockOperation {
cancelledState.set("body", .succeeded)
}
heldQueue.addOperation(waiting)
waiting.cancel()
heldQueue.isSuspended = false
heldQueue.waitUntilAllOperationsAreFinished()
precondition(waiting.isCancelled)
precondition(waiting.isFinished)
precondition(cancelledState.get("body") == .pending)
Приостановленная очередь гарантирует, что отмена произойдёт до начала выполнения: подбирать задержку в миллисекундах не нужно. Проверки охватывают одновременно флаг отмены, окончательное завершение операции и тот факт, что замыкание не выполнялось. Если убрать любую из них, в тесте останется слепая зона.
Для выполняющейся задачи нужен отдельный сценарий. Когда задача входит в цикл обработки, она отправляет сигнал «работа началась». Тест вызывает отмену только после получения этого сигнала, а затем разрешает перейти к следующей итерации. На каждой итерации проверяйте isCancelled перед чтением данных и перед фиксацией результата. Ожидание сигнала должно иметь тайм-аут; его истечение означает провал теста. Тест не должен зависать навсегда.
Замыкание, которое к моменту отмены ещё не было запущено, не запишет конечное состояние обработки. Поэтому состояние отмены должен дополнительно зафиксировать слой оркестрации. Не возлагайте очистку на замыкание, которое вообще не выполнялось.
При записи результата нужна граница фиксации
Кооперативная отмена срабатывает только в точках проверки. Если одно преобразование занимает много времени, задержка реакции на отмену может составить как минимум всё оставшееся время текущего этапа преобразования. Разбить длительную работу на разумные порции полезнее, чем часто опрашивать флаг в пустом цикле.
Сначала записывайте файл во временное расположение, выделенное конкретной задаче, а после закрытия дескриптора фиксируйте результат. При ошибке или отмене удаляйте только собственные временные файлы, а не общий каталог. Если запрос отмены поступил после успешной фиксации, сохраняйте конечное состояние «успех». Если задним числом заменить его на «отмена», вызывающая сторона ошибочно решит, что результата нет.
Отмена и фиксация могут произойти одновременно. Если прикладная логика требует строгой взаимоисключаемости этих действий, решения об отмене и фиксации должны проходить через одну и ту же блокировку или последовательный автомат состояний. Однократная проверка флага перед записью не устраняет гонку, которая может возникнуть после неё.
В регрессионных тестах проверяем конечное состояние целиком
При переносе примера в XCTest замените precondition тестовыми проверками. В задаче командной строки на облачном Mac допустимо ждать завершения очереди. В главном потоке приложения синхронно ждать не следует; особенно недопустима ситуация, когда сама задача при этом ждёт обратного вызова в главном потоке.
В каждом прогоне охватывайте как минимум следующие сценарии: успех предшествующей операции, её сбой, отмена до начала выполнения, отмена во время выполнения и отмена после фиксации результата. Для каждого сценария проверяйте одновременно конечное состояние обработки, наличие результата и число уведомлений о завершении. Уведомление должно отправляться ровно один раз. И отсутствие обратного вызова, и повторный вызов должны приводить к провалу теста.
Всегда задавайте порядок событий управляемыми сигналами, а не пытайтесь получить якобы стабильный момент с помощью sleep. В итоге должен остаться воспроизводимый контракт: сбой зависимости не приводит к ошибочной фиксации результата, после отмены работа может завершиться, а очистка выполняется только после завершения. Лишь при соблюдении этих условий появляется надёжная основа для увеличения степени параллелизма.
Часто задаваемые вопросы
Пропускается ли зависимая операция автоматически после ошибки?
Нет. Зависимость задаёт только порядок запуска. Ошибку нужно передать через потокобезопасное состояние результата, а следующая операция должна явно отказаться от выполнения.
Можно ли удалять рабочие файлы сразу после cancelAllOperations?
Нет. Работающие операции ещё могут обращаться к файлам. Запросите отмену, дождитесь завершения вне главного потока и только затем выполняйте очистку.
Подготовьте облачный Mac к следующей сборке
Выберите модель и срок аренды под свои задачи. Выделенный физический узел Apple Silicon, не виртуальная машина.
Арендовать облачный Mac