본문 바로가기

iOS/Concurrency

Concurrency (8) Getting Started With Actors

 

이전글 - Concurrency(7) Concurrent Code With Task Group 

 

Modern Concurrency in Swift를 읽고 간단 정리

 


 

이전 챕터에서 TaskGroup, ThrowingTaskGroup API를 사용해 작업을 병렬로 실행하여, 여러 스레드와 CPU 코어를 사용할 수 있도록 했다.

 

TaskGroup의 설계는 작업을 병렬로 실행하면서도, 그룹을 비동기 시퀀스로 순회함으로써 안전하고 직렬적인 방식으로 실행 결과를 수집할 수 있도록 해준다.

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/8-getting-started-with-actors

 

 

이전 장에서 공유 상태 변경 시에 유의점에 대해 언급했는데, 이는 동시성 프로그래밍의 어려운 측면 중 하나이다.

스레드가 동시에 동일한 메모리 영역에 접근할 때 신중한 제어가 필요하다.

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/8-getting-started-with-actors

 

이번 챕터에서는 이를 다룰 수 있는 actor에 대해 소개한다.

 

Understanding Thread-safe Code

[thread safe]

  • 어떤 스레드인지 관계없이 해당 메서드가 항상 예상대로 동작한다는 것을 의미한다.
  • 여러 스레드가 동시에 접근해도 데이터 손상이나 경합 상태가 발생하지 않는 것

[Note]

  • thred safe개념은 때로는 선형성(linearizability), 원자성(atomicity)라고 불리며, 이는 여러 프로세스에서 객체에 동시에 접근할 때 결과를 제한하려는 목적을 갖고 있다.
  • 선형성(linerizability)
    • 동시성 시스템에서 작업이 일관된 순서로 실행되는 것처럼 보이도록 보장하는 성질.
    • 여러 스레드나 프로세스가 동시에 어떤 객체에 접근하더라도, 그 결과는 특정한 순차적 실행 결과와 같아야 한다.
  • 원자성(atomicity)
    • 어떤 작업이 분리될 수 없는 단일 단위로 실행됨을 의미
    • 작업이 중간에 중단되지 않고, 완전히 실행되거나 전혀 실행되지 않는 성질

 

[Before Modern Concurrency]

 

Objective-C와 swift 5.5 이전 버전에서는 thread-safe를 표시할 수 있는 문법이 없었다.

 

아래 코드는 thread-safe 할까?

코드 자체만 보면 특별히 thread가 unsafe 한 부분이 없기 때문에 동시성 문제를 알아차리기 어렵다.

class Counter {
  private var count = 0

  func increment() {
    count += 1
  }
}
  • 서로 다른 스레드가 병렬로 실행되면서 둘 다 increment()를 호출하면, count가 정확히 증가하지 않을 수 있다.
  • 심지어 두 번의 increment()가 동시에 발생하게 되면, 앱의 crash를 발생시킬 수도 있다.
  • 불행히도 이러한 크래시는 Debug 모드보다 Release 모드에서 발현된다. (data-race 크래시가 일어날 만큼 충분히 빠른 실행속도의 최적화)
  • 따라서 not thread-safe이다.

 

Actor 이전 시대에는, 공유 상태에 대한 exclusive access를 보장하기 위해 다음을 사용했다.

  • locks
  • semaphores
  • serial disaptch queues

[Lock 사용]

  • 하나의 스레드가 공유 리소스에 접근하는 동안 다른 스레드가 접근하지 못하도록 자원에 대한 접근을 잠근다.
  • 그 스레드가 리소스를 풀어줄 때까지 기다려야 한다.

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/8-getting-started-with-actors

class Counter {
  private var lock = os_unfair_lock_s()
  private var count = 0

  func increment() {
    lock.withLock {
      count += 1
    }
  }
}
  • 개발자로서 이 API를 사용할 때, 정말 thread-safe인지 어떻게 알 수 있는가?
  • 컴파일러가 코드를 thread-safe 하다고 인식하고, 개발자의 실수로 인한 경합 상태를 막아줄 수 있는가?
  • 코드를 볼 수 없거나 코드를 철저히 읽을 시간이 없다면, 이 코드가 정말 thread-safe인지 알 수 없다.

 

