-
Notifications
You must be signed in to change notification settings - Fork 4
Swift 6의 데이터 격리 이해하기
Swift의 동시성 시스템은 Swift 5.5에서 등장했으며, 비동기와 병렬 코드를 쉽게 작성하고 이해할 수 있도록 했습니다. 이제 Swift 6 언어모드에서는 컴파일러 수준에서 동시성 프로그램이 데이터 경쟁 문제가 발생하지 않도록 보장합니다.
Swift 6로 안전히 마이그레이션을 수행하려 합니다.
전통적으로, 가변 상태는 실행 시간 동기화를 수동으로 보호해주었습니다. 락(lock)이나 큐와 같은 도구를 통해서 데이터 경쟁을 막는 것은 전적으로 프로그래머의 책임이었습니다.
옳바르게 작성하는 것도 악명높게 힘든데, 확장하며 이를 유지하기까지 해야 했습니다.
최악은 안전하지 않은 코드가 실행시간에 실패하는 것이 보장되지 않아, 찾아내기 힘든 것도 있었습니다.
데이터 경쟁은 어떤 스레드가 변형중인 메모리에 다른 스레드가 접근하는 상황에서 발생합니다. Swift 6는 다음과 같은 상황을 컴파일 시간에 예방합니다.
Swift의 동시성 시스템은 컴파일러가 모든 가변 상태의 안전성을 이해하고 검증하도록 합니다. 이것이 가능한 이유는 데이터 격리 기법을 사용하기 때문입니다. **데이터의 격리는 가변 상태에 대해 상호 배타적 접근을 보장합니다.**이것은 동기화 기법이며, 락(lock)과 비슷하지만 보호가 컴파일 시간에 제공됩니다.
사용자는 2가지 방법으로 데이터 격리를 처리합니다.
- 정적으로(statically)
- 동적으로(dynamically)
정적이라는 단어는 프로그램 요소에서 실행시간 상태에 영향을 주지 않는 것을 묘사할 때 사용합니다. 이러한 요소들(함수의 정의와 같은)은 키워드와 어노테이션으로 만들어집니다.
Swift의 동시성은 타입 시스템의 확장입니다. 함수와 타입을 선언할때, 정적으로 합니다. 그렇기에 격리는 정적 선언의 일부분이 될 수 있습니다.
하지만 타입 시스템만으로는 실행시간 행동을 충분히 설명하지 못할 경우도 있습니다. Swift에 Objective-C가 노출된 경우가 예시가 됩니다. Swift 코드 밖의 선언문은 컴파일러가 안전을 보장하기에 정보가 부족할 수 있습니다. 이러한 상황에 대응하기 위해서, 동적으로 격리 요구사항을 표현할 수 있는 부가적인 기능들이 있습니다.
정적 혹은 동적인 데이터 격리는 Swift 코드가 데이터 경쟁에서 안전함을 컴파일러가 보장합니다.
데이터 격리는 공유되는 가변 상태를 보호하는 기법입니다.
격리 도메인의 중요한 특징은 안전성입니다. 가변 상태는 한 번에 하나의 격리 도메인에서만 액세스할 수 있습니다. 가변 상태를 한 격리 도메인에서 다른 격리 도메인으로 전달할 수 있지만, 다른 도메인에서 동시에 해당 상태에 액세스할 수는 없습니다. 이 보장은 컴파일러에서 검증합니다.
직접 명시적으로 정의하지 않았더라도 모든 함수 및 변수 선언에는 잘 정의된 정적 격리 도메인이 있습니다. 이러한 도메인은 항상 다음 세 가지 범주 중 하나에 속합니다.
- 비격리(Non-isolated)
- 액터 값에 격리(Isolated to an actor value)
- 글로벌 액터에 격리(Isolated to a global actor)
함수와 변수는 명시적 격리 도메인의 일부가 될 필요가 없습니다. 사실, 격리가 부족한 것이 기본이며, 이를 비격리라고 합니다. 비격리 코드는 다른 도메인에서 보호되는 상태를 변형할 방법이 없습니다.
func sailTheSea() {}
이 최상위 함수는 정적 격리가 없으므로 격리되지 않습니다. 다른 격리되지 않은 함수를 안전하게 호출하고 격리되지 않은 변수에 액세스할 수 있지만 다른 격리 도메인의 어떤 것에도 액세스할 수 없습니다.
class Chicken {
let name: String
var currentHunger: HungerLevel
}
위는 비격리 타입의 예입니다.
데이터 격리는 비격리형 엔터티가 다른 도메인의 변경 가능한 상태에 액세스할 수 없음을 보장합니다. 그 결과, 비격리형 함수와 변수는 다른 도메인에서 항상 안전하게 액세스할 수 있습니다.
행위자는 격리 도메인과 해당 도메인 내에서 작동하는 메서드를 정의하는 방법입니다. 행위자의 모든 저장된 속성은 해당 행위자 인스턴스에 격리됩니다.
actor Island {
var flock: [Chicken]
var food: [Pineapple]
func addToFlock() {
flock.append(Chicken())
}
}
여기에서 모든 Island
인스턴스는 속성에 대한 접근을 보호하는 데 사용될 새 도메인을 정의합니다.
Island.addToFlock
메소드는 self
에 격리됩니다. addToFlock
메소드는 격리 도메인을 공유하는 모든 데이터에 액세스할 수 있으므로 flock
속성에 동기적으로 액세스할 수 있습니다.
행위자 격리는 선택적으로 비활성화할 수 있습니다. 격리되지 않은 메서드는 보호된 상태에 동기적으로 접근할 수 없습니다.
canGrow
메소드는 비격리로, flock
과 food
속성(격리된 상태)에 접근할 수 없습니다.
actor Island {
var flock: [Chicken]
var food: [Pineapple]
nonisolated func canGrow() -> PlantSpecies {
// neither flock nor food are accessible here
}
}
행위자의 격리 도메인은 자체 메서드에 국한되지 않습니다. 격리된 매개변수를 허용하는 함수는 다른 형태의 동기화가 필요 없이 액터 격리 상태에 액세스할 수도 있습니다.
func addToFlock(of island: isolated Island) {
island.flock.append(Chicken())
}
전역 행위자는 일반 행위자의 모든 속성을 공유하지만, 선언을 격리 도메인에 정적으로 할당하는 수단도 제공합니다. 이는 행위자 이름과 일치하는 어노테이션으로 수행됩니다.
글로벌 행위자는 모든 타입의 그룹이 공유된 가변 상태의 단일 풀로 상호 운용되어야 할 때 특히 유용합니다.
메인 행위자가 대표적인 예시입니다.
@MainActor
class ChickenValley {
var flock: [Chicken]
var food: [Pineapple]
}
작업은 프로그램 내에서 동시에 실행할 수 있는 단위입니다. Swift에서 Task 블록 외부에서 동시 코드를 실행할 수는 없지만, 항상 수동으로 시작해야 한다는 의미는 아닙니다. 일반적으로 비동기 함수는 실행 중인 작업을 알 필요가 없습니다. 사실, 작업은 종종 애플리케이션 프레임워크 내에서 훨씬 더 높은 수준에서 또는 프로그램의 진입점에서 시작될 수 있습니다.
작업은 서로 동시에 실행될 수 있지만 각 개별 작업은 한 번에 하나의 함수만 실행합니다. 처음부터 끝까지 순서대로 코드를 실행합니다.
Task {
flock.map(Chicken.produce)
}
작업은 항상 격리 도메인을 갖습니다. 작업은 행위자 인스턴스, 전역 행위자에 격리되거나 격리되지 않을 수 있습니다. 이 격리는 수동으로 설정할 수 있지만 컨텍스트에 따라 자동으로 상속될 수도 있습니다. 다른 모든 Swift 코드와 마찬가지로 작업 격리는 액세스 가능한 가변 상태를 결정합니다.
작업은 동기 및 비동기 코드를 모두 실행할 수 있습니다. 작업 수에 관계없이 동일한 격리 도메인의 함수는 서로 동시에 실행할 수 없습니다. 주어진 격리 도메인에 대해 동기 코드를 실행하는 작업은 항상 하나뿐입니다.
격리를 명시적으로 지정하는 방법은 여러 가지가 있습니다. 하지만 선언의 맥락이 격리 추론을 통해 암묵적으로 격리를 확립하는 경우가 있습니다.
하위 클래스는 항상 부모 클래스와 동일한 격리성을 갖습니다.
@MainActor
class Animal {}
class Chicken: Animal {}
Chicken
은 Animal
에서 상속하기 때문에 Animal
타입의 정적 격리도 암묵적으로 적용됩니다.
그뿐만 아니라 하위 클래스에서 변경할 수도 없습니다.
모든 Animal
인스턴스는 메인 행위자-격리로 선언되었으며, 이는 모든 Chicken
인스턴스도 그래야 함을 의미합니다.
기본적으로 타입의 정적 격리는 해당 속성과 메소드에도 적용됩니다.
@MainActor
class Animal {
// all declarations within this type are also
// implicitly MainActor-isolated
let name: String
func eat(food: Pineapple) {}
}
프로토콜 채택은 암묵적으로 격리에 영향을 미칠 수 있습니다.
프로토콜 채택을 어떻게 하는지에 따라 격리의 영향이 달라질 수 있습니다.
@MainActor
protocol Feedable {
func eat(food: Pineapple)
}
// inferred isolation applies to the entire type
class Chicken: Feedable {}
// inferred isolation only applies within the extension
extension Pirate: Feedable {}
프로토콜의 요구 사항 자체도 격리될 수 있습니다. 이를 통해 적합한 타입에 대한 격리가 추론되는 방식을 보다 세부적으로 제어할 수 있습니다.
protocol Feedable {
@MainActor
func eat(food: Pineapple)
}
프로토콜이 어떻게 정의되고 채택하든 다른 정적 격리 기법은 변경할 수 없습니다.
타입이 명시적으로 또는 슈퍼클래스에서 추론을 통해 전역적으로 격리된 경우 프로토콜 채택을 통해 변경할 수 없습니다.
격리 추론을 통해 타입은 속성과 메서드의 격리를 암묵적으로 정의할 수 있습니다. 하지만 이는 모두 선언의 예입니다.
격리 상속을 통해 함수 값으로도 비슷한 효과를 얻을 수 있습니다.
기본적으로 클로저는 형성된 동일한 컨텍스트에 격리됩니다.
예를 들어:
@MainActor
class Model { ... }
@MainActor
class C {
var models: [Model] = []
func mapModels<Value>(
_ keyPath: KeyPath<Model, Value>
) -> some Collection<Value> {
models.lazy.map { $0[keyPath: keyPath] }
}
}
위 코드에서 LazySequence.map
에 대한 클로저는 @escaping (Base.Element) -> U
형식을 갖습니다. 이 클로저는 원래 형성된 메인 행위자에 머물러야 합니다. 이렇게 하면 클로저가 상태를 캡처하거나 주변 컨텍스트에서 격리된 메서드를 호출할 수 있습니다.
기존 컨텍스트와 동시에 실행될 수 있는 클로저는 @Sendable
이나 sending
어노테이션이 명시적으로 표시됩니다.
동시에 실행될 수 있는 비동기 클로저의 경우, 여전히 기존 컨텍스트의 격리를 캡처할 수 있습니다.
이 메커니즘은 Task 이니셜라이저에서 사용되어 주어진 작업이 기본적으로 원래 컨텍스트에 격리될 수 있습니다.
그리고 명시적 격리를 지정할 수도 있습니다.
@MainActor
func eat(food: Pineapple) {
// the static isolation of this function's declaration is
// captured by the closure created here
Task {
// allowing the closure's body to inherit MainActor-isolation
Chicken.prizedHen.eat(food: food)
}
Task { @MyGlobalActor in
// this task is isolated to `MyGlobalActor`
}
}
여기서 클로저의 타입은 Task.init
에 의해 정의됩니다. 해당 선언이 어떤 행위자에도 격리되지 않았음에도 불구하고, 이 새로 생성된 작업은 명시적인 전역 행위자가 명시되지 않는 한, 둘러싼 범위의 MainActor
격리를 상속합니다.
함수 타입은 격리 동작을 제어하기 위한 여러 메커니즘을 제공하지만 기본적으로 다른 타입과 동일하게 동작합니다.
격리 도메인은 가변 상태를 보호하지만, 유용한 프로그램은 보호 이상의 것이 필요합니다. 이들은 종종 데이터를 주고받으며 통신하고 조정해야 합니다.
격리 도메인으로 값을 이동하거나 격리 도메인에서 값을 이동하는 것을 격리 경계를 넘는 것이라 합니다.
값은 공유 가변 상태에 대한 동시 액세스 가능성이 없는 경우에만 격리 경계를 넘을 수 있습니다.
값은 비동기 함수 호출을 통해 경계를 직접 넘을 수 있습니다. 다른 격리 도메인으로 비동기 함수를 호출하는 경우 매개변수와 반환 값은 해당 도메인으로 이동해야 합니다.
값은 클로저에 의해 캡처될 때 간접적으로 경계를 넘을 수도 있습니다. 클로저는 동시 액세스에 대한 많은 잠재적 기회를 제공합니다. 한 도메인에서 생성한 다음 다른 도메인에서 실행되거나, 심지어 여러 개의 다른 도메인에서 실행할 수도 있습니다.
어떤 경우에는 특정 타입의 모든 값이 스레드 안전해서 격리 경계를 무사히 통과할 수 있습니다. 이는 Sendable
프로토콜로 표현됩니다. Sendable
에 대한 준수는 주어진 타입이 스레드 안전함을 의미하며, 데이터 경쟁의 위험을 초래하지 않고도 임의의 격리 도메인에서 타입의 값을 공유할 수 있습니다.
Swift에서, 값 타입이 본질적으로 안전하기 때문에 값 타입을 사용하도록 권장합니다. 값 타입을 사용하면 프로그램의 다른 부분에서 동일한 값에 대한 공유 참조를 가질 수 없습니다. 값 타입에서 저장된 모든 속성이 Sendable
인 경우 암묵적으로 Sendable
입니다.
enum Ripeness { // implicitly Sendable
case hard
case perfect
case mushy(daysPast: Int)
}
struct Pineapple { // implicitly Sendable too.
var weight: Double
var ripeness: Ripeness
}
Sendable
프로토콜은 전체 타입에 대한 스레드 안전성을 표현하는 데 사용됩니다. 그러나 Sendable
이 아닌 타입의 특정 인스턴스가 안전한 방식으로 사용되는 상황이 있습니다.
컴파일러는 종종 지역 기반 격리라고 알려진 흐름에 민감한 분석을 통해 이러한 안전성을 추론할 수 있습니다.
지역 기반 격리를 통해 컴파일러는 Sendable
이 아닌 유형의 인스턴스가 격리 도메인을 교차하도록 허용할 수 있으며, 그렇게 하면 데이터 경쟁이 발생하지 않는다는 것을 증명할 수 있습니다.
func populate(island: Island) async {
let chicken = Chicken() // class, non-Sendable
await island.adopt(chicken) // island -> actor
}
여기서 컴파일러는 chicken
이 Sendable
이 아닌 유형을 가지고 있음에도 불구하고 island
격리 도메인으로 교차하는 것이 안전하다는 것을 올바르게 추론할 수 있습니다. 그러나 Sendable
검사에 대한 이 예외는 본질적으로 주변 코드에 달려 있습니다. chicken
변수에 대한 안전하지 않은 액세스가 도입되면 컴파일러는 여전히 오류를 생성합니다.
func populate(island: Island) async {
let chicken = Chicken()
await island.adopt(chicken)
// this would result in an error, island 이외의 다른 격리 도메인에서도 접근
chicken.eat(food: Pineapple())
}
함수의 매개변수와 반환 값은 이 메커니즘을 사용하여 교차 도메인을 지원한다고 명시적으로 명시할 수도 있습니다.
func populate(island: Island, with chicken: **sending** Chicken) async {
await island.adopt(chicken)
}
컴파일러는 이제 모든 호출 장소에서 chicken
매개변수가 안전하지 않은 액세스의 대상이 되지 않을 것이라는 보장을 제공할 수 있습니다.
sending
이 없다면 Chicken
이 Sendable
을 준수하도록 요구함으로써만 구현할 수 있습니다.
행위자는 값 타입이 아니지만, 자체 격리 도메인에서 모든 상태를 보호하므로 경계를 넘어 전달하는 것이 본질적으로 안전합니다. 즉, 모든 액터 타입은 암묵적으로 Sendable
입니다.
actor Island { // -> Sendable
var flock: [Chicken] // non-Sendable
var food: [Pineapple] // Sendable
}
전역 행위자 격리 타입도 비슷한 이유로 암묵적으로 Sendable
입니다. 이들은 비공개 전용 격리 도메인이 없지만, 상태는 여전히 actor
에 의해 보호됩니다.
@MainActor
class ChickenValley { // -> Sendable
var flock: [Chicken] // non-Sendable
var food: [Pineapple] // Sendable
}
값 타입과 달리 참조 타입은 암묵적으로 Sendable
이 될 수 없습니다. Sendable
로 만들 수는 있지만 그렇게 하려면 여러 가지 제약이 따릅니다. 클래스를 Sendable
로 만들려면 변경 가능한 상태를 포함하지 않아야 하며 모든 변경 불가능한 속성도 Sendable
이어야 합니다. 또한 컴파일러는 final
클래스의 구현만 검증할 수 있습니다(NSObject
만 슈퍼클래스로 가질 수 있습니다).
final class Chicken: Sendable {
let name: String
}
컴파일러가 추론할 수 없는 동기화 기본 요소를 사용하여 Sendable
의 스레드 안전성 요구 사항을 충족할 수 있습니다. 예를 들어 OS별 구문을 통하거나 C/C++/Objective-C
에서 구현된 스레드 안전 타입으로 작업할 때입니다.
이러한 타입은 @unchecked Sendable
을 준수하는 것으로 표시되어 컴파일러에게 해당 유형이 스레드 안전하다는 것을 약속할 수 있습니다. 컴파일러는 @unchecked Sendable
유형에 대한 검사를 수행하지 않으므로 신중하게 사용해야 합니다.
한 도메인의 함수가 다른 도메인의 함수를 호출할 때 작업은 격리 도메인 간에 전환할 수 있습니다. 격리 경계를 넘는 호출은 비동기적으로 이루어져야 합니다. 대상 격리 도메인이 다른 작업을 실행 중일 수 있기 때문입니다.
이 경우 대상 격리 도메인을 사용할 수 있을 때까지 작업이 일시 중단됩니다. 중요한 점은 일시 중단 지점이 차단(block)되지 않는다는 것입니다. 현재 격리 도메인(및 실행 중인 스레드)은 다른 작업을 수행할 수 있도록 해제됩니다.
Swift 동시성 런타임은 코드가 향후 작업에서 차단되지 않기를 기대하므로 시스템은 항상 앞으로 진행할 수 있습니다. 이를 통해 동시 코드에서 교착 상태가 발생하는 일반적인 원인을 제거합니다.
@MainActor
func stockUp() {
// beginning execution on MainActor
let food = Pineapple()
// switching to the island actor's domain, non-block
await island.store(food)
}
잠재적인 중단 지점은 소스 코드에서 await
키워드로 표시됩니다. 이 키워드가 있으면 호출이 런타임에 중단될 수 있지만 await
는 중단을 강제하지 않습니다. 호출되는 함수는 특정 동적 조건에서만 중단될 수 있습니다. await
로 표시된 호출이 실제로 중단되지 않을 수도 있습니다.
행위자는 데이터 경쟁으로부터 안전을 보장하지만, 중단 지점에서 원자성을 보장하지는 않습니다. 동시 코드는 종종 원자 단위로 일련의 작업을 함께 실행해야 하므로 다른 스레드는 중간 상태를 볼 수 없습니다. 이 속성이 필요한 코드 단위를 임계 섹션이라고 합니다.
현재 격리 도메인이 다른 작업을 수행할 수 있도록 해제되므로 비동기 호출 후 행위자 격리 상태가 변경될 수 있습니다. 결과적으로 잠재적인 중단 지점을 명시적으로 표시하는 것을 임계 섹션의 끝을 나타내는 방법으로 생각할 수 있습니다.
func deposit(pineapples: [Pineapple], onto island: Island) async {
var food = await island.food
food += pineapples
await island.store(food)
}
이 코드는 섬 행위자의 식량 값이 비동기 호출 사이에 변경되지 않는다고 잘못 가정합니다. 중요 섹션은 항상 동기적으로 실행되도록 구성되어야 합니다.
food
의 상태가 중단 지점 이후로 변할 수 있습니다. 따라서 다음 3줄의 코드는 동기적으로 실행되게 구조를 변경해야 합니다.