본문 바로가기
iOS/Swift

Eliminate data races using Swift Concurrency

by 바등쪼 2023. 11. 23.

https://developer.apple.com/wwdc22/110351

 

Eliminate data races using Swift Concurrency - WWDC22 - Videos - Apple Developer

Join us as we explore one of the core concepts in Swift concurrency: isolation of tasks and actors. We'll take you through Swift's...

developer.apple.com

 

 

이전 Swift Concurrency WWDC 정리에 이어 오늘은 Swift Concurrecy를 통해 Data race를 제거하는 방법을 전체적인 관점에서 정리해 보겠습니다.

 

이번 WWDC에서는 동시성의 세계를 동시성의 바다로 비유를 들어서 설명하고 있습니다.

 

 

목차

Task isolation
Actor isolation
Atomicity
Ordering

 

 

Task isolation

  • 동시성의 바다에서 Task는 보트로 표현된다.
  • 보트는 주요 일꾼으로 해야 할 일이 있다.
  • 보트는 처음부터 끝까지 순차적으로 일을 수행한다.
  • 이들은 비동기적이고 코드에서 await 작업 시 여러번 중단될 수 있다.
  • 보트들이 완전히 독립되어 있다면 Data race가 발생하지 않는 동시성이 가능하겠지만 서로 통신하지 못하고 완전히 독립되는 것은 유용하지 않다.

보트(Task)간 커뮤니케이션 예시를 살펴보자

 

한 보트가 다른 보트와 파인애플을 공유하려고 한다.

그래서 보트들이 바다에서 만나고 파인애플을 한 보트에서 다른 보트로 옮긴다.

여기서 물리적인 비유가 어긋나는데 왜냐하면 파인애플은 사실 물리적 물건이 아니라 데이터이기 때문이다. (물체 X, 데이터 O)

 

Swift에서는 데이터를 나타내는 몇 가지 다양한 방법이 있다.

 

 

Swift에서는 우선 Value Type을 지향하기 때문에 Struct와 enum을 사용해 파인애플을 구현했다.

  • 이러면 다른 보트는 파인애플의 복사본을 가지게 되고 각 보트는 각자의 복사본을 챙겨서 떠난다.
  • 파인애플의 ripen, slice 메서드를 호출하여 복사본을 변경하여도 다른 복사본에는 영향을 미치지 않는다.

➡️ 이러한 이유로 Swift에서는 Value Type을 선호한다.

(Mutation이 오직 local effects만 발생시키기 때문)

 

이 원리는 Value type이 isolation을 유지하도록 돕니다.

 

 

이제 파인애플 말고 닭(치킨) 객체를 추가해 보자

위 코드처럼 닭을 class로 모델링하면 각 인스턴스는 Unique identity를 가진다. (Reference Type)

  • 두 보트가 만나 닭을 공유하면 참조 타입이기 때문에 복사본이 아닌 참조를 공유하게 된다.
  • 두 보트가 헤어지고 각자 자신의 일을 수행하게 되면 둘 다 같은 닭 객체를 참조하기 때문에 독립적이지 않게 된다.
  • 즉, Data Race가 발생할 수 있다.
    • 한 보트가 feed 메서드를 호출할 때 동시에 다른 보트에서 play 메서드를 호출하면 문제가 생긴다.

 

이처럼 Reference Type을 공유할 때 안전하지 않다는 것을 알 수 있는 방법이 필요하다.

이를 위해 Swift에서 제공하는 프로토콜이 Sendable이다.

 

Sendable

 

Sendable 프로토콜은 서로 다른 isolation 도메인 간에 안전하게 공유할 수 있는 타입을 설명하는 데 사용된다.

 

Sendable을 채택하면 되는데 struct는 값 타입이기 때문에 채택이 가능하지만 class는 unsynchronized인 참조 타입이기 때문에 Sendable 채택이 불가능하다.

 

 

Sendable 프로토콜로 모델링하면 isolation 도메인 전반에서 데이터를 공유할 위치를 설명할 수 있다.

예를 들어 위처럼 코드를 작성하여 Task가 chicken을 리턴하도록 하면 컴파일러가 Sendable 하지 않은 객체를 반환하지 않는다고 에러를 발생시킨다.

 

 

