이전글 - Concurrency(7) Concurrent Code With Task Group
Modern Concurrency in Swift를 읽고 간단 정리
이전 챕터에서 TaskGroup, ThrowingTaskGroup API를 사용해 작업을 병렬로 실행하여, 여러 스레드와 CPU 코어를 사용할 수 있도록 했다.
TaskGroup의 설계는 작업을 병렬로 실행하면서도, 그룹을 비동기 시퀀스로 순회함으로써 안전하고 직렬적인 방식으로 실행 결과를 수집할 수 있도록 해준다.
이전 장에서 공유 상태 변경 시에 유의점에 대해 언급했는데, 이는 동시성 프로그래밍의 어려운 측면 중 하나이다.
스레드가 동시에 동일한 메모리 영역에 접근할 때 신중한 제어가 필요하다.
이번 챕터에서는 이를 다룰 수 있는 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 사용]
- 하나의 스레드가 공유 리소스에 접근하는 동안 다른 스레드가 접근하지 못하도록 자원에 대한 접근을 잠근다.
- 그 스레드가 리소스를 풀어줄 때까지 기다려야 한다.
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의 상태를 동시 접근으로부터 보호
actor Counter {
private var count = 0
// 클래스와 달리 한 번에 하나만 실행이 보장
// count의 독점적인 변경이 보장
func increment() {
count += 1
}
}
- 프로토콜의 요구사항은 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에 접근할 때는 자동으로 비동기 처리되며, actor의 serial 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()
}
}
...
}
위 코드는 아래의 내용을 갖는다.
- 비동기 태스크 그룹을 생성
- 이 그룹 안에서 여러 비동기 작업을 수행할 수 있다.
- 작업 중 에러가 발생하면 그 에러를 재전달(re-throw)한다.
- 이미지 리스트를 순회하며, 각 파일에 대해 새로운 태스크를 생성하여 검증 작업을 수행
- 태스크 그룹에 새로운 비동기 작업을 추가
- unowned self 캡처를 사용해 메모리 순환 참조를 방지
- 해당 파일의 체크섬을 검증하며, 잘못된 체크섬이 있을 경우 에러를 발생시킵니다.
- 검증에 성공했을 때 verifiedCount를 증가
- 이 부분은 race condition 문제가 있을 수 있다
- 그룹 내의 모든 태스크가 완료될 때까지 대기하며, 에러가 발생하면 이를 재전달(re-throw)
- 태스크 그룹은 암시적으로 함수 반환 이전에 모든 태스크의 완료를 한다.
- 하지만, 그룹 클로저 내부에 try 구문이 없다면, 컴파일러가 이를 non-throwing 그룹으로 결정하고, 에러를 재전달 하지 않는다.
- 따라서 try await group.waitForAll()를 명시하여 throwing task group임을 컴파일러에게 알린다.
Detecting Race Conditions
경쟁 상태를 검증하는 방법 중 하나는 프로젝트 스킴의 Thread Sanitizer 설정이다.
Using Actors to Protect Shared Mutable State
위에서 살펴본 verfiedCount를 동시성 접근(concurrent access)으로부터 보호하기 위해
EmojiArtModel을 actor로 변환한다.
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
사실 EmojiArtModel의 imageFeed는 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
- 스레드-안전한(thread-safe) 타입
- 임의의 동시성 컨텍스트에서 공유될 수 있으면서도 데이터 레이스의 위험이 없음
- actor도 Sendable을 준수
[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 메서드가 아닌 일반 클래스 메서드처럼 동작한다.
- 동시성 안전성 검사를 거치지 않으므로 성능이 개선될 수 있다.
'iOS > Concurrency' 카테고리의 다른 글
Concurrency (10) Actors in a Distributed System (0) | 2024.10.20 |
---|---|
Concurrency (9) Global Actors (2) | 2024.10.13 |
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 |