Meeting Actor

[Actor]

  • class와 같은 reference 타입이다.
  • 내부 상태에 동시에 접근하는 여러 스레드로부터 안전하게 보호된다.
  • 런타임에 관리되는 serial exectuor
    • GCD의 serial dispatch queue처럼 작업을 하나씩 순차적 실행
    • actor의 상태 동시 접근으로부터 보호

Modern Concurrency in Swift 193p

actor Counter {
  private var count = 0

  // 클래스와 달리 한 번에 하나만 실행이 보장
  // count의 독점적인 변경이 보장
  func increment() {
    count += 1
  }
}

 

actor

 

Actor | Apple Developer Documentation

Common protocol to which all actors conform.

developer.apple.com

  • 프로토콜의 요구사항은 unownedExecutor 하나뿐
    • actor의 상태에 대한 접근을 직렬화하는 역할 
    • 항상 동일한 executor 반환
      • 주어진 actor의 인스턴스에 대해 항상 동일한 executor를 반환해야 한다.
    • executor 유지
      • actor를 유지하는 동안 executor도 살아 있어야 한다.
    • 암시적 접근
      • actor에서 작업이 스케줄링될 때 암시적 접근. 이러한 접근은 실제로 필요한 시점과 다르게 처리될 수 있다. (다른 작업들과 병합, 제거, 재배열..)
    • side effect 발생 금지
      • 이 속성은 작업 스케줄링 시 여러 번 호출되거나 최적화 과정에서 불필요한 호출이 발생할 수 있다. side effect이 발생하면 예상치 못한 동작이나 성능 저하가 발생할 수 있음
  • 기본적으로 actor는 공유된 전역 동시 스레드 풀(global concurrency thread pool)에서 작업을 실행
    • 이 전역 풀(pool)은 특정 스레드(또는 디스패치 큐)에 대한 연관성(thread affinity)을 보장하지 않음
    • 따라서 actor는 작업을 실행할 때 다른 스레드들을 자유롭게 사용할 수 있음. 
    • 참고 : SerialExecutor
  • 이 풀(pool)은 모든 기본 actor와 작업을 공유하며, actor나 task가 구체적인 executor 요구사항을 지정하지 않는 한 사용된다.
    • actor는 특정 SerialExecutor를 사용하도록 구성할 수 있다.
    • 또한 TaskExecutor를 사용하여 기본 task와 actor의 스케줄링에 영향을 줄 수 있다.  

ㄴ SerialExecutor, TaskExecutor는 나중에 따로 한번 정리하자

 

[state isolation layer]

 

actor는 swift 컴파일러와 동시성 문제 해결을 위해 특별한 약속을 갖는다.

  • 다른 타입actor에 접근할 때는 자동으로 비동기 처리되며, actorserial exectuor에서 스케줄링된다.
  • 이를 state isolation layer라고 한다.
actor Counter {
    private (set)var count = 0

    func increment() {
        count += 1
    }

    func getCount() -> Int {
        return count
    }
}

let counter = Counter()

// 비동기적으로 actor의 메서드에 접근
Task {
    await counter.increment()
    print(await counter.count)
}
  • state isolation layer는 모든 상태 변경이 thread safe 하도록 보장한다.
  • actor 자체가 API 사용자, 컴파일러, 그리고 런타임에 대한 thread-safe 보증이다.

Mutating State Concurrently

class EmojiArtModel: ObservableObject {
  @Published private(set) var imageFeed: [ImageFile] = []
  
  private(set) var verifiedCount: Int = 0
  
  func verifyImages() async throws {
      // 1
      try await withThrowingTaskGroup(of: Void.self) { group in
          // 2
          self.imageFeed.forEach { file in
              // 3
              group.addTask { [unowned self] in
                  // 4
                  try await Checksum.verify(file.checksum)
                  //  5
                  self.verifiedCount += 1 // ✅
              }
          }

          // 6. 모든 태스크가 완료될 때까지 기다립니다.
          try await group.waitForAll()
      }
  }
  ...
}

 

 

