CPU 집약적인 Swift 계산을 병렬로 실행하려면 작업자에 명시적인 @concurrent 경계를 지정하고 독립적인 작업을 하위 태스크에 나누어 맡기세요. 이 Mac 예제는 인터페이스 업데이트를 MainActor에서 처리하고 공유 상태를 변경하지 않은 채 생성된 결과를 결합합니다.
태스크를 추가해도 계산이 직렬화될 수 있는 이유
Mac 앱에 상당한 시간이 걸리는 로컬 계산을 시작하는 Calculate 버튼이 있다고 가정해 보겠습니다. 계산을 Task { ... }로 감싸면 비동기 진입점이 생기지만, 메인 액터 컨텍스트에서 생성한 태스크는 해당 격리를 상속할 수 있습니다. 그 안의 긴 동기 루프는 여전히 메인 액터를 점유합니다. Apple은 Swift 동시성 수용하기에서 이 차이를 설명합니다.
태스크 그룹을 추가해도 중요한 질문 하나는 여전히 남습니다. 비용이 큰 루프가 들어 있는 함수는 어디에서 실행될까요? 모든 하위 태스크가 같은 @MainActor 계산 메서드를 호출하면 해당 액터가 계산을 직렬화합니다. 액터는 격리된 상태에 한 번에 하나의 태스크만 접근하도록 허용합니다. 자세한 내용은 Swift 언어 가이드의 동시성 장을 참조하세요.
Swift 6.2 이상에서는 비동기 계산 진입점에 @concurrent를 지정하세요. 이렇게 하면 호출자의 액터 바깥으로 명시적으로 실행이 전환됩니다. NonisolatedNonsendingByDefault 기능이 활성화된 경우 일반적인 nonisolated 비동기 함수는 대신 호출자의 액터에서 실행됩니다. 이전 동작은 달랐습니다. 구현된 SE-0461 제안에 기능 경계와 명시적 특성 모두가 설명되어 있습니다.
원하는 동작을 얻으려면 두 가지를 각각 선택해야 합니다. 작업자의 격리 설정은 MainActor 바깥에서 실행할 수 있게 하고, 여러 하위 태스크는 독립적으로 스케줄링할 수 있는 작업을 제공합니다. 사용 가능한 스레드 중 어떤 스레드에서 태스크를 실행할지는 런타임이 결정합니다. 태스크 수나 @concurrent 어느 쪽도 특정 수의 코어가 동시에 실행된다고 보장하지 않습니다. Apple은 Swift 동시성 수용하기에서 이 스케줄링 모델을 설명합니다.
올바르게 나눌 수 있는 계산 정의하기
아래 예제에서는 Mac 사용자가 합성 계산을 시작하거나 취소하고 최종 체크섬을 표시할 수 있습니다. 모든 입력은 상수이거나 생성된 정수입니다. 계산은 다음과 같은 정확한 값 계약을 따릅니다.
0..<count의 모든 정수 인덱스를 정확히 한 번 방문합니다.- 각 인덱스를
UInt64로 변환하고 요청된 횟수만큼x = (x &* 1_664_525) &+ 1_013_904_223을 반복 적용합니다. - 결과 값을 래핑 덧셈으로 합산합니다.
- 입력이 비어 있으면 0을 반환하고, 음수 count 또는 rounds는 거부하며, 0 이하의 작업자 한도도 거부합니다.
- 취소가 감지되면 완료되지 않은 체크섬을 완료 결과로 표시하는 대신 오류를 던집니다.
이 예제에서는 래핑 연산을 의도적으로 사용합니다. Swift의 일반 정수 연산은 오버플로 시 트랩이 발생하지만 &+ 및 &* 연산자는 오버플로 동작을 명시적으로 지원합니다. 고급 연산자를 참조하세요.
이 계약에서 각 인덱스는 독립적이며 래핑 덧셈은 2의 64제곱을 법으로 하여 부분 합계를 결합합니다. 따라서 그룹화 방식이나 완료 순서를 바꾸어도 최종 값은 정확히 보존됩니다. 이 결론은 선택한 연산 방식에서 나옵니다. 부동소수점 계산에는 별도의 정확성 규칙이 필요합니다. 덧셈을 다시 그룹화하면 반올림 결과가 달라질 수 있기 때문입니다.
분할에는 나눗셈과 나머지를 사용합니다. 각 작업자는 동일한 기본 개수의 인덱스를 받고, 앞쪽의 몇 작업자는 인덱스를 하나씩 더 받습니다. 예를 들어 count가 17이고 작업자가 3명이면 범위는 0..<6, 6..<12, 12..<17입니다. 나머지 인덱스까지 포함해 입력 전체를 한 번씩 처리합니다.
완전한 Mac 계산 예제 만들기
Swift 6.2 이상 툴체인과 배포 대상이 macOS 13 이상인 macOS SwiftUI 앱 타깃을 사용하세요. 생성된 앱 진입점과 콘텐츠 뷰를 아래 두 블록으로 바꾸고, 하나의 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를 업데이트합니다. 하위 태스크는 공유 컬렉션에 요소를 추가하거나 인터페이스 상태를 변경하지 않습니다. 태스크 그룹은 하위 태스크의 동시 실행을 지원하고, 전송 가능한 결과를 요구하며, 하위 태스크의 수명을 그룹 범위 안으로 제한합니다. 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 문서에 따르면 핸들을 버려도 태스크는 취소되지 않습니다.
작업자 한도는 컴퓨터에서 사용할 수 있는 활성 처리 코어 수를 보고하는 Foundation의 activeProcessorCount에서 시작합니다. 이를 최적의 태스크 수가 보장된 값이 아니라 측정해 볼 초기 설정으로 취급하세요. 문서에 나온 의미는 activeProcessorCount를 참조하세요.
성능을 비교하기 전에 정확성 확인하기
간단한 예시 입력부터 시작하세요. 호출의 값을 count: 17, rounds: 4, workerLimit: 3으로 바꾸세요. 예상 체크섬은 18149427060254280572입니다. workerLimit: 1로도 반복하고 값이 일치하는지 확인하세요. count: 0은 0을 반환해야 하며, 작업자 한도보다 count가 작더라도 양수라면 모든 인덱스가 포함되는지 확인하세요.
예시 체크섬과 범위의 전체 포함 여부는 래핑 연산을 재현한 간단한 Python 코드로 확인했습니다. 조사 환경에 Swift 컴파일러가 없어 이 Swift 프로그램을 여기서 컴파일하거나 실행하지는 않았습니다.
그런 다음 더 큰 입력으로 되돌리고 작업자 한도를 1, 2, 활성 프로세서 수로 설정해 최적화 빌드를 비교하세요. count와 rounds를 그대로 유지하고 체크섬을 기록한 뒤 여러 차례 실행해 측정하세요. 이는 제안하는 측정 절차이며, 실행 시간이나 속도 향상을 주장하는 것은 아닙니다.
작업자 한 명으로 실행할 때도 같은 동시 실행 경계와 태스크 그룹 구조를 사용하므로 이 구현을 비교하는 데 유용합니다. 작은 작업량은 분할과 스케줄링 오버헤드를 감수할 만하지 않을 수 있습니다. 현실적인 계산에서 인덱스별 비용 차이가 크다면 범위 크기가 같아도 작업자 한 명이 더 늦게 끝날 수 있습니다. 청크를 더 작게 만들고 동시에 진행 중인 태스크 수를 제한하는 방법을 다음 실험으로 고려할 수 있습니다.
배열을 생성하는 계산이라면 각 결과에 청크 인덱스를 붙이고 입력 순서대로 청크를 조립하세요. 태스크 그룹의 하위 태스크는 어떤 순서로든 실행될 수 있고, 그룹은 완료된 결과가 생기는 대로 제공합니다. 이 체크섬은 선택한 축약 연산이 순서에 영향을 받지 않으므로 순서 문제가 없습니다. TaskGroup을 참조하세요.
취소 및 격리 문제를 확인할 수 있게 만들기
취소는 협력적으로 이루어집니다. cancel()를 호출하면 태스크가 취소 상태로 표시되며, 계산 코드에서 그 상태를 확인해야 합니다. 두 루프의 검사 지점은 단일 인덱스에서 반복 횟수가 많을 때를 포함해 작업자가 오류를 던질 기회를 제공합니다. 검사 간격은 구현상의 선택이며, 취소가 그 안에 반드시 처리된다는 보장은 없습니다. Apple은 Task에서 이 책임을 설명합니다.
오류를 던지는 결과 루프도 중요합니다. 하위 태스크가 오류를 던지고 해당 오류가 그룹 본문 밖으로 전파되면 나머지 하위 태스크는 취소됩니다. 그래도 그룹은 하위 태스크가 끝날 때까지 기다리므로 취소 검사 지점은 여전히 필요합니다. Swift 표준 라이브러리 구현은 TaskGroup.swift에서 이 동작을 설명합니다.
인터페이스가 계속 멈춘다면 비용이 큰 호출 경로 전체를 추적하세요. 작업자가 메인 액터 메서드를 호출하거나, 액터 격리 속성에 접근하거나, 동시 실행 경계에 도달하기 전에 상당한 사전 작업을 수행하는지 확인하세요. 필요한 정수 값을 작업자에 전달하고 긴 루프는 해당 경계 안에 두세요.
컴파일러가 @concurrent를 거부한다면 툴체인 버전을 확인하세요. 이 특성은 Swift 6.2에서 구현되었으며 비동기 함수에 적용됩니다. 이전 툴체인의 경우 이전 nonisolated 비동기 실행 모델이 SE-0461에 설명되어 있습니다. 모든 버전에서 nonisolated async가 똑같이 동작한다고 가정하지 말고 해당 컴파일러 버전에 맞는 문서를 사용하세요.
이 Mac 작업에 필요한 구조는 간단합니다. 메인 액터는 인터페이스 상태를 소유하고, 동시 작업자는 독립적인 값을 받으며, 부모 태스크는 완성된 결과를 결합합니다. 청크 크기를 조정할 때도 값 계약을 유지하고, 성능 변경을 적용하기 전에 체크섬이 일치하는지와 인터페이스가 원활히 반응하는지 확인하세요.
출처
- Swift 동시성 수용하기 — Apple.
- nonisolated async 함수를 기본적으로 호출자 액터에서 실행하기 — Swift.org, Swift Evolution.
- 전역 액터 추론을 방지하도록 nonisolated 허용하기 — Swift.org, Swift Evolution.
- 동시성 — Swift.org, The Swift Programming Language.
- 고급 연산자 — 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.