apple/iOS, UIKit, Documentation

Region based Isolation

lgvv 2025. 10. 6. 16:19

Region based Isolation

 

(링크)

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0414-region-based-isolation.md

 

swift-evolution/proposals/0414-region-based-isolation.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

 

Swift Concurrency에서 Actor와 Task의 경계에 의해 정의되는 isolation domain(격리 도메인) 내에서 값을 할당하고 서로 다른 isolation domain에서 실행되는 코드는 concurrently하게 실행될 수 있음.

 

Sendable checking은 non-Sendable 값들을 경계를 넘어 전달하는 것을 금지하여, 공유된 변경 가능한 상태에 대한 동시 접근을 원천적으로 차단. 하지만 데이터 레이스가 존재하지 않는 프로그래밍 패턴들에 대해서도 조차 금지하기 때문에 상당히 강한 제약으로 작용함.

 

control-flow-sensitive diagnostic을 통해 non-Sendable 값이 격리 경계를 넘어 안전하게 전달될 수 있는 새로운 접근법 제안하고, 이를 위해 isolation region이라는 개념을 도입해 컴파일러가 두 값이 서로 영향을 미칠 수 있는지 보수적으로 추론할 수 있음.

 

isolation region을 활용하면 프로그래밍 언어는 non-Sedndable 값을 격리 경계를 넘어 전달하더라도 데이터 레이스가 발생하지 않음을 증명할 수. 왜냐하면 해당 값이 전달 시점 이후 caller에서 더 이상 사용되지 않기 때문.

 

 

Motivation

 

SE-0302에서 non-Sendable 값이 isolation boundary을 넘어서 전달될 수 없다고 명시하고 아래 코드는 그 예시.

// Not Sendable
class Client {
    init(name: String, initialBalance: Double) { ... }
}

actor ClientStore {
    var clients: [Client] = []
    
    static let shared = ClientStore()
    
    func addClient(_ c: Client) {
        clients.append(c)
    }
}

func openNewAccount(name: String, initialBalance: Double) async {
    let client = Client(name: name, initialBalance: initialBalance)
    await ClientStore.shared.addClient(client) // Error! 'Client' is non-`Sendable`!
}

 

 

보수적인 제약 사항으로 인하여 오류가 발생하고, 프로그래밍 코드는 아래와 같은 이유로 안전함.

  1. clientSendable한 타입인 StringDouble을 초기화 매개변수로 받기 때문에, 초기화 과정에서 non-Sendable 상태에 접근하지 않음.
  2. client가 막 초기화된 상태이므로, openNewAccount 함수 외부에서 해당 객체를 사용할 수 없음.
  3. openNewAccount 내부에서도 clientaddClient 호출 이후에 더 이상 사용되지 않음.

Swift의 엄격한 동시성 검사로 인해 발생하는 표현력의 한계를 보여주고, 실제로 데이터 레이스가 존재하지 않음.
일반적인 코딩 패턴에 대해서도 개발자는 @unchecked Sendable을 conformance하여 unsafe한 우회를 사용해야 하는 상황에 놓임.

 

 

Proposed solution

 

non-Sendable 값을 isolation boundary을 넘어 전달할 수 있도록 허용할 수 있도록 허용하면서 사용하는 시점(use site)에서 오류를 제어하는 보고하는 control-flow-sensitive diagnostic 제안.

 

이 변경 사항으로 인하여 위의 코드 예제에서 addClient를 통해 ClientStore.shared로 전달된 후 더이상 사용되지 않기에 오류가 발생하지 않음.

 

하지만, openNewAccount를 addClient 호출 이후에 호출하도록 수정한다면, 해당 코드는 유효하지 않음.

non-isolated context에서 actor-isolated context로 전달된 non-Sendable 값이 다시 접근되어 concurrent access(동시 접근)이 발생할 수 있음.

