apple/iOS, UIKit, Documentation

sending parameter and result values

lgvv 2025. 10. 7. 03:26

sending parameter and result values

 

(링크)

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0430-transferring-parameters-and-results.md

 

swift-evolution/proposals/0430-transferring-parameters-and-results.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

 

 

Introduction

 

region isolation 개념을 확장하여 함수의 매개변수와 반환값에 명시적으로 sending 어노테이션을 적용.
sending으로 표시된 함수의 매개변수나 반환값을 disconnected에 위치하며 아래와 같은 특성을 지님.

  • 함수 내부: 해당 값이 다른 격리 도메인 으로 안전하게 전달될 수 있음
  • 함수 호출 측: 해당 값이 actor-isolated region 에 안전하게 병합(merge)될 수 있음

 

Motivation

 

SE-0414에서 region isolation 개념을 도입하여, non-Sendable 타입의 값도 격리 경계를 넘어 안전하게 전달될 수 있도록 함.

하지만 대부분의 경우, 함수의 인자(argument)와 반환값(result)은 호출 시점에서 동일한 영역(region)으로 병합

이로 인해 Sendable이 아닌 타입의 매개변수(parameter) 값은 격리 영역 간에 전달될 수 없음

 

// Compiled with -swift-version 6

class NonSendable {}

@MainActor func main(ns: NonSendable) {}

func trySend(ns: NonSendable) async {
  // error: sending 'ns' can result in data races.
  // note: sending task-isolated 'ns' to main actor-isolated 
  //       'main' could cause races between main actor-isolated
  //       and task-isolated uses
  await main(ns: ns)
}

 

Actor initializer에는 특별한 규칙이 있는데, 초기화 함수의 매개변수 값은 해당 actor 인스턴스의 isolation region으로 전달되어야 함.

 

Actor 초기화 함수는 nonisolated이기 때문에, actor 초기화를 호출할 때는 isolation region를 넘지 않음.

일반적인 region isolation 규칙에 따르면, 초기화가 끝난 후에도 인자 값(argument value)은 호출자(caller) 쪽에서 계속 사용할 수 있어야 함.

 

하지만, SE-0414에서는 actor-isolated state를 해당 인자 값으로 초기화할 수 있도록 하기 위해서 actor 초기화 함수의 매개변수들은 actor의 region으로 전송되는 것으로 간주.

 

위의 코드는 ns는 non-Sendable 값으로 현재 Task Region에 속해 있는데, 이 값을 MainActor region으로 전송하기때문에 에러가 발생

 

class NonSendable {}

actor MyActor {
  let ns: NonSendable
  init(ns: NonSendable) {
    self.ns = ns
  }
}

func send() {
  let ns = NonSendable()
  let myActor = MyActor(ns: ns) // okay; 'ns' is sent into the 'myActor' region
}

func invalidSend() {
  let ns = NonSendable()

  // error: sending 'ns' may cause a data race
  // note: sending 'ns' from nonisolated caller to actor-isolated
  //       'init'. Later uses in caller could race with uses on the actor.
  let myActor = MyActor(ns: ns)

  print(ns) // note: note: access here could race
}

 

위의 코드에서 send 안의 지역변수 ns가 함수의 파라미터였다면, ns를 myActor의 region으로 전송하는건 유효하지 않음.

왜냐하면, caller가 send() 호출 이후에도 그 arguments를 계속 사용할 수 있기 때문.

 

func send(ns: NonSendable) {
  // error: sending 'ns' may cause a data race
  // note: task-isolated 'ns' to actor-isolated 'init' could cause races between
  //       actor-isolated and task-isolated uses.
  let myActor = MyActor(ns: ns)
}

func callSend() {
  let ns = NonSendable()
  send(ns: ns)
  print(ns)
}

 

Actor initializer시 actor 인스턴스의 isolation region으로 전달되므로 오류가 발생.

 

@MainActor var mainActorState: NonSendable?

