이전글 - Concurrency(8) Getting Started With Actors
Modern Concurrency in Swift를 읽고 간단 정리
이전 장에서는 Swift의 액터(actor) 타입을 공부했다.
이는 코드가 내부 상태에 안전하고 동시성 접근을 할 수 있도록 해준다.
동시성 작업(Concurrent computation)들을 신뢰할 수 있게 해주며, data race를 방지한다.
이번 장에서는 Global Actor에 대해 다룰 것인데, MainActor는 대표적인 Global Actor 중 하나이다.
Swift의 MainActor는 UI 작업을 메인 스레드에서 안전하게 실행시켜 주는 역할을 한다.
앱은 항상 하나의 메인 스레드에서 실행되기 때문에 다수의 MainActor를 생성할 수 없다.
따라서 앱 전역에서 안전하게 사용할 수 있는 기본 공유 인스턴스가 있다는 것은 매우 합리적이다.
메인 스레드 외에도 앱 전역에서 단일 인스턴스를 관리해야 하는 상황이 생길 수 있다.
- 데이터베이스
- 이미지 / 데이터 캐시
- 사용자 인증 상태 확인
Swift는 MainActor 외에도 개발자가 필요에 따라 Global Actor를 만들 수 있게 해준다.
Getting to Meet GlobalActor
https://developer.apple.com/documentation/swift/globalactor
[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 ...
}
- 전체 클래스에 적용하면 nonisolated 멤버를 제외하고 모두 격리된 실행 환경에서 실행
@MyActor class MyClass {
func someFunction() {
// ...
}
nonisolated func someNonisolatedFunction() {
// ...
}
}
let myClass = MyClass()
Task {
await myClass.someFunction() // 격리됨
}
myClass.someNonisolatedFunction() // ok
- 메서드, 전체 타입을 그룹핑 -> 안전하게 가변 상태를 공유할 수 있는 동기화된 사일로(synchronized silo)에서 관리
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
}
- globalActor 선언
- ImageDatabse에 @globalActor를 마킹하고 shared 프로퍼티 추가
- ImageLoader actor
- 서버에서 가져오지 않은 이미지를 자동으로 가져온다.
- DiskStorage class
- 파일을 읽고, 쓰고, 삭제하는 작업을 처리하는 클래스
- 파일 입출력 작업을 캡슐화하여 액터 내부에서 파일 작업 코드를 작성할 필요 없음
- storedImageIndex
- 디스크에 저장된 이미지 파일 목록을 인덱스로 관리
- 이미 디스크에 있는 파일을 불필요하게 다시 가져오는 것을 방지
Creating a Safe Silo
위 코드에서 ImageLoader와 DiskStorage 두 가지 의존성을 도입했다.
위 두 인스턴스는 동시성 문제에서 안전할까?
- 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의 코드가 ImageDatabase의 Serial 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
}
}
}
'iOS > Concurrency' 카테고리의 다른 글
Concurrency (10) Actors in a Distributed System (0) | 2024.10.20 |
---|---|
Concurrency (8) Getting Started With Actors (0) | 2024.09.29 |
Concurrency (7) Concurrent Code With TaskGroup (0) | 2024.09.15 |
Concurrency (6) Testing Asynchronous Code (1) | 2024.09.08 |
Concurrency (5) Intermediate async/await & Checked Continuation (0) | 2024.09.01 |