ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SE-0528] Continuation — Safe and Performant Async Continuations
    Swift 2026. 5. 30. 11:19

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는 SE-0528 — 안전하고 성능 좋은 Async Continuation, Continuation 타입 도입에 대해 정리해보겠습니다 🙋🏻


    Intro

    • Proposal: SE-0528
    • Authors: Fabian Fett, Konrad Malawski
    • Review Manager: Joe Groff
    • Status: Accepted with revisions

    Motivation

    Continuation은 콜백 기반 API를 Swift Structured Concurrency로 연결하는 핵심 메커니즘입니다.

    그런데 현재 개발자는 두 가지 옵션 중 하나를 골라야 하는 불편한 선택을 강요받고 있어요.

    • UnsafeContinuation — 오버헤드는 없지만, 두 번 resume하면 undefined behavior, resume을 빠뜨리면 조용히 task가 영원히 leak됩니다 🐛
    • CheckedContinuation — 런타임 bookkeeping으로 실수를 감지하지만, allocation과 atomic 연산 오버헤드가 발생합니다.

    Continuation은 정확히 한 번만 resume해야 하는 "use-exactly-once" 값입니다.

    이건 move-only 타입이 강제할 수 있는 계약이에요. 타입 시스템이 이 문제를 해결해야 합니다!


    Proposed Solution

    Continuation<Success: ~Copyable, Failure: Error>, 즉 ~Copyable struct를 도입합니다. 세 가지 메커니즘으로 올바른 사용을 강제합니다.

    • Move-only semantics (~Copyable) — continuation을 복사할 수 없으므로, 두 곳에서 resume하는 것이 불가능합니다.
    • consuming 메서드 — 모든 resume 메서드는 self를 consume해 두 번째 호출은 컴파일 에러가 됩니다.
    • deinit 트랩 + discard self — resume 없이 continuation이 drop되면 fatalError. 정상 resume 시엔 discard self로 deinit을 억제해 오버헤드가 없어요.

    Double-resume — 컴파일 에러로 해결!

    actor LegacyBridge {
      func complete(with value: String) {
        if let continuation {
          continuation.resume(returning: value) // ✅ consumes continuation
          continuation.resume(returning: value) // ❌ compile error:
                                                // 'continuation' used after consuming use
        }
      }
    }

     

    Missing-resume — 런타임 트랩으로 포착!

    actor LegacyBridge {
      func cancel() {
        // Bug: resume 없이 continuation을 지워버림
        self.continuation = nil // 💥 runtime trap: "This continuation was dropped."
      }
    }

    Detailed Design

    Continuation 타입 정의

    @frozen
    public struct Continuation<Success: ~Copyable, Failure: Error>: ~Copyable, Sendable {
    
      deinit {
        fatalError("The continuation was dropped without resuming.")
      }
    
      @inlinable
      public consuming func resume(returning value: consuming sending Success) { ... }
    
      @inlinable
      public consuming func resume(throwing error: Failure) { ... }
    }
    • Sendable — 다른 task/thread에서 resume할 수 있도록 isolation boundary를 넘을 수 있습니다.
    • consuming — 각 resume 메서드가 self를 consume해 이후 사용이 불가능해집니다.
    • sending Success — non-Sendable 값을 async task에 안전하게 전달할 수 있습니다.
    • consuming Success — noncopyable 타입도 값으로 전달할 수 있습니다.
    • discard self — 정상 resume 시 deinit을 억제해 오버헤드가 없습니다.

    withContinuation — of: 와 throwing: 파라미터

    // Before: 클로저 파라미터에 타입 어노테이션 필요
    let data = await withCheckedContinuation { (continuation: UnsafeContinuation<Data, Never>) in
      bridge.store(continuation)
    }
    
    // After: 호출부에서 타입이 명확하게 보임
    let data = await withContinuation(of: Data.self) { continuation in
      bridge.store(continuation)
    }

    throwing: 파라미터는 SE-0413(typed throws)를 활용해 세 가지 경우를 하나의 함수로 통합합니다.

    // Non-throwing
    let data = await withContinuation(of: Data.self) { continuation in ... }
    
    // Typed throwing
    let data = try await withContinuation(of: Data.self, throwing: NetworkError.self) { continuation in ... }
    
    // Untyped throwing
    let data = try await withContinuation(of: Data.self, throwing: (any Error).self) { continuation in ... }

     

    CheckedContinuation으로 변환하기

    콜백이 여러 개인 경우처럼 noncopyable Continuation을 사용할 수 없는 경우엔 CheckedContinuation으로 변환해 사용할 수 있습니다.

    try await withContinuation(of: Int.self, throwing: (any Error).self) { c in
      let checked = CheckedContinuation(c)
      someLib.onSuccess { checked.resume(returning: $0) } // ✅
      someLib.onFailure  { checked.resume(throwing: $0)  } // ✅
    }

     

    이 때문에 CheckedContinuationUnsafeContinuationdeprecated되지 않고 계속 유지됩니다.

     

    동작 비교표

    시나리오 UnsafeContinuation CheckedContinuation Continuation
    정상 resume ✅ 동작 ✅ 동작 ✅ 동작
    Double resume ⚠️ Undefined Behavior 💥 Runtime trap ❌ 컴파일 에러
    Missing resume 😶 Silent hang ⚠️ Runtime warning 💥 Runtime trap
    런타임 오버헤드 없음 allocation + atomic 없음

    Implications on Adoption

    Before After
    withCheckedContinuation { … } withContinuation(of: T.self) { … }
    withCheckedThrowingContinuation { … } withContinuation(of: T.self, throwing: (any Error).self) { … }
    CheckedContinuation<T, E> Continuation<T, E>

     

    단, Continuation~Copyable이기 때문에, 클로저 캡처처럼 continuation을 암묵적으로 복사하는 패턴은 컴파일 에러가 됩니다.

    이 경우엔 기존 Checked/UnsafeContinuation을 사용해야 합니다.


    Source Compatibility / ABI

    • 이번 변경은 순수 additive 변경으로, 기존 코드에 영향을 주지 않습니다.
    • withContinuationContinuation이라는 이름은 기존 표준 라이브러리 API와 충돌하지 않습니다.
    • ABI 변경도 없습니다.

    Future Directions

    ~Discardable 프로토콜 도입

    현재는 continuation이 drop될 때 런타임 트랩에 의존하고 있지만, ~Copyable 타입을 위한 새로운 모드인 ~Discardable을 도입해 "반드시 명시적으로 consume해야 함"을 컴파일러가 강제하게 할 수 있어요.

    이렇게 되면 런타임 트랩마저 컴파일 에러로 격상될 수 있습니다.


    Conclusion

    드디어 오버헤드 없이 안전한 Continuation이 생겼습니다 🙌

    기존에는 안전성(CheckedContinuation)과 성능(UnsafeContinuation) 사이에서 트레이드오프를 강요받았는데, move-only 타입의 강점을 활용해 double-resume은 컴파일 에러로, missing-resume은 런타임 트랩으로 잡아주면서 오버헤드는 UnsafeContinuation과 동일하게 유지됩니다.

    Swift 타입 시스템의 힘으로 콜백 브리징의 고질적인 버그 두 가지를 완전히 해결한 우아한 변경이라고 생각합니다 😄


    References

     

    swift-evolution/proposals/0528-noncopyable-continuation.md at main · swiftlang/swift-evolution

    This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution

    github.com

     

Designed by Tistory.