위 코드는 아래의 내용을 갖는다.

 

  1. 비동기 태스크 그룹을 생성
    • 이 그룹 안에서 여러 비동기 작업을 수행할 수 있다.
    • 작업 중 에러가 발생하면 그 에러를 재전달(re-throw)한다.
  2. 이미지 리스트를 순회하며, 각 파일에 대해 새로운 태스크를 생성하여 검증 작업을 수행
  3. 태스크 그룹에 새로운 비동기 작업을 추가
    • unowned self 캡처를 사용해 메모리 순환 참조를 방지
  4. 해당 파일의 체크섬을 검증하며, 잘못된 체크섬이 있을 경우 에러를 발생시킵니다.
  5. 검증에 성공했을 때 verifiedCount를 증가
    • 이 부분은 race condition 문제가 있을 수 있다
  6. 그룹 내의 모든 태스크가 완료될 때까지 대기하며, 에러가 발생하면 이를 재전달(re-throw)
    • 태스크 그룹은 암시적으로 함수 반환 이전에 모든 태스크의 완료를 한다.
    • 하지만, 그룹 클로저 내부에 try 구문이 없다면, 컴파일러가 이를 non-throwing 그룹으로 결정하고, 에러를 재전달 하지 않는다.
    • 따라서 try await group.waitForAll()를 명시하여 throwing task group임을 컴파일러에게 알린다.

태스크 그룹 내에 try 구문이 없는 경우

 

 

Detecting Race Conditions

경쟁 상태를 검증하는 방법 중 하나는 프로젝트 스킴의 Thread Sanitizer 설정이다.

 

Using Actors to Protect Shared Mutable State

위에서 살펴본 verfiedCount동시성 접근(concurrent access)으로부터 보호하기 위해

EmojiArtModelactor로 변환한다.

actor EmojiArtModel: ObservableObject

 

actor로 변경하니 verifiedCount를 수정하는 부분에서 컴파일 에러가 발생한다.

  • 컴파일러가 자동으로 문제를 해결해주지 않지만, 대신 어떻게 변경해야 하는지 알려준다.

 

"액터의 직접적인 범위(direct scope)를 벗어난" 곳에서 상태를 업데이트하기 때문에 발생한 에러

  • 액터의 serial executor에 속하지 않는 모든 코드는 외부 접근으로 간주된다.
  • 다른 타입에서의 호출, 동시성 작업(ex. TaskGroup) 모두 외부 접근

컴파일 에러를 제거하기 위해, verifiedCount를 수정하는 코드를 메서드 increaseVerifiedCount() 추출한다.

  • increaseVerifiedCount()는 액터 내부에서는 동기(synchronous)로 호출할 수 있다.
  • 액터 외부에서는 컴파일러에 의해 항상 비동기(asynchrnous)로 강제된다.
actor EmojiArtModel: ObservableObject {
  @Published private(set) var imageFeed: [ImageFile] = []
  
  private(set) var verifiedCount: Int = 0
  
  func verifyImages() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
      self.imageFeed.forEach {  file in
        group.addTask { [unowned self] in
          try await Checksum.verify(file.checksum)
          await self.increaseVerifiedCount() // ✅
        }
      }
      
      try await group.waitForAll()
    }
  }
  
  private func increaseVerifiedCount() {
    self.verifiedCount += 1
  }
  ...
}

 

Sharing Data Across Actors

사실 EmojiArtModelimageFeed는 UI에서 사용하는 값이다.

따라서 이 프로퍼티는 메인 액터에 두는 것이 합리적이다.

@Published @MainActor private(set) var imageFeed: [ImageFile] = []

 

메인 액터EmojiArtModel 사이에 imageFeed를 어떻게 공유할 수 있을까?

 

[EmojiArtModel에서 imageFeed를 사용하는 부분]

  • imageFeed의 변경이 UI에 영향이 가기 때문에 MainActor 안에서 수행
