apple/WWDC

Protect mutable state with Swift actors - WWDC21

lgvv 2025. 4. 27. 16:24

Protect mutable state with Swift actors - WWDC21

 

actor를 활용해 동시에 실행되는 상황에서 mutable state를 보호하는 방법에 대해서 알아볼 것.

 

목차

- Actors

- Actor reentrancy

- Actor isolation

- Main actor

 

 

근본적으로 어려운 것은 데이터 경합 상황을 피하는 것.

데이터 경쟁은 여러 개의 서로 다른 스레드가 동일한 데이터에 접근하고 이 중 하나 이상이 쓰기 동작일 때 발생

이 문제는 발생하기 쉽지만 디버깅은 매우 어려움.

 

 

데이터 경합 상황에 의하여 둘 다 값이 1 혹은 둘 다 2가 나타나는 상황이 발생할 수 있음.

데이터 경합은 공유된 변경 가능한 상태로 인해 발생함.

데이터가 변경되지 않거나 여러 동시 작업이 발생하지 않도록 공유되지 않는 상황이라면 데이터 경합 상황은 발생하지 않음.

 

데이터 경합을 피하는 하나의 방법은 데이터 의미론(semantics) 등을 통해 데이터 경합을 피하는 것. 

 

  • semantics란
    • 어떤 동작이나 코드가 의미하는 행동, 규칙, 그리고 보장(guarantee)을 의미
    • 즉, 코드가 문법(syntax)적으로 어떻게 생겼냐가 아니라, 실제로 “어떻게 동작할 것인가”

 

 

 

Counter를 구조체로 변경하고, Task 내부에 지역 변수로 선언하여 사용하면 모든 값에 1이 출력됨.

이렇게 하면 데이터 경합 상황은 피할 수 있지만 원하는 동작은 아님.

 

 

공유된 mutable state를 동시에 사용할 때 데이터 경합이 발생할 수 있는데 않도록 보장하기 위해 동기화가 필요.

동기화를 위해 저수준 Atomics과 같은 저수준 도구부터 Serial Dispatch Queue와 같은 고수준도구도 존재.

각각의 고유의 장단점이 존재하지만, 모두 중요한 공통적인 약점을 지님.

사용하기에 따라 데드락과 같은 문제가 발생할 수도 있음.

 

 

actor는 공유된 mutable state를 위한 동기화 메커니즘. 액터는 고립된 자신만의 고유한 상태를 가짐.

actor의 접근할 수 있는 방법은 오로지 actor를 거쳐야 함.

actor를 거칠 때마다 actor의 동기화 메커니즘은 다른 코드가 동시에 접근할 수 없기 mutually-exclusive를 보장함.

 

actor는 Swift의 새로운 유형.

actor는 Swift의 모든 유형과 동일한 기능을 제공하며, 속성, 메서드, 초기화, 서비스크립트 등을 가질 수 있음.

protocol을 conform 할 수 있으며, extension을 사용할 수 있음.

class와 마찬가지로 참조 타입이며, actor의 목적은 mutable state를 표현하는 것이기 때문임.

 

 

외부에서 actor를 사용할 때는 비동기적으로 진행.

만약 actor를 사용할 때 busy한 경우 실행 중인 CPU가 다른 작업을 수행할 수 있음.

await 키워드는 actor에 대한 비동기 코드가 중단을 수반할 수 있음을 나타냄.

 

 

 

actor 내부에서는 이미 코드가 actor 내부에서 실행됨을 알고 있으므로 await을 작성할 필요가 없음.

actor의 synchronous 코드는 항상 suppend 없이 완료될 때까지 실행됨.

 

 

await을 만나면 해당 지점에서 함수가 일시 중단될 수 있음을 의미하며, 다른 작업이 실행될 수 있도록 CPU를 양보하는데 이는 프로그램 전반에 영향을 미침.

함수가 재개되는 지점에서 전체 프로그램 상태가 변경되며 await 이전에 내린 가정이 await 이후에도 동일하게 유지되지 않을 수도 있음

actor의 재진입은 교착 상태를 방지하고 진행을 보장하지만, 각 대기열에서 확인해야 함.

 

 

reentrancy를 잘 설계하려면 동기적으로 동작하는 코드 내에서 actor의 상태 변경을 실행해야 함.

이상적으로는 모든 상태의 변경은 동기적으로 동작하는 함수 내에서 수행하는 것이 좋음.

상태 변경에는 일시적으로 actor가 일관되지 않은 상태로 전환되는 것도 포함. 기다리기 전에 일관성을 복원해야 함.

await은 잠재적으로 정지될 수 있는 지점이 존재하며, 코드가 재개되기 전에 타이머, 전역 상태 등은 await 후에 확인해야 함.

 

 

Actor isolation

이번에는 클로저, 클래스를 포함해 다른 언어의 기능들과 actor의 isolation이 어떻게 상호작용 하는지를 알아볼 것.

 

 

actor도 다른 타입과 마찬가지로 프로토콜을 conform 할 수 있음.

Equtable을 준수하기 위한 static 메서드는 두 매개변수 모두 actor외부에 있어서 isolation을 준수하지 않음.

하지만 구현이 오직 변경 불가능한 actor의 상태에 접근해서 문제는 없음.

 

 

 

Hashable을 준수하게 하려면 해당 함수를 외부에서 호출 가능