Task의 선언부를 보면 Task가 리턴하는 Success 타입이 Sendable을 충족해야 한다고 명시되어 있기 때문이다.

 

 

정리하면 동시성의 바다에서 두 보트가 만나 데이터를 공유하려고 할 때 Swift Compiler는 모든 물품이 공유하기에 안전한지 지속적으로 점검하여 Sendable 하지 않은 객체인 닭을 공유하려고 하면 에러를 발생시키는 것이다.

 

 

  • enum과 struct는 모두 Value Type이기 때문에 모든 인스턴스 데이터가 Sendable하기만 하면 그 자체도 Sendable 하다.
  • Sendable 한 객체들을 담는 배열도 Sendable 하기 때문에 위 코드에서 Crate 구조체 또한 Sendable 하다.
  • 반면 Chicken은 Sendable 하지 않은 class이기 때문에 Coop 또한 Sendable 하지 않다.

 

 

  • class는 참조타입이기 때문에 매우 드문 상황에서만 Sendable하다.
    • 예를 들어 final class가 오직 immutable storage만 가진 경우는 Sendable하다.
  • lock을 사용하여 자체적으로 내부 Synchronize 매커니즘을 수행하는 Reference Type을 만들 수 있다. (위에서 ConcurrentCache 클래스에 해당)
  • @unchecked Sendable을 사용해 컴파일러의 검사를 비활성화 할 수 있다.
  • 이러한 타입은 개념적으로 Sendable하지만 Swift가 이에 대해 추론할 방법이 없다.
  • 단 @unchecked Sendable을 통해 가변 상태를 밀반입하면 Swift가 제공하는 Data Race 안전 보장이 손상되기 때문에 주의해야 한다!

 

 

만약 이 코드처럼 Task의 클로저가 값을 캡처하게 된다면 이 또한 Swift 컴파일러가 Sendable 한지 검사하여 에러를 발생시킨다.

  • 클로저는 항상 Sendable 클로저로 추정되며 이는 At-Sendable로 명시적으로 작성됐을 수 있다.
  • Sendable 클로저는 Sendable function type의 value이다.

 

  • At-Sendable은 함수 타입에 쓰여 함수 타입이 Sendable 프로토콜을 준수하는지 나타낼 수 있다.
  • 즉, 캡처된 상태에서 Data race를 발생하지 않고도 해당 함수 타입의 값을 다른 격리 도메인으로 전달하여 거기서 호출할 수 있다.
  • 일반적으로 함수 타입은 프로토콜을 준수할 수 없지만 Sendable은 컴파일러가 해당 함수에 대한 Semantic 요구 사항을 검증하기 때문에 특별하다.

 

정리

 

  • Task는 격리되어 있으며 비동기 작업을 독립적으로 수행한다.
  • Sendable은 Task 간에 안전하게 공유할 수 있는 타입을 설명한다.
  • 컴파일러는 Task 격리를 유지하기 위해 Sendable 적합성을 모든 수준에서 확인한다.

 

그러나 공유 가변 데이터에 대한 개념이 없으면 Task를 의미 있는 방식으로 조율하기 어렵다.

따라서 Data race를 발생하지 않으면서 Task 간 데이터를 공유할 새로운 방법이 필요했다.

여기서 등장하는 것이 Actor이다.

 

 

Actor isolation

