본문 바로가기

iOS/Concurrency

Concurrency (9) Global Actors

 

이전글 - Concurrency(8) Getting Started With Actors

 

Modern Concurrency in Swift를 읽고 간단 정리


 

이전 장에서는 Swift의 액터(actor) 타입을 공부했다.

 

이는 코드가 내부 상태에 안전하고 동시성 접근을 할 수 있도록 해준다.

동시성 작업(Concurrent computation)들을 신뢰할 수 있게 해주며, data race를 방지한다.

 

이번 장에서는 Global Actor에 대해 다룰 것인데, MainActor는 대표적인 Global Actor 중 하나이다.

Swift의 MainActorUI 작업을 메인 스레드에서 안전하게 실행시켜 주는 역할을 한다.

 

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/9-global-actors

 

앱은 항상 하나의 메인 스레드에서 실행되기 때문에 다수의 MainActor를 생성할 수 없다.

따라서 앱 전역에서 안전하게 사용할 수 있는 기본 공유 인스턴스가 있다는 것은 매우 합리적이다.

 

메인 스레드 외에도 앱 전역에서 단일 인스턴스를 관리해야 하는 상황이 생길 수 있다.

  • 데이터베이스
  • 이미지 / 데이터 캐시
  • 사용자 인증 상태 확인

Swift는 MainActor 외에도 개발자가 필요에 따라 Global Actor를 만들 수 있게 해준다.

 

Getting to Meet GlobalActor

https://developer.apple.com/documentation/swift/globalactor

 

GlobalActor | Apple Developer Documentation

A type that represents a globally-unique actor that can be used to isolate various declarations anywhere in the program.

developer.apple.com

 

[Definition]

  • 전역적으로 고유한 액터를 나타내는 타입. 프로그램 어디에서든 다양한 선언을 격리하는 데 사용할 수 있다.

[Overview]

  • GlobalActor 프로토콜을 준수하고 @globalActor를 마킹
  • Global Actor필수 요구사항
    • static 프로퍼티인 shared
  • 해당 Actor의 격리된 환경에서 실행된다.
    • 다른 액터나 비격리된 코드에서 이 선언에 접근할 때 자동으로 동기화가 이뤄진다.
  • 상호 배타적 접근(exclusive access)
    • Global Actor에 속한 선언에 접근할 때 공유 액터 인스턴스를 통해 동기화가 이뤄지며, 이를 통해 한 번에 한 액터만이 해당 선언에 접근할 수 있도록 보장.
@globalActor actor MyActor {
    static var shared: MyActor = .init()
    private init() { }
}

 

 [Custom Actor Executors]

  • 액터가 실행되는 스레드 또는 큐를 커스터마이징해야 하는 경우, Custom Executor를 지정할 수 있다.
    • 예를 들어, 특정 액터의 모든 작업이 특정 스레드에서만 실행되도록 보장하고 싶은 경우
  • nonisolated 프로퍼티 unownedExecutor를 선언
    • 해당 액터의 실행자(executor)가 어떤 스레드 또는 큐에서 실행될지 커스텀
    • (non-global 액터와 동일)
actor CustomActor {
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        return someCustomExecutor
    }
}

 

  • sharedUnownedExecutor
    • global actor에 sharedUnownedExecutor라는 static 프로퍼티가 있지만 오버라이딩할 필요 없음.
    • 기본적으로 shared.unownedExecutor에 위임하므로, 이를 따르는 것을 권장

[Usage]

  • @애너테이션 사용 가능. : 앱의 UI를 변경할 수 있도록 메서드에 @MainActor를 적용한 것과 같이 커스텀 글로벌 액터도 가능
    • @MyActor, @DatabaseActor, @ImageLoader
    • 전역에서 공유해야 하는 리소스를 처리할 때, 동시성 문제를 해결하면서 단일 액터 내에서 안전하게 작업 가능
@MyActor func say(_ text: String) {
  ... automatically runs on MyActor ...
}

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/9-global-actors

  • 전체 클래스에 적용하면 nonisolated 멤버를 제외하고 모두 격리된 실행 환경에서 실행
@MyActor class MyClass {
    func someFunction() {
        // ...
    }
    
    nonisolated func someNonisolatedFunction() {
        // ...
    }
}

let myClass = MyClass()

Task {
    await myClass.someFunction() // 격리됨
}

myClass.someNonisolatedFunction() // ok

 

 

  • 메서드, 전체 타입을 그룹핑 -> 안전하게 가변 상태를 공유할 수 있는 동기화된 사일로(synchronized silo)에서 관리

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/9-global-actors

 

Creating a Global Actor

개념에 대해 살펴봤으니 예제를 만나볼 차례이다.

 

이미지를 서버에서 가져오는 작업디스크에 저장된 이미지 파일 관리를 처리하는 ImageDatabse를 만들자

 

이 방식으로 동시성 문제를 피하면서, 메모리와 디스크 캐시를 함께 활용할 것으로 기대한다.

 