func openNewAccount(name: String, initialBalance: Double) async {
    let client = Client(name: name, initialBalance: initialBalance)
    await ClientStore.shared.addClient(client)
    client.logToAuditStream() // Error! Already transferred into clientStore's isolation domain... this could race!
}

 

addClient 호출 이후, client로 부터 정적으로 non-referencable(참조될 가능성이 없는) 다른 non-Sendable 값들은 여전히 안전하게 사용 가능함.

이러한 성질은 isolation region 개념을 통해 증명할 수 있음.

 

 

isolation region (격리 영역) 정의

  1. x가 시점 p에서 yalias 관계 일 수 있음.
  2. x 또는 x의 프로퍼티가, y의 프로퍼티에 대한 연쇄 접근(chained access)을 통해 시점 p에서 참조될 가능성이 있음.

이러한 정의에 의해 x를 사용하는 코드가 y에 영향을 미칠 수 없기 때문에 서로 다른 격리 영역에 속하는 non-Sendable 값들이 동시에 사용될 수 있음을 보장

 

let john = Client(name: "John", initialBalance: 0)
let joanna = Client(name: "Joanna", initialBalance: 0)

await ClientStore.shared.addClient(john)
await ClientStore.shared.addClient(joanna) // (1)

 

위 코드에서 (1) 지점에서 john과 joanna는 서로 다른 격리 지점에 위치하므로 안전함.

 

let john = Client(name: "John", initialBalance: 0)
let joanna = Client(name: "Joanna", initialBalance: 0)

john.friend = joanna // (1)

await ClientStore.shared.addClient(john)
await ClientStore.shared.addClient(joanna) // (2)

 

하지만, (1) 지점에서 joannajohn.friend로 할당하면 john과 joanna는 같은 격리 도메인에 속하므로 동시에 실행될 가능성이 있어서 데이터 레이스 위험으로 진단함.

 

 

Detailed Design

 

isolation region은 non-Sendable 값들의 집합으로 해당 집합 내의 값들만이 서로 alias 관계를 가질 수 있거나 그 값으로 부터 접근 가능한 상태에 있을 수 있음.

 

 

isolation Domain (격리 도메인) 정의

  • Task: 특정 Task와 연관된 격리 도메인
  • Actor: 특정 Actor 인스턴스 또는 Global Actor에 의해 보호되는 도메인
  • disconnected: 어떤 특정 격리 도메인에도 연결되지 않은 상태 

 

isolation Domain (격리 도메인) 표기법

  • [(a)]: 단일 값으로 구성된, 어떤 도메인에도 연결되지 않은(disconnected) 격리 영역
  • [{(a), actorInstance}]: 특정 actorInstance에 격리된 단일 영역
  • [(a), {(b), actorInstance}]: 두 개의 격리 영역으로, a의 영역은 연결되지 않았고, b의 영역은 actorInstance의 격리 도메인에 속함
  • [{(x, y), @OtherActor}, (z), (w, t)]: 총 다섯 개의 값이 세 개의 서로 다른 격리 영역에 속함
    • xy는 전역 actor @OtherActor에 격리된 하나의 영역에 속함
    • z는 연결되지 않은(disconnected) 독립적인 영역에 속함
    • wt는 동일한 연결되지 않은(disconnected) 영역에 속함
  • [{(a), Task1}]: Task1의 격리 도메인에 속하는 단일 영역

 

 

Rules for Merging Isolation Regions

 

isolation region은 다른 값에 대한 alias 또는 access path를 새롭게 도입할 때 병합.

이러한 상황은 함수 호출 또는 할당에 의해 발생할 수 있고, 프로퍼티 접근 등에도 동일한 규칙 적용

 

 

 

Example (문서의 일부)

 

본문에 많은 예시가 있고, 학습한 내용 일부만 정리

 

 

Initializing a let or var binding

 

값 x로 상수 또는 변수 y를 초기화하면, y는 x와 동일한 격리 영역(region)에 속함.