Actor는 서로 다른 Task를 통해 접근할 수 있는 상태를 격리할 방법을 제공한다.

  • Actor는 동시성의 바다에 있는 섬이다.
  • 보트처럼 각 섬은 독립적이며 바다의 모든 것들로부터 격리되어 그들만의 상태를 가진다.
  • 이 상태에 액세스하려면 코드가 섬에서 실행 중이어야 한다.
  • 에를 들어 advanceTime 메서드는 이 섬으로 격리되며 섬의 모든 상태에 점근할 수 있다.
  • 섬에서 실제로 코드를 실행하려면 보트가 필요하다.
  • 보트는 섬에서 코드를 실행하도록 섬을 방문할 수 있으며 이때 보트는 해당 상태에 액세스할 수 있다.

 

  • 한 번에 하나의 보트만 동일한 섬을 방문해 코드를 실행할 수 있다.
  • 즉, 섬 상태에 동시에 액세스하는 것을 방지한다.
  • 만약 다른 보트가 나타난다면 차신의 차례를 기다려야 한다.
  • 이것은 섬을 방문할 기회를 얻기까지 오랜 시간이 걸릴 수 있음을 의미하므로 await 키워드로 표시된 잠재적인 suspend 지점이다.
  • 섬이 suspend 포인트에서 해방되면 다른 보트가 방문할 수 있다.

  • 바다에서 만난 두 보트처럼 보트와 섬 사이의 사옿 작용은 Sendable 하지 않은 타입이 둘 사이를 통과하지 못하도록 막아 서로의 격리를 유지한다.
  • 위 코드처럼 참조 타입인 Chicken을 섬에 추가하거나 반대로 섬에서 닭을 가져오려고 하면 컴파일 에러가 발생한다.
  • 이를 통해 Data Race를 방지할 수 있다.

 

 

  • Actor는 Reference Type이지만 class와 달리 모든 속성과 코드를 격리하여 동시 액세스를 방지한다. (자체적인 동기화 매커니즘 존재)
  • 따라서 다른 격리 도메인의 Actor를 참조하는 것이 안전하다.
  • 모든 Actor는 Sendable하다.

 

  • 어떤 코드가 Actor에게 격리되어 있고 어떤 코드가 격리되어 있지 않은지는 맥락(context)에 따라 결정된다.
  • 인스턴스 속성인 flock, food는 actor-isolated이다.
  • 인스턴스 메서드인 advanceTime 또한 actor-isolated이다.
  • reduce에 전달된 클로저와 같이 non-sendable 클로저는 actor에 머무르며 actor-isolated 맥락에 있을 때 같이 actor-isolated된다.
  • Task 또한 맥락에서 actor isolation을 상속하므로 생성된 task가 처음 시작된 actor와 동일한 actor에 대해 예약된다.
  • Detached Task는 맥락에서 actor isolation을 상속하지 않는다. 맥락과 완전히 독립적이기 때문이다.
    • 그래서 Task.detached에서 food에 접근할 때 await를 사용한 것을 확인할 수 있다. (actor 외부에서 접근한 것으로 취급)
    • 이러한 클로저를 위한 용어가 non-isolated code이다.

 

Non-isolated code

  • non-isolated 코드는 어떤 actor에게도 실행되지 않은 코드이다.
  • 앞서 Detached Task에서 암시적으로 발생한 상황과 같은 원리로 actor로부터 격리되지 않고 외부의 코드처럼 인식된다.
  • 따라서 actor의 속성인 flock에 접근할 때 await를 붙여야한다.
  • non-isolated async 코드는 항상 global cooperative pool에서 실행된다.
  • 또한 Sendable 하지 않은 타입을 가지고 있는지 컴파일러가 체크한다.
  • 여기서는 class인 Chicken 인스턴스가 섬을 떠나려고 하는 잠재적인 Data race를 탐지하여 에러를 발생시키고 있다.

 

Non-isolated 코드의 예제를 한 개 더 살펴보자

  • greet 함수는 non-isolated이며 동기적인 코드이다.
  • 이 함수를 actor-isolated된 동기 함수인 greetOne에서 호출할 때는 아무 문제가 없다.
    • 왜냐하면 greet 함수 자체가 섬에서 실행되면 섬에 남기 때문에 닭에 대한 접근이 자유롭다.

반면

 

  • 이렇게 greet라는 non-isolated이며 비동기 작업이 있다면 이 greet는 동시성의 바다에 있는 보트에서 실행될 것이다.
  • Swift 코드는 대부분 이러한 형태이다. 동기화되어 있으며 모든 actor와 격리되지 않고 주어진 파라미터에서만 작동하기 때문에 이 코드는 호출되는 isolation 도메인에 머문다.

 

Actor isolation 정리

 

  • Actor들은 프로그램의 나머지 부분과 격리된 상태를 유지한다.
  • Actor에 대해 한 번에 1개의 작업만 실행할 수 있기 때문에 해당 상태에 대한 동시 액세스는 없다.
  • Sendable 타입 검사는 Task가 actor를 출입시킬 때마다 적용되어 비동기 가변 상태가 빠져나가지 않도록 방지한다.
  • Actor는 그 자체로 Sendable하다.
  • 이를 모두 합쳐 Actor를 Swift concurrency에서 구성 요소 중 하나로 만든다.

 

