Проверка ограниченного параллелизма задач iOS на облачном Mac

Проверка ограниченного параллелизма задач iOS на облачном Mac

Проверка ограниченного параллелизма задач iOS на облачном Mac

Если при пакетной обработке двадцати вложений добавить все задачи в группу, а затем поочерёдно получить результаты, параллелизм от этого не станет ограниченным. Число сетевых соединений, буферов декодирования и временных файлов всё равно может расти одновременно. При проверке этой логики на облачном Mac важна не скорость выполнения, а доказательство: при лимите в три операции четвёртая обязана ждать освобождения слота.

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

Сначала зададим проверяемый контракт планирования

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

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

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

Добавляем следующую задачу по событию завершения

Сохраните следующий код в BoundedMap.swift. Запускайте его на облачном Mac с установленным набором инструментов Swift, предварительно зафиксировав версию командой swift --version. В примере используются ограничения Sendable, необходимые для строгой проверки конкурентного кода; графический сеанс и симулятор не требуются.

import Foundation

func boundedMap<Input: Sendable, Output: Sendable>(
    _ inputs: [Input],
    limit: Int,
    operation: @escaping @Sendable (Input) async throws -> Output
) async throws -> [Output] {
    precondition(limit > 0)

    return try await withThrowingTaskGroup(
        of: (Int, Output).self
    ) { group in
        var nextIndex = 0
        var completed: [(Int, Output)] = []
        completed.reserveCapacity(inputs.count)

        for index in 0..<min(limit, inputs.count) {
            try Task.checkCancellation()
            let input = inputs[index]
            group.addTask {
                try Task.checkCancellation()
                return (index, try await operation(input))
            }
            nextIndex += 1
        }

        while let result = try await group.next() {
            completed.append(result)
            try Task.checkCancellation()

            if nextIndex < inputs.count {
                let index = nextIndex
                let input = inputs[index]
                group.addTask {
                    try Task.checkCancellation()
                    return (index, try await operation(input))
                }
                nextIndex += 1
            }
        }

        try Task.checkCancellation()
        return completed.sorted { $0.0 < $1.0 }.map { $0.1 }
    }
}

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

Ошибки передаются через group.next(). Если замыкание выходит с ошибкой, группа отменяет оставшиеся дочерние задачи и ждёт их завершения. Здесь нет ни перехвата ошибок с последующим добавлением новых задач, ни независимых фоновых задач, жизненный цикл которых не связан с родительской.

Измеряем реальное число активных операций с помощью актора-зонда

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

actor Probe {
    private var active = 0
    private var peak = 0

    func run(_ value: Int) async throws -> Int {
        active += 1
        peak = max(peak, active)
        defer { active -= 1 }

        let delay = UInt64(5 - value % 5) * 10_000_000
        try await Task.sleep(nanoseconds: delay)
        return value * 2
    }

    func snapshot() -> (active: Int, peak: Int) {
        (active, peak)
    }
}

let probe = Probe()
let inputs = Array(0..<20)
let output = try await boundedMap(inputs, limit: 3) {
    try await probe.run($0)
}
let state = await probe.snapshot()
precondition(output == inputs.map { $0 * 2 })
precondition(state.active == 0)
precondition(state.peak <= 3)

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

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

Отдельно проверяем ошибки и отмену

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

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

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

При интеграции соблюдаем границы слота

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

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

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

Часто задаваемые вопросы

Ограничивает ли задержка запуска каждой задачи параллелизм?

Нет. Разные моменты старта не гарантируют предел активных задач. Запускайте только разрешённое число и добавляйте следующую после завершения одной из них.

Возвращает ли отменённая группа управление немедленно?

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

Нужен выделенный Mac для сборки?

Выберите срок аренды под свои задачи. Весь физический Mac — только в вашем распоряжении: графический интерфейс macOS и командная строка полностью доступны. Модель, регион и наличие уточняйте в консоли — там отображаются актуальные данные.

Примените эти шаги при следующей сборке.

Сначала проверьте срок аренды и конфигурацию, затем запускайте сборку.