[WWDC2022]Eliminate data races using Swift Concurrency
일단 설명을 위해서 concurrency한 환경을 '바다'로 비유하고,
Task를 '배'로 표현한다.
Task는 특정 작업을 시작부터 끝까지 수행하며 다음과 같은 특징을 지닌다.
이 Task라는 배는 독립적이며 자체 리소스를 가지고 있으므로 다른 배들과 독립적으로 작동한다.
따라서 서로의 독립적인 배들은 서로 아무런 영향을 주지 않기 때문에 이론적으로는 Data Race에서 안전하다.
하지만, 실제 작업 환경에서는 이 배들간의 커뮤니케이션이 이루어진다.
여기서는 이 커뮤니케이션을 배들끼리 물건을 주고 받는 것으로 비유한다.
물건에는 아래와 같이 두가지 종류가 있음.
각각의 코드는 다음과 같다.
// 파인애플 코드
enum Ripeness {
case hard
case perfect
case mushy(daysPast: Int)
}
struct Pineapple {
var weight: Double
var ripeness: Ripeness
mutating func ripen() async {...}
mutating func slice() -> Int { ... }
}
// 닭 코드
final class Chicken {
let name: String
var currentHunger: HungerLevel
func feed() { ... }
func play() { ... }
func produce() -> Egg { ... }
}
자, 우선 한배에서 다른배로 파인애플을 전달한다고 가정한다.
여기서 파인애플은 Structure
이며 Value Type
이다.
복사본이라는 뜻이며
값만 같을 뿐 동일한 인스턴스를 참조하는 것은 아니다.
따라서 각각의 배에서 파인애플을 slice()
및 ripen()
하더라도 각각의 배에는 영향을 주지 않는다.
즉, value type은 data race에 상대적으로 안전하며
Swift가 Value Type을 선호해왔던 이유이다.
그렇다면 닭을 전달하는 경우에는 어떠할까?
닭은 Class
이며 Reference Type
이다.
두 배가 같은 인스턴스를 참조한다.
즉, 독립적이지 않다.
따라서 각각의 배에서 feed()
및 play()
를 수행할 경우
Data Race가 발생할 수 있다.
이를 해결하기 위해서는 무엇을 해야할까?
우선 다음과 같은 판단 기준이 필요하다.
위의 역할을 수행하는 것이 바로
Sendable
Protocol이다.
Sendable
(protocol)
복사를 통해서 동시성(concurrency) 도메인 간에 안전하게 값을 전달할 수 있는 타입.
A type whose values can safely be passed across concurrency domains by copying.
파인애플(structure)은 Value Type이기 때문에 Sendable
을 따르지만,
닭(class)는 동기화 되지않은(unsynchronized) Reference Type이기 때문에 Sendable
을 따를 수 없다.
Sendable
protocol을 사용하면 데이터가 격리 도메인(Isolation Domain)에서 공유되는 위치를 설명할 수 있다.
예를 들어 아래와 같은 코드가 있다.
let petAdoption = Task {
let chickens = await hatchNewFlock()
return chickens.randomElement()!
}
let pet = await petAdoption.value
Task
에서 Chicken
을 반환하려고 하지만, 치킨은 Sendable
하지 않기 때문에 안전하지 않다는 오류가 발생한다.
Error: type
Chicken
does not conform to theSendable
protocol
Task
가 어떻게 정의되어있는지 살며보면
아래와 같이 Task
의 결과 값인 Success
가 Sendable
을 채택고 있다.
@frozen struct Task<Success, Failure> where Success : Sendable, Failure : Error
따라서 서로 다른 격리 도메인(Isolated Domain)간에 전달될 Generic Parameter
또한 Sendable
을 따라야한다.
Sendable은 조건부 적합성(conditional conformance)을 통해 Collection
및 Generic Type
에 전파될 수 있다.
아래의 코드를 보자
✅ enum Ripeness: Sendable {
case hard
case perfect
case mushy(daysPast: Int)
}
✅ struct Pineapple: Sendable {
var weight: Double
var ripeness: Ripeness
}
✅ struct Crate: Sendable {
var pineapples: [Pineapple]
}
❌ struct Coop: Sendable {
var flock: [Chicken]
}
우선 enum
과 struct
모두 Value Type
이기 때문에 Ripeness
와 Pinapple
모두 Sendable
을 채택할 수 있다.
그리고 Crate
에서 Collection pinapples
은 Senable
을 채택하는 Pineapple
을 담고 있기 때문에 Sendable
이다.
하지만 Coop
에 Sendable
을 적용시키려하면 에러가 난다.
Error: stored property
flock
ofSendable
- conforming structCoop
has non-sendable type[Chicken]
Chicken
은 Sendable
타입을 적용하지 않고 있다.
따라서 Chicken
을 담고 있는 Collection flock
는 Sendable
이 아니며,
이로 인해서 Coop
또한 Sendable
을 채택할 수 없다.
Class
에 Sendable
을 적용시키는 것이 불가능한 것은 아니다.
다만, 아래 조건을 만족하는 매우 좁은 상황에서만 Sendable
로 만들 수 있다.
final class
이어야함.final class Chicken: Sendable {
let name: String
var currentHunger: HungerLevel ❌
}
위의 코드는 아래와 같은 에러를 만든다.
Error: class
Chicken
cannot conform toSendable
Error: stored propertycurrentHunger
ofSendable
- conforming classChicken
is mutable
currentHunger
는 변경 가능하다(mutable).
따라서 Chicken
은 Sendable
을 채택할 수 없다.
만약 Chicken
을 Sendable
하게 만들고 싶다면,
현재 상태에서 immutable한 name
만을 포함해야한다.
사실 lock
를 일관적으로 사용하는 방법과 같이
내부적인 동기화 작업을 수행하는 Reference Type
을 포함하는 방법이 있기는 하다.
class ConcurrentCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
var lock: NSLock
var storage: [Key: Value]
}
이러한 유형은 개념적으로는 Sendable
이지만 Swift가 이에 대해 알 수 있는 방법이 없다.
따라서 컴파일러의 검사를 비활성화 하기 위해서 @unchecked Sendable
을 사용한다.
다만, 이 경우에는 Swift가 Data Race Safety를 보장할 수 없기 때문에 매우 주의해야한다.
Task
를 생성하는 방법 중에는
기존의 Task
내에서 완전히 새롭고 독립된 새로운 Task
를 closure
를 통해 생성하는 방법이 있다.
위에서 Task
를 배로 비유한것으로 설명하자면
기존의 배에서 조그마한 카약을 띄우는 식이다.
이 경우에는 기존 Task
(배)에서 값을 캡처 하여, 새 Task
(카약)로 전달할 수 있는데
Data Race가 발생할 수 있으므로 Sendable
검사를 수행한다.
let lily = Chicken(name: "Lily")
Task.detached {
lily.feed()
}
위의 코드는 sendable
이 아닌 lily
를 전달하므로 아래과 같은 에러를 발생시킨다.
Error: capture of
lily
with non-sendable typeChicken
detached
함수는 아래와 같이 작성되어있다.
struct Task<Success: Sendable, Failure: Error> {
static func detached(
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async throws -> Success
) -> Task<Success, Failure>
}
operation: @Sendable
를 보면 알 수 있듯이
해당 함수 유형이 sendable
을 준수하고 있다는 것을 보여준다.
즉, 위에서 오류가 났던 코드는 아래와 같이 작성된 것과 같으며
이를 통해 Sendable closure
로 유추되는 것이다.
let lily = Chicken(name: "Lily")
Task.detached { @Sendable in
lily.feed()
}
Sendable
을 체크함으로써Task Isolation
을 유지할 수 있다.
Task
는 격리되어있고, 독립적으로 비동기(asynchronous) 작업들을 수행한다.Sendable
은 각 Task
들이 격리되어있다는 것을 보장한다.Sendable
이 사용되어야한다.Task
를 격리하는 것은 Data Race를 방지하는 좋은 방법이지만,
다음과 같은 큰 문제가 있다.
공유된 변경 가능한 데이터(shared mutable data)가 없다면,
Task
들을 의미있게 사용하기 어렵다.
따라서 Data Race를 일으키지 않는 작업 간에 데이터를 공유할 수 있는 방법이 필요하다.
여기에 Actor Isolation이 등장한다.
(Actor Isolation 작성예정)