@globalActor actor ImageDatabase { // 1 
  
  static let shared = ImageDatabase() // 1
  
  let imageLoader: ImageLoader = ImageLoader() // 2
  
  private let storage: DiskStorage = DiskStorage() // 3
  private var storedImagesIndex: Set<String> = [] // 4
  
  private init() { } // 1
}

 

  1. globalActor 선언
    • ImageDatabse@globalActor를 마킹하고 shared 프로퍼티 추가
  2. ImageLoader actor
    • 서버에서 가져오지 않은 이미지를 자동으로 가져온다.
  3. DiskStorage class
    • 파일을 읽고, 쓰고, 삭제하는 작업을 처리하는 클래스
    • 파일 입출력 작업을 캡슐화하여 액터 내부에서 파일 작업 코드를 작성할 필요 없음
  4. storedImageIndex
    • 디스크에 저장된 이미지 파일 목록을 인덱스로 관리
    • 이미 디스크에 있는 파일을 불필요하게 다시 가져오는 것을 방지

Creating a Safe Silo

위 코드에서 ImageLoaderDiskStorage 두 가지 의존성을 도입했다.

 

위 두 인스턴스는 동시성 문제에서 안전할까?

  • ImageLoader
    • 액터이므로 동시성 문제를 일으키지 않는다
  • DiskStorage
    • ImageDatabase가 액터이므로 DiskStoarge의 코드도 직렬로 실행된다.
    • ImageDatabase 내에서는 데이터 레이스가 발생하지 않는다.
    • 하지만, 다른 스레드, 액터, 또는 함수에서 DiskStorage 인스턴스를 생성하여 사용하는 경우, 데이터 레이스 발생 가능

 

[문제 해결 방법]

 

DiskStorage를 액터로 변환하면 된다.

  • But, ImageDatabase가  DiskStorage와 강하게 결합되어 있을 때, 불필요한 액터 간의 전환은 성능에 비효율적이다.
actor DiskStorage { ... }

 

이 문제를 해결하는 다른 방법은 DiskStorage의 코드를 ImageDatabse의 serial executor에서 수행되도록 보장하는 것.

  • 이렇게 하면 동시성 문제를 해결하고 불필요한 액터 간 전환(Actor hopping)을 피할 수 있다.
@ImageDabase class DiskStorage { ... }

 

@ImageDatabase를 붙여주는 것은 어렵지 않다. 그러나 아래와 같은 컴파일 에러를 발견하게 된다.

Call to global actor 'ImageDatabase'-isolated initializer 'init()' in a synchronous actor-isolated context

 

 

  • DiskStorage의 코드가 ImageDatabaseSerial Executor에서 실행되도록 설정됨
  • ImageDatabase가 완전히 초기화되기 전에 DiskStorage를 생성하려 하니 컴파일에러가 발생

 

책에서는 암시적 옵셔널 선언으로 초기화를 해결하고 있다.

@globalActor actor ImageDatabase {
  
  static let shared = ImageDatabase()
  
  let imageLoader: ImageLoader = ImageLoader()
  
  private var storage: DiskStorage! // ✅
  private var storedImagesIndex: Set<String> = []
  
  private init() { }
  
  func setup() async throws { // ✅ setup 코드 추가
    self.storage = await DiskStorage()
    
    for fileURL in try await self.storage.persistedFiles() {
      self.storedImagesIndex.insert(fileURL.lastPathComponent)
    }
  }
}

 

Writing Files to Disk

  • 이미지를 디스크에 저장하는 로직 추가
  •  DiskStorage의 nonisolated 멤버는 await 없이 접근
@globalActor actor ImageDatabase {
  
  ...
  
  func store(image: UIImage, forKey key: String) async throws {
    guard let data = image.pngData() else {
      throw "Could not save image \(key)"
    }
    
    let fileName = DiskStorage.fileName(for: key)
    try await self.storage.write(data, name: fileName)
    self.storedImagesIndex.insert(fileName)
  }
}

@ImageDatabse class DiskStorage {
  
  private var folder: URL
  
  ...
  
  nonisolated static func fileName(for path: String) -> String {
    return path.dropFirst()
      .components(separatedBy: .punctuationCharacters)
      .joined(separator: "_")
  }

  func write(_ data: Data, name: String) throws {
    try data.write(to: self.folder.appendingPathComponent(name), options: .atomic)
  }
  
  ...
  
}

 

Fetching Images from Disk (or Elsewhere)

이미지를 데이터 베이스에서 가져오는 메서드를 추가할 것이다.

  • if 이미지가 메모리 캐시에 존재, 메모리에서 가져온다.
  • else if 이미지가 디스크에 저장, 디스크에서 가져온다.
  • else  ImageLoader를 사용하여 서버에 fetch
@globalActor actor ImageDatabase {
  
  ...
  
  func image(_ key: String) async throws -> UIImage {
    // 캐시의 키를 local copy하지 않으면 동시성 문제가 발생할 수 있음.
    let keys = await self.imageLoader.cache.keys
    
    if keys.contains(key) {
      // 1 메모리 캐시
      print("Cached in-memory")
      return try await self.imageLoader.image(key)
    }
  
    do {
      // 2 디스크 캐시
      let fileName = DiskStorage.fileName(for: key)
      if !self.storedImagesIndex.contains(fileName) {
        throw "Image not persisted"
      }
      
      let data = try await self.storage.read(name: fileName)
      guard let image = UIImage(data: data) else {
        throw "Invalid image data"
      }
      
      print("Cached on Disk")
      
      await self.imageLoader.add(image, forKey: key)
      return image
    } catch {
      // 3 fetch 이미지
      let image = try await self.imageLoader.image(key)
      try await self.store(image: image, forKey: key)
      return image
    }
  }
}