WWDC/Swift

[Swift] WWDC22 - Eliminate data races using Swift Concurrency (1)

유정주 2022. 9. 26. 13:20
반응형

서론

Swift Concurrency와 GCD를 비교하는 포스팅을 쓸 때 Swift Concurrency는 Data race에 안전하다고 했었습니다.

당시에는 정확히 어떻게 data race를 방지해주는지 알지 못했죠.

마침 WWDC22에 관련 내용이 있어 정리해보기로 했습니다.

 

Sendable (https://developer.apple.com/documentation/swift/sendable)에 대한 내용을 미리 읽어보고 아래 내용을 보시면 더 이해가 쉬우실 것 같네요.

물론 아래에서도 Sendable에 대한 내용이 나오니 나중에 읽으셔도 괜찮습니다 ㅎㅎ

 

추가로 Actor에 대한 것도 다루는데 전 Actor에 대한 사전 학습이 되어 있지 않아 이해하기 너무 어려웠습니다.

그래서 이번 포스팅에서 다루는 것을 포기하고 Actor 학습 후 2탄에서 다루기로 결정했습니다.

 

Eliminate data races using Swift Concurrency

이번 Eliminate data races using Swift Concurrency 영상에서는 바다와 보트, 무역품(파인애플, 닭)으로 내용을 설명하고 있습니다.

바다는 전체적인 동시성의 흐름으로 어떤 일이 발생할지 예상할 수 없으며 여러 일들이 동시에 일어나고 있음을 의미합니다.

보트는 동시성 위에서 돌아가는 작업을 의미하며 처음부터 끝까지 순차적으로 일을 수행합니다.

무역품은 보트 사이에서 공유될 수 있는 자원입니다.

 

이를 인지하시고 글을 따라와 주세요.

 

Task isolation

Task의 격리는 data race를 방지하는 핵심 아이디어 중 하나입니다.

Task는 처음부터 끝까지 순차적으로 일을 수행하고, 비동기적이며, "await" 작업 시 얼마든지 중단될 수 있습니다.

Task는 통신을 통해 자원을 공유하며 작업을 할 수 있습니다.

만약 각 Task들끼리 어떤 자원도 공유하지 않는다면 Data race에서는 안전하겠지만 그렇게 유용하지도 않겠죠.

 

그래서 Task가 자원을 공유하는 상황을 알아보겠습니다.

보트끼리 파인애플이라는 데이터를 공유하려고 합니다.

파인애플은 구조체로 구현되어 있습니다.

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 { … }
}

따라서 위에서 데이터를 공유한다는 것은 사실 아래 그림처럼

파인애플 복사본을 전달한다는 의미입니다.

보트에서 각자의 파인애플 데이터를 변형해도 다른 보트의 파인애플에는 아무런 영향을 주지 않죠.

이렇게 Swift는 데이터의 변형이 국소적인 영향만 미치기 때문에 value semantic를 선호했습니다. 

 

반대로, 클래스로 구현된 닭을 살펴보겠습니다.

final class Chicken {
  let name: String
  var currentHunger: HungerLevel
  
  func feed() { … }
  func play() { … }
  func produce() -> Egg { … }
}

닭은 클래스이기 때문에 보트끼리 공유를 해도 복사가 되는 것이 아니라,

하나의 닭을 참조하는 형태가 됩니다.

두 보트는 동시에 각자의 일을 하지만 둘 다 같은 닭 객체를 참조하고 있기 때문에 독립적이지 않습니다.

이렇게 공유된 데이터는 Data race에 영향을 받습니다.

예를 들어, 한 보트에서는 닭에게 먹이를 주고 다른 보트에서는 닭과 놀려고 하면

닭은 밥을 먹어야할지 놀아야할지 혼란스럽겠죠.

 

보트들은 파인애플 공유는 안전하지만 닭은 안전하지 않다는 것을 알아야 합니다.

또한, "우연히" 닭이 공유되는 것을 Swift 컴파일러가 방지해줘야 합니다.

 

Sendable types are safe to share

Swift 프로토콜은 타입을 분류하는 좋은 방법입니다.

프로토콜을 채택하는 것으로 어떤 동작을 하는지 추론할 수 있죠.

protocol Sendable { }

Sendable 프로토콜은 Data race를 생성하지 않고 서로 다른 격리 도메인 간에 안전하게 공유할 수 있는 타입이라는 것을 나타냅니다.

(공식 문서에서는 "A type whose values can safely be passed across concurrency domains by copying." 로 동시성 도메인으로 표현되고 있습니다.)

 

구조체인 파인애플은 Sendable 프로토콜을 채택할 수 있지만 동기화 되지 않은 클래스인 닭은 불가능합니다.

 