MainActor

 

MainActor는 특별한 Actor이며 바다 한가운데 있는 큰 섬이라고 생각하면 된다.

  • UI에 대한 모든 drawing 및 상호 작용이 발생하는 Main Thread를 나타낸다.
  • UI에 대한 접근을 하고 싶다면 MainActor의 섬에서 코드를 실행해야 한다.
  • MainActor가 담당하는 UI 프레임워크와 앱 전체에서 실행해야 하는 코드가 정말 많다.
  • 하지만 actor이기 때문에 한 번에 1개의 작업만 처리가 가능하다.
  • 따라서 MainActor에게 너무 많은 작업을 시키거나 오래 걸리는 작업을 할당하지 않도록 하자
    • 이를 지키지 않으면 UI가 응답하지 않을 수 있다. (화면 버벅임 등 문제 발생 가능)

 

 

  • MainActor 어트리부트를 통해 MainActor에 대한 격리를 표현할 수 있다.
  • 이 어트리부트는 코드가 MainActor에서 실행되어야 함을 의미한다.
  • Swift는 MainActor isolated 코드가 메인 스레드에서만 동작하는 것을 보장한다.
  • 다른 actor에 대한 상호 배타적 액세스를 보장하는 동일한 매커니즘을 사용한다.
  • MainActor와 격리되지 않은 함수(위의 computeAndUpdate 함수)에서 MainActor로 격리된 updateView를 호출하는 경우 MainActor로의 전환을 나타내기 위해 await 키워드를 붙여야 한다.

 

 

  • MainActor 어트리부트는 타입에도 적용될 수 있다. (ChickenValley 전체에 메인 엑터 격리 적용)
  • 이렇게 하면 다른 Actor와 마찬가지로 속성과 메서드들이 MainActor에 격리되며 Sendable 하게 된다.
  • UI View 및 VC에 MainActor를 사용하는 것이 적합하다. (메인 스레드에서 동작 보장)
  • 프로그램의 다른 Task 및 actor와 VC에 대한 참조를 공유할 수 있으며 actor가 비동기적으로 VC에 접근할 수 있게된다.

 

 

  • 이는 앱의 아키텍처에 직접적인 영향을 미친다.
  • UI 관련 로직은 MainActor의 섬을 사용하지만 다른 로직들은 다른 Actor를 사용한다.
  • 이러한 Task(보트)들은 필요에 따라 MainActor와 다른 Actor 사이를 왕복할 수 있다.

 

 

 

Atomicity

Swift Concurrency의 목표는 Data Race를 제거하는 것이다.

즉, Data Corruption을 수반하는 low-level의 Data Race 또한 제거 대상이다.

 

 

여전히 우리는 원자성(Atomicity)에 대해 높은 수준에서 추론해야 한다. (내가 실행하고 있는 작업이 낮은 레벨에서도 Atomic 하게 동작하는지 확신 불가)

  • Actor는 한 번에 하나의 Task만 수행한다.
  • 그러나 Actor에 대한 실행을 중지하면 해당 actor는 다른 작업을 실행할 수 있다.
  • 이렇게 하면 프로그램이 진행되므로 Deadlock의 가능성이 제거된다. 그러나 await 구문에 대한 Actor의 invariants(불변)을 주의 깊게 고려해야 한다.
  • 그렇지 않으면 실제로 데이터가 손상(corrupted)되지 않더라도 high-level의 Data race가 발생할 수 있다.

예시 코드를 살펴보자

 

  • deposit 함수는 actor 외부에 있기 때문에 non-isolated 비동기 코드이다.
  • 섬에 원래 2개의 파인애플이 존재한다.
  • 이 함수는 섬에 접근하여 섬이 가진 음식(파인애플)을 복사하여 food 변수에 저장한다. (2개의 파인애플)
    • 이 때 섬에 접근해야 하기 때문에 await 키워드가 필요하다.
  • 그리고 보트에서 파인애플 한 개를 추가한다. -> 총 3개의 파인애플이 된다.
  • 이제 다시 island.food에 3개의 파인애플을 할당하면 어떻게 될까?