actor EmojiModel: ObeservableObject {
  @Published @MainActor private(set) var imageFeed: [ImageFile] = []
  
  ...

  func loadImages() async throws {
    await MainActor.run { // ✅
      self.imageFeed.removeAll()
    }
    
    guard let url = URL(string: "http://localhost:8080/gallery/images") else {
      throw "Could not create endpoint URL"
    }
    let (data, response) = try await URLSession.shared.data(from: url, delegate: nil)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
      throw "The server responded with an error."
    }
    guard let list = try? JSONDecoder().decode([ImageFile].self, from: data) else {
      throw "The server response was not recognized."
    }
    await MainActor.run { // ✅
      self.imageFeed = list
    }
  }
  ...
}

 

  • 메인 액터EmojiArtModel은 서로 다른 액터이기 때문에 비동기 호출 (await)
actor EmojiArtModel: ObservableObject {
  @Published @MainActor private(set) var imageFeed: [ImageFile] = []
  
  ...
  
  func verifyImages() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
      await self.imageFeed.forEach {  file in // ✅
        group.addTask { [unowned self] in
          try await Checksum.verify(file.checksum)
          await self.increaseVerifiedCount()
        }
      }
      
      try await group.waitForAll()
    }
  }

 

 

[메인 액터에서 EmojiArtModel을 사용하는 부분]

  • 마찬가지로 서로 다른 액터이기 때문에 비동기 호출 (await)
.onReceive(timer) { _ in
  guard !model.imageFeed.isEmpty else { return }

  Task {
    progress = await Double(model.verifiedCount) / Double(model.imageFeed.count) // ✅
  }
}

 

Understanding Sendable

Sendable

 

Sendable | Apple Developer Documentation

A thread-safe type whose values can be shared across arbitrary concurrent contexts without introducing a risk of data races.

developer.apple.com

 

 

  • 스레드-안전한(thread-safe) 타입
  • 임의의 동시성 컨텍스트에서 공유될 수 있으면서도 데이터 레이스의 위험이 없음
  • actorSendable을 준수

 

[Sendable 타입 특성]

Sendable은 하나의 동시성 도메인(Concurreny Domain)에서 다른 동시성 도메인으로 안전하게 전달할 수 있다.

  • ex. 액터의 메서드를 호출할 때 Sendable 값을 인자로 전달할 수 있다.

 

[Sendable이 가능한 케이스]

  • 값 타입
  • 가변 저장소가 없는 참조 타입 (불변 상태로만 동작)
  • 내부적으로 상태 접근을 관리하는 참조 타입 (lock 등으로 관리)
  • @Sendable이 표기된 함수와 클로저

 

[의미적 요구 사항 (semantic requirements)]

  • Sendable에는 필수 메서드나 속성이 없지만, 컴파일 타임에 강제되는 의미적 요구사항이 있다.
  • Sendable에 대한 준수는 타입의 선언과 동일한 파일에서 선언되어야 한다.
  • 컴파일러 강제를 없애고 싶다면?
    • @unchecked Sendable
    • 개발자가 직접 스레드 안전성을 보장하며, 컴파일러는 감지하지 않음
class MySafeClass: @unchecked Sendable {
    private var value: Int = 0
    private let lock = NSLock()

    func increment() {
        self.lock.lock()
        self.value += 1
        self.lock.unlock()
    }
}

 

[Sendable Structures and Enumerations]

 

열거형이나 구조체가 Sendable을 준수하려면 

오직 Sendable 한 멤버들과 연관 값(associated value)으로 이뤄져야 한다.

 

구조체 및 열거형이 암시적으로 Sendable 준수하는 경우

  • frozen 구조체 / 열거형
@frozen
struct MyStruct: Sendable {
    let value: Int
}
  • non-public이면서 @usableFromInline으로 표시되지 않은 경우
struct MyStruct2 {
    let value: Int
}

 

그 외는 명시적으로 Sendable의 준수를 선언해야 한다.

 

non-sendable 한 저장 프로퍼티를 갖는 구조체와 non-sendable 한 연관 값을 갖는 열거형은