nonisolated func test() async {
  let ns = await withCheckedContinuation { continuation in
    Task { @MainActor in
      let ns = NonSendable()
      // Oh no! 'NonSendable' is passed from the main actor to a
      // nonisolated context here!
      continuation.resume(returning: ns)

      // Save 'ns' to main actor state for concurrent access later on
      mainActorState = ns
    }
  }

  // 'ns' and 'mainActorState' are now the same non-Sendable value;
  // concurrent access is possible!
  ns.mutate()
}


위 코드에서,
withCheckedContinuation의 클로저 인자는 isolation boundary 넘어 MainActor로 이동하기 위해 실행됨.

그 안에서 Non-Sendable 타입의 값을 생성한 뒤, 그 값을 사용해 resume(returning:)을 호출하여 continuation을 resume.

 

이때, Non-Sendable 타입의 값은 원래의 nonisolated 컨텍스트로 반환되므로, 결과적으로 isolation boundary를 한번 더 넘나들게 됨.

 

하지만 resume(returning:)의 매개변수에는 Sendable이 제약이 적용되지 않고, 이에 따라서 -strict-concurrency=complete 옵션을 사용하더라도 데이터 레이스 안전성 진단(data-race safety diagnostics)이 발생하지 않음.

 

resume(returning:)의 매개변수 타입에 Sendable을 요구하는 것은 너무 강한 제약이라서 대신 그 값이 disconnected region 상태이고, resume(returning:) 호출 이후 그 영역의 값들이 다시 사용되지 않는다면, Non-Sendable 타입의 값을 전달하더라도 safe 하다고 설명

 

즉, mainActorState에 값을 저장함으로써 isolation boundary를 넘게되어 Non-Sendable 타입의 값이 동시 접근이 가능해져 잠재적 data race의 조건이 됨.

 

 

Proposed solution

이 제안은 값이 격리 경계를 넘어 sendable capability을 가진 것임을 명시적으로 지정할 수 있도록 하는 것으로, contextual sending keyword(sending)를 사용하여 parameter와 result 에 이러한 특성을 표시할 수 있게 하는 것을 목표로 함.

 

즉, sending이라는 키워드를 통해 값이 안전하게 넘나들 수 있음을 명시적으로 표현.

 

public struct CheckedContinuation<T, E: Error>: Sendable {
  public func resume(returning value: sending T)
}

public func withCheckedContinuation<T>(
    function: String = #function,
    _ body: (CheckedContinuation<T, Never>) -> Void
) async -> sending T

 

 

Detailed design

 

Sendable Values and Sendable Types

Sendable 프로토콜을 준수하는 타입은 thread-safe 타입.

즉, 해당 타입의 값은 여러 동시(concurrent) 컨텍스트에서 동시에 사용해도 data race 없이 안전하게 공유하고 사용할 수 있음.

 

반대로, 값이 Sendable을 준수하지 않는 경우, Swift는 해당 값이 동시에 사용되지 않도록 보장해야 함.

Non-Sendable 값도 동시 컨텍스트 사이로 전달할 수는 있지만, 이때는 값의 전체 영역(entire region)을 완전히 이전(complete transfer) 해야함.

즉, 원본(concurrency source) 컨텍스트에서 값과 그 값으로 접근 가능한 모든 Non-Sendable 값의 사용이 종료된 후에야 대상(destination) 컨텍스트에서 값을 사용할 수 있음.

 

Swift는 이를 보장하기 위해 disconnected region 존재하도록 요구하며, 이런 값을 sending value라고 부름.

따라서, 기존 영역과 연결되지 않은 새로 생성된 값(newly-created value)은 항상 sending value가 됩니다.

 

func f() async {
  // This is a `sending` value since we can transfer it safely...
  let ns = NonSendable()

  // ... here by calling 'sendToMain'.
  await sendToMain(ns)
}

 

한 번 정의된 sending value는 다른 isolation region으로 병합될 수 있음.

