ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SE-0538] Disconnected
    Swift 2026. 8. 22. 08:38

    안녕하세요. 그린입니다 🍏
    이번 포스팅에서는 SE-0538 — Disconnected에 대해 정리해보겠습니다 🙋🏻

    Intro

    Proposal: SE-0538

    Author: Franz Busch

    Review Manager: Holly Borla

    Status: Active review (July 17 - July 31, 2026)

    Implementation: swiftlang/swift#89597

    SE-0414는 control flow 기반 진단을 활용해서 non-Sendable 값을 isolation 경계 너머로 안전하게 보낼 수 있는지 판단하는 region-based isolation을 도입했어요.

    SE-0430은 함수 경계에서 disconnected region에 있어야 하는 값을 명시적으로 표시하는 sending 파라미터/리턴 어노테이션을 도입했고요.

     

    이 제안은 값의 disconnected 속성을 자료구조나 actor 같은 저장소를 거쳐서도 유지해주는 Disconnected 타입을 도입해요.

    제네릭 타입이 sending 이펙트를 신경 쓰지 않고도 non-Sendable 값을 isolation region 너머로 안전하게 옮길 수 있게 해줍니다 🙌

     

    Motivation

    region-based isolation 덕분에 값이 disconnected region에 있으면 isolation 경계를 넘어 non-Sendable 값을 전달할 수 있어요.

    SE-0430의 sending 파라미터/리턴 어노테이션은 함수 경계에서 disconnected 값을 명시적으로 요구할 수 있게 해주지만, 저장 프로퍼티나 컬렉션 타입, 제네릭 컨테이너를 거치면 sending은 유지되지 않아요.

     

    isolation 경계 너머로 처리할 원소를 저장하는 큐 구현을 생각해볼게요.

     

    가상의 UniqueDeque를 예로 들어봅니다.

    struct UniqueDeque: ~Copyable {
      func append(_ element: consuming Element) { ... }
      func popFirst() -> Element? { ... }
    }

     

    UniqueDeque에 non-Sendable한 disconnected 값을 append하고, 나중에 pop할 때 다른 isolation region으로 보내고 싶은 경우가 있을 수 있어요 😁

     

    var deque = UniqueDeque()
    deque.append(NonSendable())
    
    guard let element = deque.popFirst() else { return }
    
    Task {
        print(element) // Error: Element is assumed to be in the same isolation region as uniqueDeque
    }

     

    이걸 되게 하려면 append에서 원소를 consume하고, popFirst에서 sending으로 반환해야 해요.

    하지만 그러면 non-Sendable이지만 disconnected는 아닌 원소를 저장하고 싶은 다른 중요한 용례에서 이 타입이 크게 제약을 받게 됩니다.

     

    근본적인 한계는 sending이 함수 경계의 속성이지 타입의 속성이 아니라는 점이에요.

    UniqueDeque 같은 제네릭 타입은 저장된 원소가 disconnected 상태를 유지해야 하는지를 조건부로 제어할 수 없어요.

    appendpopFirstsending으로 요구하면 결국 disconnected 값만 UniqueDeque에 담을 수 있게 되는 셈입니다.

     

    Proposed Solution

    disconnected 값을 모델링할 수 있는 새로운 Disconnected 타입을 도입합니다.

    var deque = UniqueDeque<Disconnected>()
    deque.append(Disconnected(NonSendable()))
    
    guard var disconnected = deque.popFirst() else { return }
    let element = disconnected.take()
    
    Task {
        print(element)
    }

     

    Disconnected 타입은 값을 wrap해서 disconnected region에 머물도록 보장해줘요.

    take() 메서드는 Disconnected wrapper를 consume하고 값을 sending으로 반환해서 isolation 경계를 넘을 수 있게 해줍니다.

     

    Detailed Design

    Disconnected 타입은 타입 시스템을 통해 region isolation을 강제하는 wrapper예요.

     

    Disconnected 타입 정의

    @frozen
    public struct Disconnected: ~Copyable, Sendable {
        public init(_ value: consuming sending Value)
    
        public consuming func take() -> sending Value
    
        @discardableResult
        public mutating func swap(
            newValue: consuming sending Value
        ) -> sending Value
    
        public mutating func withValue(
            body: (inout sending Value) throws(Failure) -> Return
        ) throws(Failure) -> Return
    }

     

    disconnected region이란?

    region-based isolation은 프로그램 실행 중 어느 시점에 존재하는 값들을, 어떤 참조가 어떤 저장소에 도달하는지에 따라 isolation region으로 나눕니다.

    어떤 값의 저장소에 다른 region으로부터/다른 region으로의 참조가 전혀 도달하지 않으면, 그 값은 disconnected region에 있는 거예요.

     

    이런 값은 actor, Task, 다른 동시성 컨텍스트 등 다른 isolation region으로 옮겨도 안전한데, 옮기는 행위 자체가 데이터 레이스를 만들 수 없기 때문이에요.

     

    실제로 disconnected region은 보통 다음 중 하나에서 생겨요.
    • 초기화 인자 자체가 disconnected였던, 새로 생성된 값
    • 다른 disconnected 컨테이너에서 막 꺼낸 값
    • 함수 경계의 sending 파라미터 (callee가 disconnected region으로 전달받음)
    • 함수의 sending 리턴 값 (caller가 disconnected region으로 전달받음)

    disconnected 속성은 보통 sending 경계에서만 추적돼요.

    Disconnected<Value>는 그렇지 않았다면 sending 경계를 벗어나는 순간 region 정보를 잃어버렸을 저장 경계(제네릭 컨테이너, 저장 프로퍼티, 큐)를 거치면서도 이 속성을 유지할 수 있게 해줘요.

     

    연산들

    값은 init(_:)을 통해 wrapper 안으로 들어오는데, 이때 sending 인자가 필요해요.

    값은 take()swap(newValue:)를 통해 나가는데, 둘 다 sending Value를 반환합니다.

    withValue(body:)는 wrap된 값을 inout sending Value로 받는 클로저에 그 자리에서 빌려줘요.

     

    모든 연산은 wrapper를 consume하거나, sending 경계를 통해 wrap된 값을 교체하기 때문에, wrapper의 저장소로 향하는 alias가 transfer보다 오래 살아남을 수 없어요.

    이 특성 덕분에 Value 자체가 Sendable인지와 무관하게 DisconnectedSendable을 채택할 수 있는 겁니다.

     

    isolation 경계를 넘을 수 있는 값 만들기

    take()로 wrap된 값을 꺼낼 수 있어요.

    이 호출은 wrapper를 consume해서, 그 이후로는 wrapper에 어떤 연산도 할 수 없습니다.

    반환된 값은 disconnected region에 있어서, 같은 표현식 안에서 바로 isolation 경계를 넘어 전달하거나 저장해뒀다가 나중에 전달할 수 있어요.

    final class Resource: ~Sendable {}
    
    // wrapper는 Disconnected 값을 담고 있던 큐 등에서 꺼낸 것이라,
    // 그 안의 resource는 이미 주변 컨텍스트와 disconnected된 것으로 알려져 있어요.
    func process(wrapper: consuming Disconnected) async {
        let resource = wrapper.take()
        await Task.detached {
            use(resource) // OK: 'resource'는 disconnected region에 있음
        }.value
    }

     

    결과에 disconnected 보장이 없었다면, 캡처된 resource는 caller의 region에 속한 것으로 간주되어 detached task에서의 캡처가 허용되지 않았을 거예요.

     

    wrap된 값을 제자리에서 교체하기

    swap(newValue:)로 한 번에 값을 새 값으로 교체할 수 있어요.

    newValue 인자는 disconnected region에 있어야 하고, swap은 이전에 저장돼 있던 값을 반환하는데 이 값도 disconnected region에 있습니다.

    final class Resource: ~Sendable {}
    
    func swapResources(in wrapper: inout Disconnected) async {
        let old = wrapper.swap(newValue: Resource())
        await Task.detached {
            dispose(old) // OK: 'old'는 disconnected region에 있음
        }.value
    }

     

    swap의 양방향 모두 disconnected-region 경계를 넘나들어요.

    새 값은 들어갈 때 disconnected여야 하고, 예전 값은 나올 때 disconnected로 알려집니다.

     

    꺼내지 않고 wrap된 값 mutate하기

    값을 꺼내지 않고 임시로 mutable 접근이 필요하면 withValue(body:)를 쓰면 돼요.

    클로저는 값을 inout sending Value로 전달받고, withValuebody가 반환한 값을 그대로 반환합니다.

    var wrapper = Disconnected([Int]())
    wrapper.withValue { array in
        array.append(42)
    }

     

    inout sending 파라미터 형태는 보통의 inout보다 더 많은 것을 의미해요.

    클로저 안에서는 값을 다른 isolation region으로 옮길 수 있는데, 단 클로저가 반환될 때 wrapper에는 disconnected 값이 남아 있어야 합니다.

    보통은 클로저가 제자리 mutation을 수행하지만, 이 더 관대한 형태 덕분에 withValue는 값을 다른 isolation region으로 보내고 싶어하는 코드와도 조합될 수 있어요.

     

    init(_:)

    주어진 값을 감싸는 disconnected wrapper를 만들어요.

    이 인자는 호출 시점에 disconnected region에 있어야 하는데, alias가 없는 새로 생성된 값이 이 요구사항을 바로 만족시킵니다.

    final class Resource: ~Sendable {}
    let wrapper = Disconnected(Resource())

     

    Sendable 채택과 exclusivity

    Disconnected 타입은 wrap된 값이 disconnected region에 있음을 보장하기 때문에 Sendable을 채택해요.

    disconnected region은 isolation 경계를 안전하게 넘을 수 있기 때문에, TSendable인지와 무관하게 Disconnected<T>를 공유하는 게 안전합니다.

     

    더불어 Disconnected의 모든 메서드는 consuming이거나 mutating이라서, 컴파일러가 정적/동적 exclusivity 검사를 강제해서 겹치거나 동시적인 접근을 금지해요.

    이 API는 의도적으로 sending 경계에서의 atomic transfer로만 제한돼요.

    initsending 값을 consume하고, take는 wrapper를 consume해서 sending 값을 반환하고, swap은 wrap된 값을 다른 sending 값과 교환하고, withValue는 클로저가 끝날 때 disconnected 값을 남겨야 하는 조건으로 wrap된 값을 inout sending으로 빌려줘요.

    wrap된 값을 consume하거나 교체하지 않고 노출하는 accessor는 없습니다.

    왜 borrow accessor가 무조건적인 Sendable 채택과 함께라면 안전하지 않은지는 Alternatives Considered 섹션에서 다룹니다.

     

    DisconnectedMutex, Atomic 등 표준 라이브러리가 제공하는 isolation 경계를 넘기 위한 다른 primitive들과 함께 Synchronization 모듈에 자리합니다.

     

    Conclusion

    Disconnectedsending이 함수 경계에만 머물던 정보를 저장소를 통과해서도 유지할 수 있게 해줘요.

    non-Sendable 값을 담는 제네릭 컨테이너나 큐를 만들 때, 그 원소가 disconnected인지 아닌지를 타입 하나로 명확히 구분할 수 있게 된 셈이죠.

    init/take/swap/withValue 모두 sending 경계에서의 atomic transfer로만 제한된 설계 덕분에, 무조건적인 Sendable 채택에도 안전성이 깨지지 않는다는 점이 인상적입니다 🙌

     

    References

     

    swift-evolution/proposals/0538-disconnected.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.