@unchekced Sendable을 사용하여 컴파일 타임 에러를 바로 잡을 수 있다.

(단 개발자가 의미적 요구사항을 준수!!)

 

[Sendable Actors]

  • 모든 Actor 타입은 Sendable을 암시적으로 준수
  • actor는 그 자체로 스레드 안전한(thread-safe) 구조

 

[Sendable Classes]

 

클래스가 Sendable을 준수하려면,

  • final class
  • 불변(immutable), Sendable 한 저장 프로퍼티만 가져야 함
  • 상속 클래스가 없거나 NSObject를 상속

 

@MainActor로 표시된 클래스는 암시적으로 Sendable 준수

  • 메인 액터는 클래스 상태에 대한 모든 접근을 메인 스레드에서만 허용하므로, 클래스의 스레드 안전성이 보장
  • 이 클래스는 가변 속성, Sendable 하지 않은 속성도 가질 수 있다.
  • 이러한 속성들에 접근할 때는 항상 메인 스레드에서만 이뤄지므로, 동시성 문제가 발생하지 않는다.

위 요구사항을 충족하지 않는 클래스는

@unchekced Sendable을 사용하여 컴파일 타임 에러를 바로 잡을 수 있다.

 

[Sendable Functions and Closures]

 

  • Sendable 프로토콜을 준수하는 대신 @Sendable로 함수와 클로저에 표시
  • 함수나 클로저가 캡처하는 모든 값은 Sendable 이어야 한다.
  • Sendable 클로저는 값으로만 캡처해야 하며, 캡처된 값은 Sendable 타입이어야 한다.
    • 가변 상태를 참조하지 않도록 하여 데이터 레이스 방지
  • Sendable 클로저가 요구되는 상황에서는, 요구 사항을 충족하는 클로저는 암시적으로 준수된다.
let sendableClosure = { @Sendable (number: Int) -> String in
    if number > 12 {
        return "More than a dozen."
    } else {
        return "Less than a dozen"
    }
}

 

기존에 Swift 동시성 모델에서 사용하던 많은 @Sendable 클로저들이 Swift6에서 @isolated(any) 클로저로 개선되었다.

ㄴ 음.. 아직 잘 모르겠다. 동시성 쭉 공부하고 다시 돌아와 보면 좋을 듯

 

[Sendable Tuples]

  • Tuple의 모든 요소 값이 Sendable
  • 암시적 준수

[Sendable Metatypes]

  • Int.Type과 같은 메타 타입들은 암시적으로 Sendable 준수

 

Making Safe Methods nonisolated

  • actor의 모든 메서드가 반드시 격리가 필요한 것은 아니다.
  • actor공유 상태를 읽거나 변경하지 않고, 동시성 위험이 없다면 그 메서드는 더 이상 actor 격리를 필요로 하지 않는다.
  • 이때 nonisloated 키워드를 사용해 주면 된다.

 

아래의 loadImages()는 더 이상 보호할 상태가 없기에 nonisolated를 마킹해 줄 수 있다.

actor EmojiArtModel: ObservableObject {
  @Published @MainActor private(set) var imageFeed: [ImageFile] = []
  
  private(set) var verifiedCount: Int = 0

  ...
  
  nonisolated func loadImages() async throws { // ✅ 
    await MainActor.run {
      self.imageFeed.removeAll()
    }
    
    guard let url = URL(string: "http://localhost:8080/gallery/images") else {
      throw "Could not create endpoint URL"
    }
    let (data, response) = try await URLSession.shared.data(from: url, delegate: nil)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
      throw "The server responded with an error."
    }
    guard let list = try? JSONDecoder().decode([ImageFile].self, from: data) else {
      throw "The server response was not recognized."
    }
    await MainActor.run {
      self.imageFeed = list
    }
  }
  ...
}

 

  • nonisolated를 붙여주면 actor 메서드가 아닌 일반 클래스 메서드처럼 동작한다.
  • 동시성 안전성 검사를 거치지 않으므로 성능이 개선될 수 있다.