Чтобы выполнять ресурсоёмкие вычисления на 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 может запустить или отменить синтетическое вычисление и вывести итоговую контрольную сумму. Все входные данные — константы или сгенерированные целые числа. Вычисление должно точно соблюдать следующий контракт:
- Посетить каждый целочисленный индекс в
0..<countровно один раз. - Преобразовать каждый индекс в
UInt64и многократно применитьx = (x &* 1_664_525) &+ 1_013_904_223заданное число раундов. - Сложить полученные значения с помощью сложения с переполнением.
- Возвращать ноль для пустого ввода, отклонять отрицательные значения
countилиroundsи отклонять неположительный лимит работников. - При обнаружении отмены выбрасывать исключение, а не показывать неполную контрольную сумму как готовый результат.
Арифметика с переполнением здесь используется намеренно. Обычная целочисленная арифметика 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 архитектура проста: главный актор владеет состоянием интерфейса, конкурентные рабочие функции получают независимые значения, а родительская задача объединяет полные результаты. Сохраняйте контракт значений при настройке размера блоков и убедитесь, что контрольная сумма совпадает, а интерфейс остаётся отзывчивым, прежде чем оставлять изменение производительности.
Источники
- Embracing Swift concurrency — Apple.
- Run nonisolated async functions on the caller’s actor by default — Swift.org, Swift Evolution.
- Allow nonisolated to prevent global actor inference — Swift.org, Swift Evolution.
- Concurrency — Swift.org, The Swift Programming Language.
- Advanced Operators — Swift.org, The Swift Programming Language.
- Task — Apple Developer Documentation.
- TaskGroup — Apple Developer Documentation.
- activeProcessorCount — Apple Developer Documentation.
- TaskGroup.swift — Swift.org, Swift standard library.