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

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

Что означает строгий порядок отправки?

Представим, что настольный калькулятор отправляет задания A, B и C. Требуемое условие: вычисление A завершается до начала вычисления B, а B завершается до начала C. Если запустить три независимые задачи, а затем ожидать их в алфавитном порядке, это упорядочит лишь получение результатов.

Граница отправки имеет значение. В этой реализации отправка происходит при выполнении изолированного главным актором метода submit, который устанавливает следующую связь в цепочке. Последовательные синхронные вызовы из одного контекста главного актора задают порядок A, B, C без приостановок между ними.

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

В этой схеме главный актор используется для короткого шага учёта. Apple определяет MainActor как актор-одиночку, исполнитель которого эквивалентен главной очереди dispatch. Само вычисление ниже выполняется отдельным рабочим актором. Документация MainActor

Почему недостаточно актора или последовательной очереди операций?

Актор выполняет изолированную для него работу по одной части за раз, но асинхронный метод может приостановиться и позволить продолжить другому вызову. В описании модели акторов Swift также прямо сказано, что задачи, ожидающие актора, не гарантированно выполняются в порядке, в котором они его ожидали. Поэтому само по себе помещение всего асинхронного вычисления внутрь актора не гарантирует, что следующая задача начнётся только после завершения предыдущей. SE-0306: Акторы

Создание отдельных экземпляров Task также не устанавливает зависимостей между ними. Задачи могут начать выполняться после создания; сохранённые дескрипторы позволяют ожидать их или отменять, но не задают последовательность выполнения. Документация Task

Foundation предлагает другой механизм упорядочивания. OperationQueue планирует операции с учётом готовности, приоритета и зависимостей; Apple рекомендует использовать зависимости, когда важен конкретный порядок выполнения. Установка maxConcurrentOperationCount в 1 ограничивает параллелизм, но для требования порядка всё равно нужны явные зависимости. Документация OperationQueue

Выбирайте механизм с учётом потребностей приложения:

Ситуация Подходящий вариант Что задаёт порядок?
Один известный пакет в одной асинхронной функции Цикл, который ожидает каждое вычисление перед продолжением Последовательное управление потоком
Задания поступают со временем в результате действий пользователя за компьютером Синхронный метод отправки с цепочкой задач Каждое вычисление ожидает сигнал завершения предшественника
Уже используются операции Foundation Зависимости, настроенные до добавления в очередь Каждая операция зависит от завершения предыдущей

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

Создайте очередь, которая ожидает завершения асинхронных вычислений

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

Задача завершения ожидает result, которое представляет успех или ошибку после выполнения задачи. Благодаря этому неудачное вычисление освобождает следующее задание, но ошибка при этом остаётся доступна через дескриптор самого вычисления. Вызывающий код по-прежнему может получить её через дескриптор. Документация результата Task

Ниже приведён полный пример программы командной строки для компьютера Mac с набором инструментов Swift 6. Сохраните его в файле OrderedDemo.swift. Исходный код был проверен, но скомпилировать его в исследовательской среде не удалось, поскольку компилятора Swift там не было.

import Foundation

@MainActor
final class OrderedIntQueue {
    private var tail: Task<Void, Never>?

    func submit(
        _ operation: @escaping @Sendable () async throws -> Int
    ) -> Task<Int, Error> {
        let predecessor = tail

        let job = Task<Int, Error> {
            if let predecessor {
                await predecessor.value
            }
            try Task.checkCancellation()
            return try await operation()
        }

        tail = Task<Void, Never> {
            _ = await job.result
        }

        return job
    }
}

enum CalculationError: Error {
    case inputOutOfRange
}

actor CalculationWorker {
    private var events: [String] = []

    func square(_ input: Int, label: String) async throws -> Int {
        events.append("start \(label)")
        guard (-1000...1000).contains(input) else {
            throw CalculationError.inputOutOfRange
        }

        await Task.yield()
        try Task.checkCancellation()

        let answer = input * input
        events.append("finish \(label)")
        return answer
    }

    func trace() -> [String] {
        events
    }
}

