Чтобы выполнять ресурсоёмкие вычисления на Swift параллельно, задайте для рабочей функции явную границу @concurrent и распределите независимые задачи между дочерними задачами. В этом примере для Mac обновления интерфейса остаются на MainActor, а синтетические результаты объединяются без общей изменяемой памяти.

Почему добавление задач всё ещё может привести к последовательному выполнению вычислений

Предположим, что в приложении для Mac есть кнопка Calculate, запускающая длительное локальное вычисление. Обёртка вычисления в Task { ... } создаёт асинхронную точку входа, но задача, созданная в контексте главного актора, может унаследовать эту изоляцию. Долгий синхронный цикл внутри неё всё равно занимает главный актор. Apple показывает это различие в статье Embracing Swift concurrency.

Добавление группы задач тоже оставляет без ответа важный вопрос: где выполняется функция с затратным циклом? Если каждый дочерний элемент вызывает один и тот же метод вычисления @MainActor, этот актор выполняет вычисления последовательно. Акторы позволяют только одной задаче за раз обращаться к изолированному состоянию, как объясняется в главе Concurrency руководства по языку Swift.

Для Swift 6.2 и новее пометьте асинхронную точку входа вычисления атрибутом @concurrent. Он явно переключает выполнение с актора вызывающего кода. Если включена возможность NonisolatedNonsendingByDefault, обычная асинхронная функция без изоляции вместо этого выполняется на акторе вызывающего кода; в более раннем поведении было иначе. В принятом предложении SE-0461 описаны и границы действия этой возможности, и явный атрибут.

Желаемое поведение обеспечивают два отдельных решения: изоляция рабочей функции позволяет выполнять её вне MainActor, а несколько дочерних задач создают работу, которую можно планировать независимо. Среда выполнения выбирает, какие доступные потоки выполняют эти задачи. Ни количество задач, ни @concurrent не гарантируют определённое число ядер, работающих одновременно. Apple описывает эту модель планирования в статье Embracing Swift concurrency.

Определите вычисление, которое можно правильно разделить

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

  1. Посетить каждый целочисленный индекс в 0..<count ровно один раз.
  2. Преобразовать каждый индекс в UInt64 и многократно применить x = (x &* 1_664_525) &+ 1_013_904_223 заданное число раундов.
  3. Сложить полученные значения с помощью сложения с переполнением.
  4. Возвращать ноль для пустого ввода, отклонять отрицательные значения count или rounds и отклонять неположительный лимит работников.
  5. При обнаружении отмены выбрасывать исключение, а не показывать неполную контрольную сумму как готовый результат.

Арифметика с переполнением здесь используется намеренно. Обычная целочисленная арифметика Swift вызывает исключение при переполнении; операторы &+ и &* явно поддерживают поведение с переполнением. См. Advanced Operators.

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

Разбиение выполняется с помощью деления и остатка: каждый работник получает одинаковое базовое число индексов, а первые несколько работников — на один индекс больше. Например, для количества 17 и трёх работников получатся диапазоны 0..<6, 6..<12 и 12..<17. Они охватывают весь ввод, включая остаток.

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

Используйте инструментарий Swift 6.2 или новее и целевой объект приложения macOS SwiftUI с минимальной версией macOS 13. Замените сгенерированные точку входа приложения и представление содержимого двумя блоками ниже, размещёнными вместе в одном файле Swift. Вместе они образуют полноценную программу; в целевом объекте должна оставаться только одна декларация приложения @main.

Пространство имён вычислений явно объявлено как nonisolated, поэтому его элементы не получают неявно выведенную изоляцию глобального актора. Swift описывает такую декларацию на уровне типа в SE-0449. Оба метода асинхронного вычисления используют @concurrent, явно задавая границу выполнения.

import Foundation
import SwiftUI

nonisolated enum CalculationError: Error {
    case invalidInput
}

nonisolated enum SyntheticCalculator {
    @concurrent
    static func run(
        count: Int,
        rounds: Int,
        workerLimit: Int
    ) async throws -> UInt64 {
        guard count >= 0, rounds >= 0, workerLimit > 0 else {
            throw CalculationError.invalidInput
        }
        try Task.checkCancellation()
        guard count > 0 else { return 0 }

        let workers = min(count, workerLimit)
        let baseSize = count / workers
        let remainder = count % workers

        return try await withThrowingTaskGroup(
            of: UInt64.self,
            returning: UInt64.self
        ) { group in
            var lower = 0

            for worker in 0..<workers {
                try Task.checkCancellation()
                let size = baseSize + (worker < remainder ? 1 : 0)
                let start = lower
                let end = start + size
                lower = end

                group.addTask {
                    try await calculateChunk(
                        lower: start,
                        upper: end,
                        rounds: rounds
                    )
                }
            }

            var total: UInt64 = 0
            for try await partial in group {
                total = total &+ partial
            }
            try Task.checkCancellation()
            return total
        }
    }

    @concurrent
    private static func calculateChunk(
        lower: Int,
        upper: Int,
        rounds: Int
    ) async throws -> UInt64 {
        try Task.checkCancellation()
        var total: UInt64 = 0

        for index in lower..<upper {
            if index % 256 == 0 {
                try Task.checkCancellation()
            }
            var value = UInt64(index)
            for round in 0..<rounds {
                if round % 4096 == 0 {
                    try Task.checkCancellation()
                }
                value = (value &* 1_664_525) &+ 1_013_904_223
            }
            total = total &+ value
        }

        try Task.checkCancellation()
        return total
    }
}