일단 병합되면, 해당 영역이 disconnected라면 그 값은 더 이상 다른 격리 도메인으로 전송될 수 없음.

즉, 이제 그 값은 더 이상 sending value가 아닌걸로 간주

 

actor MyActor {
  var myNS: NonSendable

  func g() async {
    // 'ns' is initially a `sending` value since it is in a disconnected region...
    let ns = NonSendable()

    // ... but once we assign 'ns' into 'myNS', 'ns' is no longer a sending
    // value...
    myNS = ns

    // ... causing calling 'sendToMain' to be an error.
    await sendToMain(ns)
  }
}

 

만약 sending valueisolation region이 다른 disconnected isolation region과 병합된다면 그 값은 여전히 sending value로 간주

그 이유는, 두 개의 분리된 영역이 병합되면 새로운 disconnected region 형성되기 때문.

 

func h() async {
  // This is a `sending` value.
  let ns = Nonsending()

  // This also a `sending` value.
  let ns2 = NonSendable()

  // Since both ns and ns2 are disconnected, the region associated with
  // tuple is also disconnected and thus 't' is a `sending` value...
  let t = (ns, ns2)

  // ... that can be sent across a concurrency boundary safely.
  await sendToMain(t)
}

 

 

sending Parameters and Results

 

sending function parameter는, 인자로 전달되는 값이 disconnected에 속해야 함.

 

함수가 호출되는 시점에서, 그 disconnected region은 callerisolation domain에 더 이상 속하지 않음.

따라서, callee는 그 매개변수 값을 호출자에게는 opaque 새로운 영역으로 자유롭게 전송할 수 있음.

 

@MainActor
func acceptSend(_: sending NonSendable) {}

func sendToMain() async {
  let ns = NonSendable()

  // error: sending 'ns' may cause a race
  // note: 'ns' is passed as a 'sending' parameter to 'acceptSend'. Local uses could race with
  //       later uses in 'acceptSend'.
  await acceptSend(ns)

  // note: access here could race
  print(ns)
}

 

callee가 인자 값을 어떻게 처리하는지는 caller는 알 수 없음.

즉, callee는 그 값을 다른isolation region으로 전송 할 수도 있고매개변수 중 하나의 격리 영역과 병합 할 수도 있음.

 

한편, sending result는 함수의 구현부가 disconnected region 속한 값을 반환해야 함을 의미함.

@MainActor
struct S {
  let ns: NonSendable

  func getNonSendableInvalid() -> sending NonSendable {
    // error: sending 'self.ns' may cause a data race
    // note: main actor-isolated 'self.ns' is returned as a 'sending' result.
    //       Caller uses could race against main actor-isolated uses.
    return ns
  }

  func getNonSendable() -> sending NonSendable {
    return NonSendable() // okay
  }
}

 

sending result를 반환하는 함수를 호출하는 caller쪽은 반환값이 disconnected region에 속해 있다고 가정할 수 있음.

 

즉, Sendable 프로토콜을 따르지 않는 non-Sendable typed 결과 값이라도 actor isolation boundary를 안전하게 넘어갈 수 있음.

 

@MainActor func onMain(_: NonSendable) { ... }

nonisolated func f(s: S) async {
  let ns = s.getNonSendable() // okay; 'ns' is in a disconnected region

  await onMain(ns) // 'ns' can be sent away to the main actor
}

 

Function subtyping

 

어떤 타입 T에 대해, sending TTsubtype임.

 

sending은 parameter 위치에서 반공변(contravariant)적임.

즉, 함수가 일반적인 타입 T의 매개변수를 기대하는 경우, disconnected region에 속한 sending T 값을 전달하는 것은 유효함.


반대로, 함수가 sending T 타입의 parameter를 기대하는 경우에는, non-disconnected value 을 전달하는 것은 유효하지 않음.