@main
struct OrderedDemo {
    @MainActor
    static func main() async {
        let queue = OrderedIntQueue()
        let worker = CalculationWorker()

        let a = queue.submit {
            try await worker.square(3, label: "A")
        }
        let b = queue.submit {
            try await worker.square(4, label: "B")
        }
        let c = queue.submit {
            try await worker.square(5, label: "C")
        }

        var answers: [Int] = []
        for job in [a, b, c] {
            switch await job.result {
            case .success(let answer):
                answers.append(answer)
            case .failure(let error):
                print("Calculation failed: \(error)")
            }
        }

        let events = await worker.trace()
        precondition(answers == [9, 16, 25])
        precondition(events == [
            "start A", "finish A",
            "start B", "finish B",
            "start C", "finish C"
        ])

        print(events.joined(separator: "\n"))
        print("Answers: \(answers)")
    }
}

Скомпилируйте и запустите сохранённую программу в Терминале из её каталога:

swiftc -swift-version 6 -parse-as-library OrderedDemo.swift -o OrderedDemo
./OrderedDemo

Ожидаемый вывод следует из входных данных примера и цепочки зависимостей:

start A
finish A
start B
finish B
start C
finish C
Answers: [9, 16, 25]

Утверждения проверяют и возвращённые значения, и границы выполнения вычислений. Если просто выводить результаты, ожидая дескрипторы в порядке отправки, это не гарантирует, что вычисления выполнялись в том же порядке.

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

Почему цепочка сохраняет порядок при приостановках?

Гарантию обеспечивают три детали. Во-первых, submit не содержит точек приостановки, поэтому другая отправка на главном акторе не может вклиниться, пока он захватывает и заменяет tail. В документации Swift сказано, что изолированный актором код без точек приостановки выполняется без прерывания этой последовательности другой задачей актора. Параллельное выполнение: акторы

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

В-третьих, сигнал предшественника завершается только после того, как более раннее задание выдаёт результат или ошибку. Если пройти по связям назад, получится последовательность для каждой принятой отправки: C ожидает сигнал завершения B, который ожидает B, а B ожидает сигнал завершения A.

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

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

Как должны обрабатываться ошибки и отмена?

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

Чтобы проверить простую обработку ошибки, замените входное значение B с 4 на 1001. Рабочий актор отклонит его до умножения. Измените ожидаемые ответы на [9, 25], а ожидаемую последовательность — на start A, finish A, start B, start C, finish C. Программа должна вывести сообщение об ошибке и всё равно вычислить C. Ограничение на входные данные — наглядное условие, выбранное, чтобы вычисления оставались небольшими.

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

Чтобы проверить отмену до выполнения, сразу после отправки C и до цикла получения результатов вызовите b.cancel(). При этих непрерываемых отправках с главного актора B ещё не могло начать вычисление. Измените ожидаемые ответы на [9, 25] и удалите из ожидаемой последовательности обе записи для B. B всё равно дождётся сигнала завершения A, прежде чем сообщить об отмене; затем продолжится C.

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

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

Когда лучше использовать зависимости Foundation?

Если в настольном приложении работа уже представлена объектами Operation из Foundation, задайте зависимость каждой операции от предыдущей до добавления в очередь. Foundation считает зависимость выполненной, когда предшествующая операция завершается независимо от успеха или ошибки; поведение, зависящее от успеха, нужно обрабатывать отдельно. Документация Operation

Будьте внимательны при связывании асинхронных функций Swift с этой моделью. Синхронная операция, которая запускает Task и сразу возвращает управление, не отражает полную продолжительность задачи. Пользовательская асинхронная операция должна точно управлять ходом выполнения и состоянием завершения, включая необходимые уведомления, чтобы зависимые операции ожидали именно нужную работу. Apple описывает эти обязанности в разделах об асинхронных операциях и сообщении о состоянии завершения. Документация жизненного цикла Operation

Для локального асинхронного калькулятора, описанного здесь, цепочка задач делает условие наглядным: синхронно принимать задания, ожидать завершения предшественника, выполнять вычисление целиком и предоставлять его результат. Сохраняйте эти границы при подключении очереди к действиям в настольном приложении и проверяйте выполнение по трассам начала и завершения, а также по возвращённым значениям.

Источники