Используйте одну долгоживущую очередь Foundation OperationQueue и задайте её maxConcurrentOperationCount до отправки вычислений. Для утилиты Mac, получающей один запрос при каждом нажатии кнопки, это ограничит число одновременных задач, а последующие запросы смогут ждать.

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

Какой инструмент конкурентности подходит для отдельных входящих запросов?

OperationQueue принимает операции по одной, а её блочный метод addOperation(_:) оборачивает каждое переданное замыкание в операцию. Храните очередь в объекте, который существует на протяжении обработки запросов, а затем отправляйте по одному замыканию для каждого входящего элемента. В документации Apple по addOperation(_:) описан этот метод отправки.

Установите для maxConcurrentOperationCount положительный предел, например 2. Apple определяет это свойство как максимальное число операций данной очереди, которые могут выполняться одновременно. Ограничение относится к конкретной очереди: создание отдельной очереди для каждого запроса лишит вас общего ограничения. Если впоследствии уменьшить это значение, уже выполняющиеся операции продолжат работу. Эти границы описаны в документации Apple об ограничении конкурентности.

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

Организация работы Подходящий вариант
Независимые синхронные вычисления поступают по одному Храните одну OperationQueue и отправляйте по одной операции на вычисление.
Родительская асинхронная операция управляет конечным набором дочерних задач Используйте группу задач в области видимости родительской операции; если требуется числовое ограничение, явно контролируйте допуск задач.
Работа должна обновлять интерфейс настольного приложения Выполняйте вычисление отдельно, а затем передавайте результат коду, изолированному актором MainActor.

Последняя строка касается передачи результата. В руководстве по языку Swift главный актор указан как место для состояния интерфейса, а также показаны замыкания с явной изоляцией на главном акторе. Не выполняйте ресурсоёмкую арифметику на этом этапе передачи. Изоляция на главном акторе описана в документации «Язык программирования Swift: конкурентность».

Определите вычисление и условия завершения

Пример принимает целочисленный идентификатор и число итераций от 1 до 1_000_000. Для числа итераций n программа складывает квадраты всех целых чисел от 1 до n. Она возвращает идентификатор вместе с результатом типа UInt64. Идентификатор обозначает результат и не влияет на арифметику.

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

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

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

Создайте полноценную программу с общим ограничением

Используйте Mac с компилятором Swift 6.2 или новее и SDK macOS, доступным через Xcode или его инструменты командной строки. Сохраните приведённую программу целиком в файл main.swift. В командах ниже явно задана изоляция по умолчанию без изоляции, чтобы вспомогательные функции вычислений не зависели от настроек изоляции целевого приложения. Эта настройка компилятора описана в SE-0466: управление выводом изоляции актора по умолчанию.

Журнал результатов использует NSLock для защиты массива и счётчиков активности. Соответствие @unchecked Sendable возлагает ответственность за синхронизацию на реализацию: каждый доступ к изменяемому состоянию выполняется под одной и той же блокировкой. Эта ответственность описана в документации Apple по Sendable, а защита критических секций — в документации по NSLock.

import Foundation

enum InputError: Error {
    case invalidIterations
}

struct Calculation: Sendable {
    let id: Int
    let iterations: Int
}

func sumOfSquares(_ job: Calculation) -> UInt64 {
    var sum: UInt64 = 0
    for integer in 1...job.iterations {
        let value = UInt64(integer)
        sum += value * value
    }
    return sum
}

final class ExecutionLog: @unchecked Sendable {
    private let lock = NSLock()
    private var active = 0
    private var peak = 0
    private var rows: [(id: Int, value: UInt64)] = []

    func begin() {
        lock.lock()
        defer { lock.unlock() }
        active += 1
        peak = max(peak, active)
    }

    func finish(id: Int, value: UInt64) {
        lock.lock()
        defer { lock.unlock() }
        rows.append((id: id, value: value))
        active -= 1
    }

    func snapshot() -> (
        rows: [(id: Int, value: UInt64)], peak: Int
    ) {
        lock.lock()
        defer { lock.unlock() }
        return (rows, peak)
    }
}

final class CalculationPool {
    private let queue = OperationQueue()
    private let log = ExecutionLog()

    init(limit: Int) {
        precondition(limit > 0)
        queue.name = "Synthetic calculations"
        queue.maxConcurrentOperationCount = limit
    }