따라서 acotr-isolation을 준수하지 않아서 컴파일러가 오류를 발생시킴

 

 

nonisolated 키워드를 통해 컴파일 에러를 막아줌

nonisolated는 actor 외부에 있는 것으로 처리되므로 actor의 mutable state를 참조할 수 없음

하지만 위의 예시에스는 변경 불가능한 idNumber를 참조하므로 문제가 없음

 

 

하지만 mutable state인 booksOnLoan을 외부에서 접근할 경우 데이터 경합 문제로 인해 컴파일 오류 발생

 

 

클로저는 한 함수 내에 정의된 작은 함수 나중에 다른 함수로 전달되어 실행될 수 있음.

함수와 마찬가지로 클로저는 actor-isolated일 수 있고 아닐 수도 있음

 

이 예제에서는 readSome을 호출하는데 await이 없음. 그 이유는 readSome 메서드가 actor 내부에 있어서 actor-isolated이기 때문

 

Task.detached에서 실행되는 작업은 actor가 실행하는 다른 작업과 동시에 클로저를 실행함.

즉, 여기서 isolation은 actor에 적용되지 않으며 데이터 경쟁이 발생할 수 있음.

 

이는 코드가 actor 내부에서 실행되는지 아니면 외부에서 실행되는지에 대한 부분임.

 

 

 

Book이 struct일 때는 안전하나, class일 경우에는 참조를 가지게 되어서 데이터 경합이 발생할 수 있음.

동시에 사용해도 사용해도 안전한 유형을 Sendable 이라고 함.

 

 

 

Sendable Type은 여러 영역 사이에서 값을 공유할 수 있는 유형.

한 곳에서 다른 곳에서 다른 곳으로 값을 공유하고, 간섭하지 않고 해당 값의 자체 사본을 안전하게 수정할 수 있는 경우에 Sendable type이 될 수 있음.

 

class는 Sendable이 될 수 있지만 신중하게 잘 구현된 경우에만 가능

어떤 클래스가 본인과 모든 하위의 클래스가 변경 불가능한 상태만 가지고 있다면 Sendable임.

클래스가 내부적으로 동기화를 수행하는 경우에도 Sendable임(예시로 Lock을 사용해서 보호하는 경우)

하지만 대부분의 class는 그렇지 않으므로 Sendable이 아님

함수는 반드시 Sendable하지 않으므로 액터 간에 안전하게 전달할 수 있는 함수에 대해서만 Sendable임

 

 

사실상 모든 동시성코드는 Sendable 유형에 따라 상호작용 해야 함

Sendable 유형은 코드를 데이터 경합으로부터 안전하게 보호함

 


Sendable 타입을 알리기 위해서는 Sendable은 프로토콜로 해당 유형이 Sendable을 준수한다고 명시하면 됨.

이 예제에서는 Author가 class인 경우 에러를 발생시킴

 

 

Pair 한 유형에는 모든 변수가 Sendable인 경우에만 Sendable임.

 

 

 

함수 자체는 Sendable일 수도 있는데, 이는 actor 간에 함숫값을 전달하는 것이 안전하다는 것을 의미

클로저의 경우에는 특히 중요한데, 데이터 경쟁을 발생할 수 있는 작업을 방지함.

Sendable 클로저는 캡처할 수 없는데, 왜냐하면 로컬 변수에서 데이터 경쟁이 발생하기 때문임.

클로저가 캡처하는 모든 값은 Sendable이어야 하며 클로저를 사용하여 actor의 경계를 넘어 Sendable이 아닌 유형을 이동할 수 없도록 해야 함.

마지막으로 synchronous 클로저는 actor로부터 격리될 수 없는데, 왜냐하면 actor 외부에서 코드가 실행될 수 있기 때문

 

 

 

 

detached에서 실행되는 것은 Sendable임.

Counter 예제에서는 Sendable 하다는 것을 보임.

 

 

read 예시에서는 detathed 된 작업에 대한 isolation이 Sendable이므로 actor에서 격리되면 안 된다는 오류를 발생시킴.

따라서 여기에서는 actor와의 상호작용이 비동기적으로 이루어져야 함.

 

 

Sendable 클로저는 변경 가능한 상태가 actor 간에 공유되지 않고, 동시에 수정될 수 없도록 검사하여 actor-isolation을 유지하는데 도움이 됨.

 

 

Main actor

MainActor는 특별한 actor인데, 메인스레드를 설명하는 특별한 actor임

MainActor는 main thread를 나타내는 나타내는 actor로 일반적인 actor와 다름.

 

 

- MainActor는 모든 동기화를 DispatchQueue를 통해서 수행

   - Runtime 관점에서 MainActor는 DispatchQueue.main과 상호교환 가능함.

- 메인 스레드에 있어야 하는 코드와 데이터가 곳곳에 흩어져 있음.

   - SwiftUI, UIKit, AppKit 등 기타 시스템 프레임워크

   - Swift Concurrency를 사용하면 해당 작업이 Main thread에 있어야 함을 알릴 수 있음

   - 외부에서는 await을 사용하므로 DispatchQueue.main에서 동기적으로 수행하며 Swift는 코드가 메인 스레드에서 실행되도록 함.

 

 

 

타입도 MainActor가 될 수 있으며 이 경우 모든 변수와 하위 클래스가 MainActor에 배치됨

nonisolated를 통해 개별적으로 Opt out 시킬 수도 있음