복사(copy) 연산이 형식적으로는 x를 인자로 받아 x의 복사본을 반환하는 함수를 호출하는 것과 동등하기 때문

 

func bindingInitialization() {
  let x = NonSendable()
  // Regions: [(x)]
  let y = x
  // Regions: [(x, y)]
  let z = consume x
  // Regions: [(x, y, z)]
}

 

 

Assigning a var binding

 

변수 y에 값 x를 할당하면, yx와 동일한 격리 영역(region)에 속함.

만약 y가 클로저 내에서 참조 방식(reference) 으로 캡처되지 않았다면, y에 이전에 할당되어 있던 영역은 폐기(forget) 됨.

 

func mutableBindingAssignmentSimple() {
  var x = NonSendable()
  // Regions: [(x)]
  let y = NonSendable()
  // Regions: [(x), (y)]
  x = y
  // Regions: [(x, y)]
  let z = NonSendable()
  // Regions: [(x, y), (z)]
  x = z
  // Regions: [(y), (x, z)]
}

 

 

대조적으로, y가 클로저 내에서 참조(reference) 형태로 캡처된 경우에는, y이전 격리 영역(region)x의 영역과 병합(merge) 됨.

 

// Since we pass x as inout in the closure, the closure has to capture x by
// reference.
func mutableBindingAssignmentClosure() {
  var x = NonSendable()
  // Regions: [(x)]
  let closure = { useInOut(&x) }
  // Regions: [(x, closure)]
  let y = NonSendable()
  // Regions: [(x, closure), (y)]
  x = y
  // Regions: [(x, closure, y)]
}

 

 

Accessing a non-Sendable property of a non-Sendable value

 

non-Sendable 값 x의 속성 f에 접근하면, 결과값 y는 반드시 x와 같은 격리 도메인에 속함.

형식적으로 속성 접근(property access)은 xself 인자로 전달하는 getter 함수 호출과 동일

 

func assignFieldToValue() {
  let x = NonSendableStruct()
  // Regions: [(x)]
  let y = x.field
  // Regions: [(x, y)]
}

 

 

Setting a non-Sendable property of a non-Sendable value

 

프로퍼티 대입 연산은 본질적으로 x를 인자로 받고 y.f에 저장하는 함수 호출과 동등

이 연산 이후에는 x, y, 그리고 y.f가 모두 하나의 합쳐진 격리 영역을 공유

 

func assignValueToField() {
  let x = NonSendableStruct()
  // Regions: [(x)]
  let y = NonSendable()
  // Regions: [(x), (y)]
  x.field = y
  // Regions: [(x, y)]
}

 

 

Capturing non-Sendable values by reference in a closure

 

non-Sendable 값 x, y를 클로저가 캡처하면, x, y는 동일한 격리 도메인에 속함

이에 따라서 클로저 자체도 동일한 격리 도메인에 속함

 

func captureInClosure() {
  let x = NonSendable()
  let y = NonSendable()
  // Regions: [(x), (y)]
  let closure = { print(x); print(y) }
  // Regions: [(x, y, closure)]
}

 

 

Function arguments in the body of a function

 

arguments는 동일한 격리 영역에 속하고, f를 통해 x,z가 합쳐지면서 x,y,z가 모두 동일한 격리 도메인에 묶임.

 

 

 

func transfer(x: NonSendable, y: NonSendable) {
  // Regions: [(x, y)]
  let z = NonSendable()
  // Regions: [(x, y), (z)]
  f(x, z)
  // Regions: [(x, y, z)]
}

 

 

Control Flow

 

조건문이 존재하는 경우에는 각 블록에서는 Region을 규칙에 따라 병합하지만, 제어 흐름이 종료 되었을 때는 컴파일러는 보수적으로 판단하여 Region을 진단.

 

// Regions: [(x), (y)]
var x: NonSendable? = NonSendable()
var y: NonSendable? = NonSendable()
if ... {
  // Regions: [(x), (y)]
  x = y
  // Regions: [(x, y)]
} else {
  // Regions: [(x), (y)]
}