    func submit(id: Int, iterations: Int) throws {
        guard (1...1_000_000).contains(iterations) else {
            throw InputError.invalidIterations
        }
        let job = Calculation(id: id, iterations: iterations)
        queue.addOperation { [log] in
            log.begin()
            let value = sumOfSquares(job)
            log.finish(id: job.id, value: value)
        }
    }

    // Call only after the producer has stopped submitting work.
    // This blocking method is for the command-line demonstration.
    func finishForDemo() -> (
        rows: [(id: Int, value: UInt64)], peak: Int
    ) {
        queue.waitUntilAllOperationsAreFinished()
        return log.snapshot()
    }
}

@main
struct Demo {
    static func main() throws {
        let limit = 2
        let pool = CalculationPool(limit: limit)

        try pool.submit(id: 1, iterations: 100_000)
        try pool.submit(id: 2, iterations: 200_000)
        try pool.submit(id: 3, iterations: 300_000)

        let report = pool.finishForDemo()
        let rows = report.rows.sorted { $0.id < $1.id }
        precondition(rows.map { $0.id } == [1, 2, 3])
        precondition(rows.map { $0.value } == [
            UInt64(333338333350000),
            UInt64(2666686666700000),
            UInt64(9000045000050000)
        ])
        precondition(report.peak >= 1 && report.peak <= limit)

        for row in rows {
            print("Job \(row.id): \(row.value)")
        }
        print("Observed peak: \(report.peak)")
    }
}

Скомпилируйте и запустите программу из каталога, содержащего main.swift:

swiftc -swift-version 6 -default-isolation nonisolated \
  -parse-as-library main.swift -o calculation-demo
./calculation-demo

Должны появиться три строки с результатами:

Задача 1: 333338333350000
Задача 2: 2666686666700000
Задача 3: 9000045000050000

В последней строке будет указан наблюдавшийся пик: 1 или 2. Ограничение допускает перекрытие выполнения, но не гарантирует его. Ожидаемые суммы независимо проверены целочисленной арифметикой и формулой n * (n + 1) * (2 * n + 1) / 6. Код на Swift был проверен, но не скомпилирован в исследовательской среде, поскольку там не установлен исполняемый файл Swift.

Почему это ограничение охватывает фактические вычисления?

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

Сохраняйте эту взаимосвязь при адаптации программы. Если операция лишь создаёт отдельную Task и сразу возвращает управление, очередь считает операцию завершённой, хотя задача может продолжать вычисление. Тогда ограничение числа операций будет регулировать лишь короткие замыкания, запускающие задачи. Это следует из описанного срока жизни операций и API отправки блоков. Эти компоненты определены в обзоре OperationQueue от Apple и документации по addOperation(_:).

Блокировка журнала охватывает только короткие участки учёта. Если удерживать её во время sumOfSquares(_:), доступ к вычислению будет сериализован, что помешает нужному перекрытию выполнения. Каждое получение и снятие блокировки происходит в рамках одного синхронного метода, что соответствует требованию Apple снимать NSLock в том потоке, который её получил. Это требование объяснено в документации Apple по NSLock.

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

Обрабатывайте действия пользователя на Mac, не блокируя интерфейс

Храните один экземпляр CalculationPool в компоненте, который отвечает за вычисления в настольном приложении. Для каждого входящего действия один раз вызывайте submit(id:iterations:). Не создавайте пул заново в обработчике действий: у каждого нового экземпляра своя очередь и своё ограничение.

В демонстрации finishForDemo() вызывается только после последней отправки, чтобы процесс командной строки успел собрать результаты до завершения. Этот метод блокирует вызывающий поток. В настольном приложении используйте передачу каждого результата отдельно и не вызывайте этот метод из обработчика нажатия кнопки или из главного актора. Блокирующее поведение waitUntilAllOperationsAreFinished() прямо описано Apple.

Для интеграции с интерфейсом расширьте API отправки, добавив обратный вызов результата, изолированный актором MainActor и помеченный @Sendable. После вычисления результата внутри операции запланируйте Task с явным замыканием @MainActor, которое вызовет этот обратный вызов. Захватите неизменяемое значение результата и обратный вызов. Внутри этого замыкания выполняйте только обновление интерфейса; арифметику оставьте в операции. В Swift описаны и синтаксис замыканий главного актора, и захват значений в передаваемых замыканиях.

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

Что происходит, когда запросы поступают быстрее, чем выполняются вычисления?

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

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

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

Источники