func sendingParameterConversions(
  f1: (sending NonSendable) -> Void,
  f2: (NonSendable) -> Void
) {
  let _: (sending NonSendable) -> Void = f1 // okay
  let _: (sending NonSendable) -> Void = f2 // okay
  let _: (NonSendable) -> Void = f1 // error
}

 

하지만, sending은 함수의 반환 위치에서 공변(covariant)적임.


어떤 함수가 sending T 타입의 값을 반환한다면, 그 result value는 다른 parameters 동일한 region으로 merge된 것으로 간주.


반대로 함수가 일반적인 T 타입의 값을 반환한다면, non-disconnected value 영역에 있다고 가정하는건 유효하지 않음.

 

func sendingResultConversions(
  f1: () -> sending NonSendable,
  f2: () -> NonSendable
) {
  let _: () -> sending NonSendable = f1 // okay
  let _: () -> sending NonSendable = f2 // error
  let _: () -> NonSendable = f1 // okay
}

 

 

covariant와 contravariant란?

 

covariant: 하위 타입을 상위 타입 자리에 둘 수 있는 관계

contravariant: covariant의 반대

 

공변 (Covariant) 하위 → 상위 Dog → Animal 반환값 위치에서 사용 가능
반공변 (Contravariant) 상위 → 하위 Animal → Dog 매개변수 위치에서 사용 가능
불변 (Invariant) 둘 다 불가능 - 타입을 그대로 유지해야 함

 

 

Protocol conformances


프로토콜은 sending이 붙은 매개변수나 반환값(annotation)을 포함할 수 있음.

protocol P1 {
  func requirement(_: sending NonSendable)
}

protocol P2 {
  func requirement() -> sending NonSendable
}

 

함수 subtyping 규칙에 따라,sending 매개변수를 가진 프로토콜 요구사항은 sending이 없는 일반 매개변수를 가진 함수로 witnessed될 수 있음.

 

struct X1: P1 {
  func requirement(_: sending NonSendable) {}
}

struct X2: P1 {
  func requirement(_: NonSendable) {}
}

 

sending 반환값을 가진 프로토콜 요구사항은 반드시 sending 반환값을 가진 함수로 witnessed되어야 하며, 일반적인 T 타입의 반환값을 요구하는 프로토콜은 sending T를 반환하는 함수로 witnessed될 수 있음.

 

struct Y1: P1 {
  func requirement() -> sending NonSendable {
    return NonSendable()
  }
}

struct Y2: P1 {
  let ns: NonSendable
  func requirement() -> NonSendable { // error
    return ns
  }
}

 

inout sending parameters

sending 매개변수는 inout으로도 표시할 수 있음.

이때 인자로 전달되는 값은 함수에 전달될 때 disconnected region에 있어야 하며, 함수가 반환될 때도 매개변수의 값은 disconnected region에 있어야 함.


함수 내부에서는 inout sending 매개변수를 actor에 격리된 함수와 병합하거나, 다른 곳으로 전송 할 수도 있음.

단, 함수가 종료될 때는 그 매개변수가 반드시 새로운 독립된 영역의 값으로 재할당되어야 함.

 

Ownership convention for sending parameters

호출이 sending 매개변수로 인자를 전달하면, caller는 해당 인자 값을 함수가 반환된 이후 다시 사용할 수 없음.

기본적으로, 함수 매개변수에 sending이 붙으면 callee가 그 매개변수를 consumes 한다는 의미.

consuming 매개변수처럼, sending 매개변수도 함수 내부에서 재할당 될 수 있음.

 

하지만 consuming 매개변수와는 달리, sending 매개변수는 자동 복사 방지(no-implicit-copying) 의미를 갖지 않ㅇ므.

만약 자동 복사 방지를 명시적으로 적용하거나 기본 소유권 규칙을 바꾸고 싶다면, sending은 명시적인 consuming 소유권 한정자(modifier) 와 함께 사용할 수 있음.

 

func sendingConsuming(_ x: consuming sending T) { ... }