// Regions: [(x, y)]

 

 

Transferring Values and Isolation Regions

 

non-Sendable 값의 경우에는 특정 격리 도메인에 속하게 되는데, 하나의 격리 영역에서 격리 경계를 넘어 값을 전달(pass) 할 수 는 있지만 동시에 여러 격리 도메인에서 접근(access) 할 수 없음.

 

격리 영역 표기 격리 도메인 설명
Actor.field [{(field), actorInstance}] Actor Instance Actor 내부 상태, 해당 인스턴스에서만 접근 가능
method()
내부
ns
[(ns)] disconnected 메서드 로컬, 다른 격리 도메인과 독립
nonisolatedFunction
내부 ns
[(ns)] disconnected 함수 로컬, Actor/GlobalActor와 독립
globalVariable [{(globalVariable), @GlobalActor}] @GlobalActor 전역 액터에 격리, 다른 컨텍스트 접근 시 await 필요
taskIsolatedArgument
인자
x
[{(x), Task}] 호출한 Task 호출한 Task 내부에서만 접근 가능

 

actor Actor {
  // 'field' is in an isolation region that is isolated to the actor instance.
  var field: NonSendable

  func method() {
    // 'ns' is in a disconnected isolation region.
    let ns = NonSendable()
  }
}

func nonisolatedFunction() async {
  // 'ns' is in a disconnected isolation region.
  let ns = NonSendable()
}

// 'globalVariable' is in a region that is isolated to @GlobalActor.
@GlobalActor var globalVariable: NonSendable

// 'x' is isolated to the task that calls taskIsolatedArgument.
func taskIsolatedArgument(_ x: NonSendable) async { ... }



아래는 @MainActor로의 전송과 격리 영역 병합에 대해서도 설명 

 

@MainActor func transferToMainActor<T>(_ t: T) async { ... }

func assigningIsolationDomainsToIsolationRegions() async {
  // Regions: []

  let x = NonSendable()
  // Regions: [(x)]

  let y = x
  // Regions: [(x, y)]

  await transferToMainActor(x)
  // Regions: [{(x, y), @MainActor}]

  print(y) // Error!
}

 

x가 @MainActor로 전송되었을 때, transferToMainActor(x) 내부에서 잠재적으로 alias 할 수 있어서 하나의 격리 도메인으로 묶음.

이에 따라서 (x, y)가 @MainActor와 동일한 격리 도메인에 속하게 됨.

 

assigningIsolationDomainsToIsolationRegions() asnyc 메서드는 Task의 격리 도메인에 속하므로, print(y) 시점에서의 격리 도메인이 서로 일치하지 않아서 Error 발생

 

 

Weak Transfers, nonisolated functions, and disconnected isolation regions

 

Swift 언어에서 값의 소유권과 값에 접근할 수 있는 권한이 구분됨

값 v를 다른 격리 도메인 (actor, task, global actor 등)으로 전송하더라도, caller는 여전히 v에 대한 소유권(ownership)을 가짐.

하지만, region-based isolation에 의하여 접근(access)는 불가능.

 

(참고)

  • 소유권(Ownership): 값이 메모리에서 유지되는 책임. 스코프 종료 시 deinit 호출 가능
  • 접근 권한(Accessibility): 값이 현재 실행 컨텍스트에서 안전하게 읽고 쓸 수 있는 권한
초기 생성 [(x)], {(), self} x는 disconnected, self는 액터 영역
transferToMainActor 호출 후 [ {(x), @MainActor}, {(), self} ] x가 @MainActor로 이동
x 사용 시 @MainActor 외부 접근 오류 발생
스코프 종료 x lifetime 종료 deinit 호출

 

class NonSendable {
  deinit { print("deinit was called") }
}

@MainActor func transferToMainActor<T>(_ t: T) async {  }

