Swift continuation을 정확히 한 번 재개하려면 완료와 취소가 하나의 동기화된 상태 머신에서 경쟁하도록 하세요. 설치 전의 취소도 저장하고, 하나의 최종 결과를 선택한 뒤, 대기 중인 태스크를 재개하기 전에 잠금을 해제하세요.
이 튜토리얼에서는 Mac 명령줄 프로그램을 위한 로컬 콜백-비동기 어댑터를 만듭니다. 예시 계산은 정수를 두 배로 만들며, 네트워크 호출이나 레코드, 영속 저장소를 사용하지 않습니다. 핵심 계약은 명확합니다. 보호된 상태를 가장 먼저 변경하는 최종 이벤트가 승리합니다.
어댑터가 보장해야 하는 것은 무엇인가요?
검사형 continuation은 동기 콜백과 비동기 Swift 코드 사이를 연결합니다. Apple은 모든 실행 경로에서 이를 정확히 한 번 재개해야 한다고 요구합니다. 재개가 누락되면 태스크가 계속 중단된 상태로 남습니다. 런타임 검사는 오용을 찾는 데 도움이 되지만, 직접 조정 로직을 구현해 주지는 않습니다. CheckedContinuation
취소가 재개의 또 다른 잠재적 담당자가 됩니다. Swift의 취소는 협력적입니다. 태스크를 취소해도 임의의 콜백 작업이 자동으로 중단되거나 continuation이 오류를 던지지는 않습니다. 어댑터는 취소를 선택한 결과로 명시적으로 변환해야 합니다. Task.cancel()
이 예시의 값 계약은 다음과 같습니다.
-1000부터1000까지의 입력은 해당 값의 두 배를 반환합니다.- 그 밖의 입력은
CalculationError.outOfRange를 반환합니다. - 상태 전이에서 취소가 승리하면
CancellationError를 반환합니다. - 최종 결과가 선택된 뒤에는 나중에 발생한 완료 또는 취소 이벤트가 그 결과를 바꿀 수 없습니다.
- 각 호출은 새 게이트를 소유하고 continuation을 하나 설치합니다.
따라서 호출자가 값을 받기 전에 취소가 발생해도 완료가 승리할 수 있습니다. 결정 시점은 호출자가 다음에 실행되는 때가 아니라 보호된 상태 전이가 일어나는 때입니다. 이는 이 예시에서 선택한 정책이며, Swift가 보장하는 보편적인 순서 규칙은 아닙니다.
설치 전에 취소가 발생할 수 있는 이유는 무엇인가요?
Apple 문서에 따르면 태스크가 이미 취소된 경우 취소 핸들러가 작업 클로저보다 먼저 실행됩니다. 그래도 작업은 실행됩니다. 이미 진행 중인 작업과 동시에 취소가 발생할 수도 있습니다. 따라서 선택적 continuation을 재개하기만 하는 핸들러는 해당 선택적 값이 아직 채워지지 않았을 때 취소를 놓칩니다. withTaskCancellationHandler(operation:onCancel:isolation:)
그러므로 게이트는 현재 continuation이 있는지보다 더 많은 정보를 보관해야 합니다. continuation이 없는 상태는 아직 설치되지 않았다는 뜻일 수도 있고, 최종 결과가 이미 선택되었다는 뜻일 수도 있습니다. 이 두 경우에는 서로 다르게 동작해야 합니다.
| 보호된 상태 | continuation 설치 | 완료 또는 취소 수신 |
|---|---|---|
| 비어 있음 | continuation 저장 | 최종 결과 저장 |
| 대기 중 | 두 번째 설치 거부 | continuation을 꺼내고 최종 결과 저장 |
| 완료됨 | 새로 설치된 continuation을 저장된 결과로 재개 | 나중의 최종 이벤트 무시 |
이 표는 진단에도 도움이 됩니다. 구현이 설치 전에 최종 결과를 표현할 수 없다면 이미 취소된 경로를 살펴보세요. 두 최종 이벤트가 모두 continuation을 꺼낼 수 있다면 검사와 제거가 하나의 임계 구역에서 이루어지는지 확인하세요.
Mac에서 로컬 예시 만들기
macOS SDK가 포함된 Swift 6 툴체인을 사용하세요. Terminal에서 swift --version을 실행해 선택된 툴체인을 확인하세요. 아래 프로그램은 Foundation과 Dispatch만 사용하며 패키지 의존성이 없습니다. Swift 명령줄 가이드에서는 설치 확인과 비동기 @main 진입점 사용법을 설명합니다. 명령줄 도구 빌드하기
다음 세 개의 Swift 코드 블록을 순서대로 ContinuationRace.swift라는 파일 하나에 저장하세요. 세 블록을 합치면 완전한 프로그램이 됩니다. 첫 번째 블록은 게이트를 구현하고, 두 번째 블록은 계산과 어댑터를 제공하며, 세 번째 블록은 실행 가능한 검사를 제공합니다. 조사 환경에서는 Swift를 사용할 수 없어 이 프로그램을 검토만 했으며 여기서 컴파일하거나 실행하지는 않았습니다.
동기화된 게이트 구현하기
Foundation은 임계 구역을 보호하는 방법으로 NSLock을 설명합니다. 잠금과 잠금 해제는 같은 스레드에서 이루어져야 하며, 이 잠금은 재귀적으로 사용할 수 없습니다. 여기서는 모든 임계 구역이 동기 메서드 안에 있고, 중단 지점을 포함하지 않으며, continuation을 재개하기 전에 끝납니다. NSLock
import Foundation
import Dispatch
final class IntContinuationGate: @unchecked Sendable {
private enum State {
case empty
case waiting(CheckedContinuation<Int, any Error>)
case finished(Result<Int, any Error>)
}
private let lock = NSLock()
private var state: State = .empty
private var didInstall = false
// Returns false if a terminal event already won.
func install(
_ continuation: CheckedContinuation<Int, any Error>
) -> Bool {
lock.lock()
precondition(!didInstall, "Install exactly once per gate")
didInstall = true
switch state {
case .empty:
state = .waiting(continuation)
lock.unlock()
return true
case .waiting:
preconditionFailure("Unexpected second installation")
case .finished(let result):
lock.unlock()
continuation.resume(with: result)
return false
}
}
func finish(_ result: Result<Int, any Error>) {
let continuation: CheckedContinuation<Int, any Error>?
lock.lock()
switch state {
case .empty:
continuation = nil
state = .finished(result)
case .waiting(let waiting):
continuation = waiting
state = .finished(result)
case .finished:
lock.unlock()
return
}
lock.unlock()
continuation?.resume(with: result)
}
func cancel() {
finish(.failure(CancellationError()))
}
}
게이트는 변경 가능한 저장소를 직접 보호하므로 @unchecked Sendable을 사용합니다. 이 어노테이션은 구현에 책임을 맡길 뿐 동기화를 추가하지는 않습니다. 변경 가능한 두 속성은 모두 비공개이며, 모든 접근은 같은 잠금 안에서 이루어집니다. Apple은 잠금으로 보호된 상태가 unchecked sendability의 의미상 요구사항을 충족하는 한 방법이라고 명시합니다. Sendable
finish이 잠금을 해제하기 전에 상태를 변경한다는 점에 주목하세요. 그러면 승리한 스레드가 아직 resume를 호출하지 않았더라도 경쟁하는 최종 이벤트는 finished만 볼 수 있습니다. 승자는 꺼낸 continuation을 로컬 변수에 보관하므로, 다른 호출이 이를 다시 꺼낼 수 없습니다.
완료와 취소를 같은 게이트에 연결하기
콜백 계산은 전역 디스패치 큐를 사용합니다. Apple 문서에 따르면 비동기로 제출한 작업은 제출한 코드와 별도로 진행되며, 백그라운드 디스패치 작업의 스레드 식별은 보장되지 않습니다. 따라서 여기에는 스레드와 무관한 조정 방식이 적합합니다. DispatchQueue
enum CalculationError: Error {
case outOfRange
}
func startDoubling(
_ value: Int,
completion: @escaping @Sendable (Result<Int, any Error>) -> Void
) {
DispatchQueue.global().async {
guard (-1000...1000).contains(value) else {
completion(.failure(CalculationError.outOfRange))
return
}
completion(.success(value * 2))
}
}
func doubled(_ value: Int) async throws -> Int {
let gate = IntContinuationGate()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Int, any Error>) in
guard gate.install(continuation) else { return }
startDoubling(value) { result in
gate.finish(result)
}
}
} onCancel: {
gate.cancel()
}
}
취소 핸들러 범위에 들어가기 전에 게이트를 만드세요. 그러면 이미 취소된 태스크가 즉시 취소를 기록할 수 있습니다. 그다음 continuation이 설치되면 저장된 오류로 재개하고 false를 반환하므로 어댑터는 계산 시작을 건너뜁니다.
설치가 true을 반환한 뒤 startDoubling가 호출되기 전에 취소가 발생할 수도 있습니다. 이 순서에서는 계산이 시작되어 완료되지만, 취소가 이미 승리했으므로 계산 결과는 무시됩니다. 래퍼는 대기를 취소하지만 이처럼 작고 범위가 제한된 계산을 중단한다고 보장하지는 않습니다.
지원되는 continuation 연결 함수는 withCheckedThrowingContinuation입니다. 해당 클로저는 값을 반환하거나 오류를 던질 때 사용하는 continuation을 전달받습니다. 예시에서는 추론에 모호함이 없도록 continuation의 정수 결과 형식과 오류 형식을 명시합니다. withCheckedThrowingContinuation(isolation:function:_:)
지연에 의존하지 않고 이벤트 순서 확인하기
시간 지연으로는 경합에서 어느 이벤트가 이겼는지 알 수 없습니다. 대신 다음 검사는 게이트를 통해 설치, 완료, 취소의 여섯 가지 순서를 모두 직접 실행합니다. 각 순서에는 추가 최종 이벤트도 보내 선택된 결과가 변하지 않는지 확인합니다.
enum GateEvent {
case install
case complete
case cancel
}
func checkOrder(
_ events: [GateEvent],
expected: Int?
) async throws {
let gate = IntContinuationGate()
do {
let value: Int = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Int, any Error>) in
for event in events {
switch event {
case .install:
_ = gate.install(continuation)
case .complete:
gate.finish(.success(42))
case .cancel:
gate.cancel()
}
}
// Neither of these may replace the selected result.
gate.finish(.success(99))
gate.cancel()
}
guard let expected else {
preconditionFailure("Expected cancellation")
}
precondition(value == expected, "Unexpected value")
} catch is CancellationError {
precondition(expected == nil, "Unexpected cancellation")
}
}
@main
struct ContinuationRaceDemo {
static func main() async throws {
try await checkOrder(
[.install, .complete, .cancel], expected: 42
)
try await checkOrder(
[.install, .cancel, .complete], expected: nil
)
try await checkOrder(
[.complete, .install, .cancel], expected: 42
)
try await checkOrder(
[.complete, .cancel, .install], expected: 42
)
try await checkOrder(
[.cancel, .install, .complete], expected: nil
)
try await checkOrder(
[.cancel, .complete, .install], expected: nil
)
let value = try await doubled(21)
precondition(value == 42)
do {
_ = try await doubled(1001)
preconditionFailure("Expected outOfRange")
} catch CalculationError.outOfRange {
// Expected input-domain error.
}
// Integration smoke check: either terminal outcome is valid.
for _ in 0..<100 {
let task = Task { try await doubled(21) }
task.cancel()
do {
let result = try await task.value
precondition(result == 42)
} catch is CancellationError {
// Cancellation won.
}
}
print("Event-order checks and integration checks passed.")
}
}
파일이 있는 디렉터리에서 컴파일하고 실행하세요.
swiftc -swift-version 6 -parse-as-library ContinuationRace.swift -o ContinuationRace
./ContinuationRace
정상적으로 실행되면 Event-order checks and integration checks passed.가 출력됩니다. 명시적인 순서 검사는 상태 전이를 검증합니다. 통합 반복문은 실제 어댑터를 실행하지만 특정 스케줄링 순서가 발생한다고 보장하지 않으며 모든 동시 실행을 테스트했음을 증명하지도 않습니다.
구현이 정확히 한 번 재개하는 이유는 무엇인가요?
최대 한 번 재개되는지 보려면 finish의 첫 번째 성공 호출을 살펴보세요. 이 호출은 현재 상태를 원자적으로 finished로 바꿉니다. continuation이 대기 중이라면 이 호출만이 이를 꺼냅니다. 이후의 모든 finish 호출은 아무것도 재개하지 않고 반환합니다. continuation이 아직 없다면 결과는 한 번의 설치를 기다립니다.
최종적으로 재개되는 경로는 두 가지입니다. 설치가 먼저 이루어지고 첫 최종 이벤트가 대기 중인 continuation을 재개하거나, 최종 이벤트가 먼저 발생하고 설치가 저장된 결과로 재개합니다. 이 논증은 설치가 한 번 이루어지고 최종 이벤트가 하나 이상 발생한다고 가정합니다. 완료되지 않는 일반 콜백 소스와 취소되지 않는 태스크를 함께 사용하면 호출자는 여전히 대기 상태로 남습니다.
이 예시의 콜백은 범위가 제한된 계산을 수행하고 성공 경로와 입력 오류 경로 모두에서 완료를 호출합니다. 승자가 결정된 뒤의 중복 최종 콜백은 의도적으로 무시합니다. 이는 지정된 단일 결과 계약에 적합하지만, 의미 있는 값이 여러 개 전달되는 스트림에는 다른 인터페이스가 필요합니다.
어떤 지름길이 계약을 깨뜨리나요?
선택적 continuation을 확인한 다음 nil로 설정하는 작업을 동기화하지 않으면 충분하지 않습니다. 두 개의 동시 호출이 제거하기 전에 같은 nil이 아닌 continuation을 읽을 수 있습니다. 확인, 꺼내기, 최종 상태 변경은 하나의 보호된 작업이어야 합니다.
continuation을 만들기 전에 취소 여부를 확인해도 빈틈이 생깁니다. 확인 직후 취소가 발생할 수 있습니다. 핸들러와 기억된 최종 상태가 그 빈틈을 막습니다. Swift 언어 가이드는 취소가 협력적이라고 설명하며 즉시 알림을 받는 수단으로 취소 핸들러를 소개합니다. 동시성 — Swift 프로그래밍 언어
공유 잠금을 잡은 채 재개하면 또 다른 문제가 생깁니다. Apple은 취소 핸들러가 내부 태스크 잠금이 잡힌 상태에서 실행될 수 있고 continuation 재개가 해당 잠금을 획득할 수 있다고 명시적으로 경고합니다. 따라서 예시에서는 모든 resume 호출 전에 잠금을 해제합니다. 취소 핸들러와 잠금
마지막으로 continuation이 반환된 뒤 Task.checkCancellation()를 추가하면 이 예시의 외부 계약이 달라집니다. 게이트에서 완료가 승리한 뒤에도 취소가 값을 대체할 수 있습니다. 이 정책은 신중하게 결정하세요. 여기서 구현한 계약에서는 설치, 완료, 취소가 모두 하나의 상태 머신을 사용하며 첫 번째 최종 상태 전이가 호출자의 결과를 결정합니다.
출처
- CheckedContinuation — Apple.
- withTaskCancellationHandler(operation:onCancel:isolation:) — Apple.
- cancel() — Apple.
- NSLock — Apple.
- Sendable — Apple.
- DispatchQueue — Apple.
- withCheckedThrowingContinuation(isolation:function:_:) — Apple.
- 명령줄 도구 빌드하기 — Swift.org.
- 동시성 — Swift 프로그래밍 언어 — Swift 프로젝트.