섬이 3개의 파인애플을 가지게 될 것 같지만 그렇지 않을 수 있다. (Data Race 발생 가능)

 

 

만약 첫 번째 보트가 island.food = food를 실행하기 위해 await 하는 동안 다른 해적선 보트가 섬에 접근하여 모든 파인애플들을 가져갈 수도 있다.

 

원래 보트는 섬에 파인애플 3개를 저장했는데 갑자기 바다에 존재하는 파인애플의 수가 5개로 증가하는 문제가 발생했다.

  • await 때문에 이러한 문제가 발생한 것이다.
  • 위 함수에서는 await가 2개 있으며 우리는 이 섬의 food 배열이 2개의 await 사이에서 변하지 않을 것이라고 가정했다.
  • 하지만 이들은 await 이기 때문에 다른 우선 순위가 높은 일을 하는 동안 우리의 작업이 보류될 수 있다.
  • 특정한 경우 Swift 컴파일러는 이렇듯 다른 Actor에 대한 상태를 대놓고 수정하려는 시도를 거부한다.

 

따라서 우리는 deposit 작업을 actor에 대한 동기 코드로 다시 작성하는 것이 적절하다.

 

 

  • deposit 함수를 Island에 작성하여 actor-isolated로 만든다.
  • 이는 동기식 코드이므로 suspend 없이 actor에서 실행될 것이다.
  • 따라서 작업 도중에 다른 배가 접근하여 섬의 상태를 변경하지 않을 것을 확신할 수 있다.

 

 

Atomicity 정리

 

  • Actor를 작성할 때는 어떤 식으로든 interleaving이 가능한 동기식 트랜잭션 작업 측면에서 생각하자.
  • 비동기 actor task는 간결하게 작성하자
  • 주로 동기식 트랜잭션 작업으로 구성하고 각 await 마다 actor의 상태가 양호하도록 주의해야 한다.
  • 이렇게 하면 Actor를 최대한 활용하여 Low-level과 High-level의 Data Race를 모두 제거할 수 있다.

 

 

Ordering

동시성의 세계에서는 여러가지 일이 동시에 발생하기 때문에 이러한 일들이 일어나는 순서는 실행마다 다를 수 있다.

 

예를 들어 사용자 입력 또는 서버의 메시지에서 나오는 이벤트 스트림이 있다.

이러한 이벤트 스트림이 들어오면 그 효과가 순차적으로 발생하길 원한다.

 

 

Swift Concurrency는 작업 순서를 지정하기 위한 도구를 제공하지만 Actor는 이러한 도구가 아니다.

Actor는 전체 시스템의 응답성을 유지하기 위해 priority가 높은 작업을 먼저 실행한다. (우선 순위 역전 문제 방지)

 

이는 완전히 FIFO 형태로 실행되는 Serial DispatchQueue와는 상당한 차이가 있는 것이다.

 

 

SwiftConcurrency에서는 작업의 순서를 보장하기 위한 몇가지 툴이 있다.

 

  1. Task
    • Task는 일반적인 제어 흐름으로 처음부터 끝까지 실행된다.
  2. AsyncStream
    • 실제 이벤트 스트림을 모델링할 수 있다. (마치 Combine의 스트림처럼)
    • for-await-in 루프를 통해 이벤트 스트림을 반복하여 차례로 각 이벤트를 처리할 수 있다.
    • AsyncStream은 순서를 유지하면서도 스트림에 요소를 추가할 수 있는 이벤트 생성자와 공유될 수 있다.

 

 

 

지금까지 Swift 동시성 모델이 Task 및 Actor 바운더리에서 Sendable 검사를 통해 유지되는 isolation 개념을 사용하여 Data race를 제거하는 방법에 대해 알아봤다.

 

그러나 모든 Sendable 타입을 모든 곳에서 표시하기 위해 하고 있는 작업을 전부 중지할 수는 없다. (개발자가 레거시 코드에 전부 Sendable을 적는 것은 시간이 많이 필요하기 때문)

점진적인 접근이 필요했는데 Swift 5.7은 컴파일러가 얼마나 엄격하게 Sendable을 검사해야 하는지 지정하는 Build Setting을 도입했으며 기본값은 Minimal이다.

 