actor MyActor {
  func example() async {
    // Regions: [{(), self}]
    let x = NonSendable()
    
    // Regions: [(x), {(), self}]
    await transferToMainActor(x)
    // Regions: [{(x), @MainActor}, {(), self}]

    // Error! Since 'x' was transferred to @MainActor, we cannot use 'x'
    // directly here.
    useValue(x)                                                      // (1)
    
    print("After nonisolated callee")

    // But since example still owns 'x', the lifetime of 'x' ends here. (2)
  }
}

let a = MyActor()
await a.example()

 


Weak Transfer Convention

  • 값을 다른 격리 도메인으로 전송했더라도, caller는 여전히 값의 메모리를 소유
  • 하지만 caller는 참조(reference)를 통해 접근할 수 없음

Strong Transfer Convention

  • 값을 전송하면 caller는 참조도 가질 수 없음
  • 즉, ownership까지 완전히 넘김 → callee가 region을 청소(clean up)

Weak Transfer와 Disconnected Region

  • Disconnected isolation region를 nonisolated async 함수에 전달
    • 값은 함수의 task-isolated domain으로 포함됨
    • 함수 실행이 끝나면, 값은 더 이상 isolation domain에 속하지 않음 → caller region으로 돌아옴
    • 따라서 함수 종료 후에도 값 사용 가능하고, 다시 다른 격리 도메인으로 전송 가능
  • 규칙 요약
    1. Task-isolated region에 있는 파라미터는 다른 persistent isolation domain으로 전송 불가
    2. nonisolated async 함수 호출 후에는 값이 caller region으로 돌아와서 다시 전송 가능

 

전체 흐름

Initial [(x)], {(), self}
After transferToMainActor(x) [{(x), @MainActor}, {(), self}]
During nonisolatedCallee(x) [{(x), task-isolated function}, {(), self}]
After nonisolatedCallee returns [(x)], {(), self}  // x can be reused

 

func nonIsolatedCallee(_ x: NonSendable) async { ... }
func useValue(_ x: NonSendable) { ... }
@MainActor func transferToMainActor<T>(_ t: T) { ... }

actor MyActor {
  var state: NonSendable

  func example() async {
    // Regions: [{(), self}]

    let x = NonSendable()
    // Regions: [(x), {(), self}]

    // While nonIsolatedCallee executes the regions are:
    // Regions: [{(x), Task}, {(), self}]
    await nonIsolatedCallee(x)
    // Once it has finished executing, 'x' is disconnected again
    // Regions: [(x), {(), self}]

    // 'x' can be used since it is disconnected again.
    useValue(x) // (1)

    // 'x' can be transferred since it is disconnected again.
    await transferToMainActor(x) // (2)

    // Error! After transferring to main actor, permanently
    // in main actor, so we can't use it.
    useValue(x) // (3)
  }
}

 

 

KeyPath

Actor에 격리되지 않은 non-Sendable 키패스는 disconnected로 간주.

해당 값의 격리 영역이 로컬에서 다시 사용되지 않는 한 격리 도메인으로 전송할 수 있음.

 

 

class Person {
  var name = "John Smith"
}

class Wrapper<Root: AnyObject> {
  var root: Root
  init(root: Root) { self.root = root }
  func setKeyPath<T>(_ keyPath: ReferenceWritableKeyPath<Root, T>, to value: T) {
    root[keyPath: keyPath] = value
  }
}

func useNonIsolatedKeyPath() async {
  let nonIsolated = Person()
  // Regions: [(nonIsolated)]
  let wrapper = Wrapper(root: nonIsolated)
  // Regions: [(nonIsolated, wrapper)]
  let keyPath = \Person.name
  // Regions: [(nonIsolated, wrapper, keyPath)]
  await transferToMain(keyPath) // Ok!
  await wrapper.setKeyPath(keyPath, to: "Jenny Smith") // Error!
}