Каждая дочерняя задача захватывает неизменяемые границы и возвращает одно целое число. Только родительская задача обновляет total; дочерние задачи не добавляют элементы в общую коллекцию и не изменяют состояние интерфейса. Группы задач поддерживают конкурентное выполнение дочерних задач, требуют, чтобы результаты были sendable, и ограничивают время жизни дочерних задач областью группы. См. документацию Apple TaskGroup.

Добавьте этот код интерфейса в тот же файл:

@main
@MainActor
struct ParallelCalculationApp: App {
    var body: some Scene {
        WindowGroup {
            CalculationView()
        }
    }
}

@MainActor
struct CalculationView: View {
    @State private var status = "Ready"
    @State private var work: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Synthetic Calculation")
                .font(.title2)
            Text(status)
                .textSelection(.enabled)

            HStack {
                Button("Calculate") {
                    startCalculation()
                }
                .disabled(work != nil)

                Button("Cancel") {
                    work?.cancel()
                }
                .disabled(work == nil)
            }
        }
        .padding(24)
        .frame(minWidth: 440, minHeight: 180)
        .onDisappear {
            work?.cancel()
        }
    }

    private func startCalculation() {
        guard work == nil else { return }
        status = "Calculating…"
        let workerLimit = max(
            1,
            ProcessInfo.processInfo.activeProcessorCount
        )

        work = Task { @MainActor in
            defer { work = nil }
            do {
                let checksum = try await SyntheticCalculator.run(
                    count: 1_000_000,
                    rounds: 128,
                    workerLimit: workerLimit
                )
                try Task.checkCancellation()
                status = "Checksum: \(checksum)"
            } catch is CancellationError {
                status = "Cancelled"
            } catch {
                status = "Calculation failed: \(error)"
            }
        }
    }
}

Интерфейс хранит дескриптор внешней задачи, чтобы кнопка Cancel могла её отменить. Задача остаётся изолированной главным актором, вызывает конкурентное вычисление и обновляет отображаемое состояние после ожидания результата. Хранение дескриптора также позволяет отменить задачу при исчезновении представления. В документации Apple Task поясняется, что удаление дескриптора не отменяет задачу.

Лимит работников изначально берётся из activeProcessorCount в Foundation. Это свойство сообщает число активных процессорных ядер, доступных на компьютере. Считайте его начальным значением для измерений, а не гарантированно оптимальным количеством задач. Определение свойства приведено в документации activeProcessorCount.

Проверьте корректность, прежде чем сравнивать производительность

Начните с небольшого примера: измените вызов на count: 17, rounds: 4 и workerLimit: 3. Ожидаемая контрольная сумма — 18149427060254280572. Повторите с workerLimit: 1; значение должно совпасть. Проверьте также count: 0, для которого результатом должен быть ноль, и положительное значение count, меньшее лимита работников: при этом всё равно должны учитываться все индексы.

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

Затем восстановите исходные крупные значения и сравните оптимизированные сборки с лимитами работников 1, 2 и равным числу активных процессорных ядер. Не меняйте значения count и rounds, сохраните полученную контрольную сумму и выполните несколько измерений. Это предлагаемая процедура измерений; никаких утверждений о времени выполнения или ускорении не приводится.

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

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

Сделайте отмену и ошибки изоляции заметными

Отмена работает по запросу. Вызов cancel() помечает задачу; код вычисления должен проверять это состояние. Проверки в обоих циклах дают рабочей функции возможность выбросить исключение, в том числе если для одного индекса требуется много раундов. Эти интервалы — решения реализации, а не гарантия времени реакции на отмену. Apple описывает эту ответственность в документации Task.

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

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

Если компилятор не принимает @concurrent, проверьте версию инструментария. Атрибут появился в Swift 6.2 и применяется к асинхронным функциям. Для более старого инструментария модель асинхронного выполнения без изоляции описана в SE-0461; используйте документацию для соответствующего компилятора и не предполагайте, что во всех версиях nonisolated async ведёт себя одинаково.

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

Источники