Minimal로 두면 직접 개발자가 타입에 Sendable을 채택시킨 곳에서만 컴파일러가 오류를 발생시킨다.

 

 

Data Race의 안정성을 향상시키려면 Targeted Strict Concurrency Checking 설정을 활성화하면 된다.

이 설정을 사용하면 async/await, Task, Actor 같은 Swift Concurrency 기능을 이미 채택한 코드에 대한 Sendable 검사를 활성화 한다.

 

예를 들어 위의 코드에서 non-sendable 타입의 캡처를 잡아내서 경고를 발생시킨다.

 

 

때로는 non-sendable 타입이 다른 모듈로부터 나오는 경우가 있다. (예전에 만들어진 모듈에서 Sendable의 업데이트 대응을 하지 않은 상태일 때)

 

이런 경우 @preconcurrency 어트리부트를 사용하여 해당 모듈에서 오는 타입에 대해 일시적으로 Sendable 경고를 끌 수 있다.

 

어느 시점에서 FarmAnimal 모듈이 Sendable에 대한 대응을하여 업데이트가 되었다면 다음 두 가지 중 하나의 상황이 발생하게 된다.

  1. Chicken이 어떻게든 Sendable을 준수하게 되어 이제 @preconcurrency 어트리부트를 없애도 에러가 발생하지 않는 상황
  2. Chicken이 Non-Sendable로 확정이 난 상황 ➡️ 다시 컴파일러가 Sendable 하지 않은 타입을 캡처하고 있다고 경고를 보여준다.

 

Targeted Strict Concurrency Checking은 기존 코드와의 호환성과 잠재적인 Data race를 식별하는 것 사이에서 균형을 맞추려고 한다.

 

하지만 만약 Data race가 발생할 수 있는 모든 위치를 확인하고 싶다면 Complete Strict Concurrency Checking을 사용하면 된다.

 

 

Complete Strict Concurrency Checking은 Swift 6의 의미와 근사하여 Data Race를 완전히 제거한다.

이전 두 모드에서 확인하는 모든 항목을 검사하지만 추가로 모듈의 모든 코드에 대해 이를 수행한다.

 

위의 코드처럼 DispatchQueue에서도 동작한다.

위 코드에서는 동시적으로 body()를 실행하게 되는데 DispatchQueue의 async는 Sendable 클로저를 취하기 때문에 컴파일러가 경고를 생성하게 된다.

 

 

앞선 경고를 해결하기 위해서는 doWork 함수의 파라미터인 body 클로저에 @Sendable 을 붙여 Sendable 클로저로 만들면 된다.

 

이제 doWork의 모든 호출자는 Sendable 클로저를 제공해야 한다는 것을 알게되며 visit 함수에서 doWork를 호출하고 있기 때문에 visit 함수에 경고가 뜨게 된다. ➡️ Data Race의 원인이 visit 함수라는 것을 알게 되었다!

 

이렇듯 Complete Strict Concurrency Checking(전체 검사)를 완료함녀 프로글매의 잠재적인 Data Race를 제거할 수 있다.

 

 

 

Data Race를 제거하겠다는 Swift의 목표를 달성하기 위해서는 결국 검사를 완료해야 한다.

이 목표를 향해 점진적으로 노력할 것을 권장한다.

Swift 동시성 모델을 채택하여 Data Race 안전을 위해 앱을 설계한 다음 점진적으로 더 엄격한 동시성 검사를 활성화하여 코드에서 오류 클래스를 제거하자!!

 

가져온 타입에 대한 경고를 억제하기 위해 @preconcurrency를 사용하는 것을 걱정하지 말자!

왜냐하면 이 모듈들은 더 엄격한 동시성 검사를 채택하기 때문에 컴파일러가 우리의 가정을 재점검 할 것이기 때문에 안전하다.

'iOS > Swift' 카테고리의 다른 글

Beyond the basic of structured concurrency  (4) 2023.11.09
Swift concurrency: Behind the scenes  (0) 2023.09.13
Protect mutable state with Swift actors  (0) 2023.08.31
Swift의 분산된 Actor 소개  (0) 2023.08.22
Adopting Swift Packages in Xcode  (0) 2023.06.14

댓글