[WWDC2022] Swift Concurrency를 통한 Data Race 방지하기(1) - Task isolation

Minseok, Kim·2022년 6월 14일
0

원문

[WWDC2022]Eliminate data races using Swift Concurrency

Task에 대하여

일단 설명을 위해서 concurrency한 환경을 '바다'로 비유하고,
Task를 '배'로 표현한다.

Task는 특정 작업을 시작부터 끝까지 수행하며 다음과 같은 특징을 지닌다.

  • 순차적 (Sequential)
  • 비동기적 (Asynchronous)
  • 독립적 (Self-contained)

이 Task라는 배는 독립적이며 자체 리소스를 가지고 있으므로 다른 배들과 독립적으로 작동한다.
따라서 서로의 독립적인 배들은 서로 아무런 영향을 주지 않기 때문에 이론적으로는 Data Race에서 안전하다.

하지만, 실제 작업 환경에서는 이 배들간의 커뮤니케이션이 이루어진다.
여기서는 이 커뮤니케이션을 배들끼리 물건을 주고 받는 것으로 비유한다.

예시

물건에는 아래와 같이 두가지 종류가 있음.

  • 파인애플(Structure)
  • 닭(Class)

각각의 코드는 다음과 같다.

// 파인애플 코드

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

Sendable에 대하여

우선 다음과 같은 판단 기준이 필요하다.

  • 파인애플은 배 사이 에서 공유하는 것이 안전하지만, 닭은 공유할 수 없음을 알 수 있는 방법
  • 닭이 실수 로 한 보트에서 다른 보트 로 전달되지 않도록 Swift 컴파일러에서 검사

위의 역할을 수행하는 것이 바로
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을 따를 수 없다.

Task간의 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 the Sendable protocol

Task가 어떻게 정의되어있는지 살며보면
아래와 같이 Task의 결과 값인 SuccessSendable을 채택고 있다.

@frozen struct Task<Success, Failure> where Success : Sendable, Failure : Error

따라서 서로 다른 격리 도메인(Isolated Domain)간에 전달될 Generic Parameter 또한 Sendable을 따라야한다.

Sendable 전파

Sendable은 조건부 적합성(conditional conformance)을 통해 CollectionGeneric 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]
}

우선 enumstruct 모두 Value Type이기 때문에 RipenessPinapple모두 Sendable을 채택할 수 있다.
그리고 Crate에서 Collection pinapplesSenable을 채택하는 Pineapple을 담고 있기 때문에 Sendable이다.

하지만 CoopSendable을 적용시키려하면 에러가 난다.

Error: stored property flock of Sendable - conforming struct Coop has non-sendable type [Chicken]

ChickenSendable타입을 적용하지 않고 있다.
따라서 Chicken을 담고 있는 Collection flockSendable이 아니며,
이로 인해서 Coop또한 Sendable을 채택할 수 없다.

Reference Type에 Sendable 적용하기

ClassSendable을 적용시키는 것이 불가능한 것은 아니다.
다만, 아래 조건을 만족하는 매우 좁은 상황에서만 Sendable로 만들 수 있다.

  • final class이어야함.
  • 변경 불가능한 저장소(immutable storage)만 포함해야함.
final class Chicken: Sendable {
    let name: String
    var currentHunger: HungerLevel}

위의 코드는 아래와 같은 에러를 만든다.

Error: class Chicken cannot conform to Sendable
Error: stored property currentHunger of Sendable - conforming class Chicken is mutable

currentHunger는 변경 가능하다(mutable).
따라서 ChickenSendable을 채택할 수 없다.

만약 ChickenSendable하게 만들고 싶다면,
현재 상태에서 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 생성시 Sendable 검사

Task를 생성하는 방법 중에는
기존의 Task내에서 완전히 새롭고 독립된 새로운 Taskclosure를 통해 생성하는 방법이 있다.

위에서 Task를 배로 비유한것으로 설명하자면
기존의 배에서 조그마한 카약을 띄우는 식이다.

이 경우에는 기존 Task(배)에서 값을 캡처 하여, 새 Task(카약)로 전달할 수 있는데
Data Race가 발생할 수 있으므로 Sendable 검사를 수행한다.

let lily = Chicken(name: "Lily")
Task.detached {
    lily.feed()
}

위의 코드는 sendable이 아닌 lily를 전달하므로 아래과 같은 에러를 발생시킨다.

Error: capture of lily with non-sendable type Chicken

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이 사용되어야한다.

다음 포스트 - Actor Isolation

Task를 격리하는 것은 Data Race를 방지하는 좋은 방법이지만,
다음과 같은 큰 문제가 있다.

공유된 변경 가능한 데이터(shared mutable data)가 없다면, Task들을 의미있게 사용하기 어렵다.

따라서 Data Race를 일으키지 않는 작업 간에 데이터를 공유할 수 있는 방법이 필요하다.
여기에 Actor Isolation이 등장한다.
(Actor Isolation 작성예정)

profile
iOS, Swift Dev

0개의 댓글