Sendable 프로토콜을 준수하면 격리 도메인 전반에서 데이터를 공유할 위치를 설명할 수 있습니다.

예를 들어 Task가 값을 반환할 때 이 값은 해당 값을 await 하는 모든 Task에 제공됩니다.

이 때 닭처럼 Sendable 하지 않은 값이 반환되려고 하면 컴파일 에러가 발생합니다.

Task 구조체에서 Success 제네릭 타입은 Sendable을 준수해야 한다고 지정되어 있기 때문입니다.

 

다시 보트 예시로 돌아가서, 보트가 데이터를 공유한다고 합시다.

모든 데이터가 공유하기 안전한지 지속적으로 점검해줄 무언가가 필요합니다.

이 역할을 Swift 컴파일러가 수행하고 Sendable 타입만 교환되도록 체크해줍니다.

Sendable한 파인애플은 공유를 허용하지만, 닭에서는 컴파일 에러를 발생시키죠.

 

Swift는 데이터가 Sendable한지 아주 꼼꼼히 체크합니다.

enum Ripeness: Sendable {
  case hard
  case perfect
  case mushy(daysPast: Int)
}

struct Pineapple: Sendable {
  var weight: Double
  var ripeness: Ripeness
}

enum과 struct 모두 Sendable을 채택해야 하고, 그들의 프로퍼티도 모두 Sendable 해야 합니다.

 

Sendable은 조건부 적합성(conditional conformance)을 통해 Collection과 Generic 타입으로도 사용될 수 있습니다.

//contains an array of Sendable types, therefore is Sendable
struct Crate: Sendable {
  var pineapples: [Pineapple]
}

원소가 Sendable하다면 Sendable 타입의 배열도 공유가 가능합니다.

 

반대로 Coop 구조체는 Sendable을 채택했지만 Chicken이 Sendable 하지 않기 때문에 공유가 불가능합니다.

 

Reference Type에 Sendable 적용

참조 타입이어도 Sendable을 적용할 수 있는 방법이 있지만,

이 방법은 매우 일부의 상황에서만 사용 가능합니다.

  1. final class이어야 함
  2. immutable 프로퍼티만 포함해야 함
final class Chicken: Sendable {
  let name: String
  var currentHunger: HungerLevel //'currentHunger' is mutable, therefore Chicken cannot be Sendable
}

Chicken 클래스는 Sendable 하지 못합니다.

currentHunger가 변경될 수 있는 프로퍼티이기 때문입니다.

 

이외에도 lock을 일관적으로 사용하여 내부 동기화를 통해 참조 타입을 포함시킬 수 있습니다.

ConcurrentCache는 내부에서 자체적으로 동기화를 하는 클래스인데요.

//@unchecked can be used, but be careful!
class ConcurrentCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
  var lock: NSLock
  var storage: [Key: Value]
}

@unchecked를 사용하여 컴파일러 검사를 비활성 했습니다.

개념적으로는 Sendable 하지만 Swift 컴파일러가 이에 대해 알 수 없기 때문에 사용이 가능합니다.

이 경우에는 Swift가 Data race 안전성을 보장하지 않기 때문에 매우 주의해야 합니다.

 

Sendable checking during task creation

Task를 생성하는 방법 중에는 기존 Task 내부에서 클로저를 통해 새로운 Task를 생성하는 방법이 있습니다.

보트에서 작은 카약을 띄우는 것처럼요.

이 경우에는 기존 Task(보트)에서 값을 캡처하여 새로운 Task(카약)로 전달할 수 있는데 이때 Data race가 발생할 수 있으므로 Sendable 검사를 수행합니다.

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

위 코드는 Sendable하지 않은 lily를 전달하기 때문에 에러가 발생합니다.

detached를 살펴보면 operation이 @Sendable로 표시되어 Sendable을 준수하고 있기 때문입니다.

 

일반적으로 function 타입은 프로토콜을 준수할 수 없지만,

Sendable은 해당 함수에 대한 의미 요구 사항을 검증하기 때문에 특별 케이스입니다.

 

즉, 위의 lily 코드는 아래처럼 수정하여 Sendable 클로저 유추하게끔 수정할 수 있습니다.

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

 

Sendable 결론

Sendable을 체크하여 Task Isolation을 유지할 수 있습니다.

Sendable은 각 Task들이 격리되어 있다는 것을 보장하며,

값들이 교환되어야 하는 모든 곳에서 Sendable을 사용하여 Data race를 방지합니다.

 

2탄에서 Actor에 대한 내용으로 이어집니다.


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

반응형