- 이 글은 해당 링크를 보고, 개인적으로 정리한 글입니다.(자세한 내용은 아래 링크로 ㄱㄱ)
비동기란?
- 요청과 응답이 동시에 이루어지지 않는 것
- 따라서 요청에 대한 응답이 오지 않아도, 다음 작업을 수행할 수 있음
- iOS에서는 비동기 작업을 "나중에 알 수 없는 시간에 호출되는 코드"로 설명하고 있음
- 즉, 비동기 작업은 시스템에 의해 저장되었다가, 나중에 적절한 시점에 호출되는 것
CompletionHandler
- 비동기 작업은 알 수 없는 시간에 호출됨
- 따라서 해당 작업의 완료 시점을 알기 위해 completionHandler라는 클로저를 사용했음
- 하지만 가독성, 안정성 등의 문제가 있음
func performAsyncTask(completionHandler: @escaping (String) -> Void) {
DispatchQueue.global().async {
let result = "작업 완료"
DispatchQueue.main.async {
completionHandler(result) // 비동기 작업 완료 후, 호출
}
}
}
performAsyncTask { result in
print(result) // "작업 완료"
}
async & await 등장
- 그래서 등장한 것이 바로 async & await임
func performAsyncTask() async -> String {
return "작업 완료"
}
Task {
let result = await performAsyncTask() // 비동기 작업 호출
print(result) // "작업 완료"
}
- 위 코드처럼 비동기 작업의 코드를 동기적인 코드를 만들어줌
- performAsyncTask라는 비동기 작업이 완료된 이후, 출력이 이루어짐.
async & await 사용
- 함수에 async라는 키워드를 붙이면, 비동기로 작동됨
- 그리고 async 함수를 호출하기 위해서는 await라는 키워드를 통해 호출해야 함
let result = await performAsyncTask()
- await으로 마킹된 곳은 "potential suspension point (잠재적인 일시 중단 지점)" 으로 지정됨
potential suspension point
- 해당 스레드에 대한 제어권을 포기한다는 것
- 원래 코드를 돌리고 있는 스레드에 대한 제어권을 시스템에게 맡기고, 시스템은 해당 스레드를 사용하여 다른 작업을 수행함
- 따라서 await로 인한 중단은 해당 스레드에서 다른 코드의 실행을 막지 않는 것임
- 스레드 제어권을 시스템에게 넘기면서, 원래 async 작업에 대한 스케줄링도 시스템에게 넘어감
- 그리고 시스템이 일시 중단된 비동기 작업을 재개해야겠다고 판단하는 순간, 함수의 호출을 위해 스레드를 할당함
- 근데 호출 전의 스레드와 할당된 스레드가 동일하다는 보장은 없음
- 이것이 await 통한 비동기 작업의 원리
주의할 점
- await 키워드를 통해 코드 블럭이 하나의 트랜잭션으로 처리되지 않을 수 있음
- 함수는 중단되고, 다른 것들이 먼저 실행될 수 있기 때문에 이 중단 기간 동안 앱의 상태가 변할 수 있음
정리
- async는 함수가 비동기로 동작하게 해줌
- 이를 호출하기 위해 await이 필요
- await의 동작 원리는 다음과 같음
- 실행하고 있는 스레드의 제어권을 시스템에게 넘김
- 따라서 시스템은 해당 스레드를 통해 다른 작업을 수행할 수 있음
- 반면, 스레드 제어권을 넘기면서 동시에 비동기 작업에 대한 스케줄링도 넘어감
- 시스템은 적절한 시기에 비동기 작업을 호출함
- 비동기 작업이 중단되는 기간 동안 앱의 상태가 변할 수 있다는 것을 인지해야 함
왜 이런 동작을 만들었지?
- 기존 GCD와 비교하여 어떤 점이 나아졌는지 확인해보자.
GCD
- 작업이 queue에 들어오면, 스레드를 생성하여 해당 작업을 수행함
- Concurrent 큐는 여러 스레드를 생성하여 작업을 처리함
- 하지만 이 경우, CPU 코어가 포화될 때까지 스레드를 생성할 수 있음
- 예를 들어, 듀얼 코어의 상황을 가정할 경우
- 특정 이유로 스레드가 차단된 상태에서 Concurrent 큐에서 수행해야 할 작업이 많은 경우,
- 나머지 작업을 처리하기 위해 스레드를 더 많이 불러옴
스레드를 계속 생성하는 이유
1. CPU 효율성 유지
- CPU 코어는 항상 작업을 하고 있어야 효율적임.
- 따라서 추가 스레드를 생성하여 다른 작업을 계속 코어가 맡을 수 있도록 함
2. 차단 해제 가속화
- 차단된 스레드는 세마포어와 같은 특정 리소스를 기다리고 있을 수 있음
- 이 때, 새로운 스레드를 생성하여 작업을 처리함으로써 스레드의 차단이 빠르게 해제될 수 있도록 도움
GCD 방식의 단점
- 앱의 스레드가 많다는 것은 시스템이 CPU 코어보다 더 많은 스레드를 가진다는 것을 의미 (overcommitted)
- 만약 6개의 CPU 코어가 100개의 스레드를 가진다면, 코어보다 약 16배나 많은 스레드로 아이폰을 overcommitted 했다는 의미임
- 이러한 현상을 thread explosion 이라 부름
- 그리고 thread explosion은 데드락을 포함해 메모리 및 스케줄링 오버헤드를 야기함
메모리 오버헤드란?
- 스레드는 스택 및 스레드 추적을 위한 커널 데이터 구조를 가지고 있고, 일부는 실행 중인 다른 스레드에 필요한 lock을 가지고 있을 수도 있음.
- 즉, 스레드는 메모리와 리소스를 보유하고 있음. 따라서 스레드가 많이 차단되는 것은 그만큼 메모리와 리소스를 차단하고 있다는 것을 의미.
스케줄링 오버헤드
- CPU는 이전 스레드에서 새 스레드로 전환하기 위해 전체 스레드 컨텍스트 스위치를 수행해야 함
- 근데 스레드의 수가 많다면? 과도한 컨텍스트 스위치가 발생한다는 것임
새로운 Swift Concurrency의 등장
- 위에서 보았듯이, 기존 GCD의 Concurrent 처리 방식은 성능 상 오버헤드를 가져올 수 있음
- 따라서 새로운 개념을 도입함
- 앱의 실행을 스레드와 컨텍스트 스위치가 많은 이전 모델 -> 스레드 간 컨텍스트 스위치가 없고, contiuation으로 구성된 모델로 변경
- 새로워진 동시성 개념에서는 최대로 실행되는 스레드 개수가 코어 개수와 동일하고, 스레드 간의 컨텍스트 스위치가 없음
- 스레드의 차단이 모두 사라지고 대신, 작업의 재개를 추적하는 continuation으로 알려진 가벼운 객체가 생겨남
- 따라서 컨텍스트 스위치를 하지 않고, 같은 스레드 내에서 continuation을 전환함
- 이는 함수 호출 비용만 감당하면 되는 장점이 있음
Swift Concurrency의 지향
- CPU 코어의 수만큼만 스레드 만들자
- 스레드 차단하지말고, 전환하는 방식 사용하자
- 이를 통해 동시성을 효율적으로 제공함
async 호출
- 위에서 async 함수를 호출하기 위해서는 await 키워드를 사용해야 한다고 했음
- 근데 위 코드를 보면 await을 사용함에도 불구하고, 에러가 발생함
- 이는 async 함수를 호출하기 위해서는 해당 함수를 또 async로 만들어줘야 한다는 것임
- 이건 무한 async 키워드 붙이기와 같음
Task의 등장
- async 함수는 concurrent context(동시 컨텍스트)에서만 실행이 가능함.
- Task { }는 수동으로 동시 컨텍스트를 제공함
- 그래서 async 함수를 Task 블럭 내에서 호출할 수 있음
Task란?
- 비동기 작업 단위
print("1")
Task {
print("2") // 비동기 작업
}
print("3")
- 위 코드처럼 Task 블럭 내의 작업은 비동기로 동작함
- Task 내에서 비동기 함수를 호출하는 것과, DispatchQueue.global().async { } 를 호출하는 것과 매우 유사함
- 즉, 각 Task는 임의의 스레드에서 다른 실행 맥락과 함께 동시에 실행됨.
- 위 그림과 같은 여러 개의 Task는 Concurrent code의 기본 작업 단위임
- 즉, 코드를 병렬로 실행하는 기본 메커니즘으로, 각 Task는 다른 Task와 동시(concurrent)에 작업을 실행할 수 있음 (하나의 Task 자체가 Concurrency를 의미하는 것은 아님)
- 여러 개의 Task를 만들면, 동시에 여러 작업을 수행할 수 있음.
- 그리고 각 Task는 독립적으로 자신의 작업을 수행함
Task의 속성
1. Task 블럭 내의 작업은 비동기로 실행됨. -> async 함수를 호출할 수 있음
2. Task 블럭 내의 작업은 처음부터 끝까지 순차적으로 실행됨.
// 순차적 실행
Task {
let apple = await catchApple() // 1
let banana = await catchBanana() // 2
print(apple + banana) // 3
}
- await를 만나면, 작업이 중단될 수는 있지만, 실행 순서가 변경되지는 않음
3. 각 Task는 독립적임. 즉, 다른 Task와 Concurrent하게 작업할 수 있음
Task의 자원 공유
- Task는 독립적인데, 서로의 데이터를 공유하고 싶을 경우 어케 될까?
func harvestBananas() async -> [String] {
return ["블루 바나나", "레드 바나나", "옐로 바나나"]
}
Task {
let bananaPicking = Task { () -> String in
let bananas = await harvestBananas()
return bananas.randomElement()! // Task가 반환하는 값
}
let banana = await bananaPicking.value // Task가 값을 반환하기를 기다림
print("선택된 바나나: \(banana)")
}
- banana가 값 타입이라면, 해당 값을 복사해주고 작업이 종료됨
- 따로 문제가 없음
참조 타입을 공유한다면?
- 각 Task는 독립적이기 때문에 Concurrent하게 작업을 하지만, 동일한 참조 타입을 참조하는 경우, 공유 자원으로 인한 Data Race 상황이 발생할 수 있음
- 값 타입을 공유할 경우와 참조 타입을 공유할 경우, Thread-safe하지 않다는 것을 컴파일러를 통해 알 수 있을까?
- 그것을 위해 "Concurrent하게 사용해도 안전한 타입"을 지칭하는 Sendable 프로토콜이 등장
Sendable
- Data race를 생성하지 않고, 서로 다른 격리 도메인 간에 안전하게 공유할 수 있는 타입
- 즉, 복사를 통해 값을 동시성 도메인에 안전하게 전달할 수 있는 타입
- 위 Task의 정의를 보면, Success 반환 타입이 Sendable을 준수해야 한다는 것을 볼 수 있음
Sendable 준수 가능 리스트
- 값 타입
- mutabel storage가 없는 참조 타입
- 내부적으로 상태에 대한 액세스를 관리하는 참조 타입
- @Sendable로 표시된 함수 및 클로저
Actor 등장
정리해보면
- 각 Task는 독립적이고, Task 내부 작업은 비동기적으로 수행됨
- Task의 Success 반환 타입이 Sendable 프로토콜을 준수하고 있음
- Sendable 프로토콜은 서로 다른 독립적인 작업에서 안전하게 값을 전달할 수 있는지를 나타내는 타입임
- 값 타입과 같은 Senable을 준수하는 타입은 Task 간의 독립적인 작업에서 데이터를 주고 받아도 문제가 발생하지 않음
- 하지만 참조 타입은 Data Race가 발생할 수 있음
- 그럼 Data Race를 발생시키지 않으면서, Task 간에 안전하게 mutable한 데이터를 공유할 방법은 없을까?
- 그래서 나온 것인 Actor
Actor
- Actor는 공유 데이터에 접근해야 하는 여러 Task를 조정함
- 외부로부터 데이터를 격리하고, 한 번에 하나의 Task만 내부 상태를 조작하도록 허용함으로써 동시 변경으로 인한 Data Race를 방지함
Actor의 특성
- 공유되는 가변 상태를 표현하기 위함
- 따라서 class와 같은 참조 타입이고, actor의 인스턴스에 하나 이상의 참조가 걸릴 수 있음
- 하지만 상속을 지원하지 않음
- 동시성 작업에서 값을 안전하게 주고 받을 수 있기 때문에 암시적으로 Sendable함
Actor는 어떻게 Data Race를 방지하면서, Task 간 데이터 공유가 가능할까?
- Data Race가 발생하는 이유는 두 개 이상의 스레드가 하나의 공유 자원에 접근하여, 쓰기 작업을 수행하는 것임
- 만약 여러 곳에서 동시에 접근하는 상황을 방지한다면, Data Race도 방지할 수 있음
- Actor의 역할 중 하나가 바로 동시에 하나의 Task만 접근할 수 있도록 하는 것임
- Actor는 인스턴스 데이터를 프로그램의 나머지 부분에서 격리하고, 해당 데이터에 대한 동기화된 액세스를 보장함
Actor의 사용
actor SharedWallet {
let name = "공금 지갑"
var amount: Int = 0
init(amount: Int) {
self.amount = amount
}
func spendMoney(ammount: Int) {
self.amount -= ammount
}
}
- actor를 정의
Task {
let wallet = SharedWallet(amount: 10000)
let name = wallet.name // 1. 상수는 변경 불가능하기 때문에 어느 스레드에서 접근해도 안전함. actor 외부에서도 바로 접근 가능
let amount = await wallet.amount // 2. actor 외부에서 변수 접근시 await 필요
await wallet.spendMoney(ammount: 100) // 3. actor 외부에서 메서드 호출시 await 필요
await wallet.amount += 100 // 4. ❌ 컴파일 에러. actor 외부에서 actor 내부의 변수를 변경할 수 없음
}
- 위 코드로 보아 actor의 특징을 살펴볼 수 있음
- actor의 프로퍼티 변경은 actor 내부에서만 가능함
- actor는 언제 접근이 허용되는지 확실하지 않기 때문에 변경 가능한 데이터에 대해 비동기 액세스를 생성함
- 다른 Task들이 actor에 접근하여 코드를 실행하고 있으면, 해당 actor에 대한 코드는 실행되지 못하고 기다려야 함
- 따라서 await 키워드를 사용하는 것임
Actor Serialization
- actor에 대한 접근은 Serial로 동작함
- 따라서 서로 다른 Task에서 actor에 대한 작업을 수행하려고 할 때, 순차적으로 수행됨
- 즉, 다른 호출이 시작되기 전에, 진행되고 있던 호출을 완료하도록 보장함
- 어떻게? -> isolation 개념을 통해
Actor isolation
- 우리가 사용하는 자원이 immutable하다면 Actor를 사용할 이유가 없음
- 하지만 mutable한 자원을 Data Race없이 안전하게 사용하기 위해 Actor를 사용함
- 그 방법이 위에서 나왔던 Actor의 동기화된 액세스 접근이었음.
- 이것이 isolation 개념임
또 다른 예시
actor BankAccount {
let accountNumber: Int
var balance: Double
init(accountNumber: Int, balance: Double) {
self.accountNumber = accountNumber
self.balance = balance
}
}
- 위와 같이 Actor를 정의했음
extension BankAccount {
enum BankError: Error {
case insufficientFunds
}
func transfer(amount: Double, to other: BankAccount) throws {
if amount > self.balance {
throw BankError.insufficientFunds
}
print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")
self.balance = balance - amount
other.balance = other.balance + amount // Actor-isolated property 'balance' can not be mutated on a non-isolated actor instance
}
}
- BankAccount를 extension하여 transfer() 메서드를 구현
- transfer() 메서드는 balance를 업데이트하는 역할을 수행함
- 내부 코드를 보면, other라는 다른 Actor 인스턴스인 프로퍼티 balance에 접근을 하게 되면 컴파일 에러가 발생함
만약 BankAccount가 class 타입이었다면?
- transfer() 메서드를 통한 balance 업데이트에 대해 컴파일 에러가 발생하지 않았을 것임
- 하지만, 참조타입이기 때문에 동시에 여러곳에서 balance의 업데이트를 시도했다면, Data Race가 발생했을 것임.
그래서?
- BankAccount는 Actor 타입이기 때문에 Data Race가 발생할 수 있는 other.balance에 대한 접근을 컴파일 에러를 발생시킴
- 이는 balance라는 프로퍼티가 해당 Actor 인스턴스에 의해 격리(isolated)되었기 때문임
- 이것이 Actor가 동기화된 접근을 만드는 비법임.
- 먼저 isolated가 무엇인지 알아보자.
Actor - isolated 요소
- Actor 내에서 생성된
- 저장, 계산 프로퍼티
- 메서드
- 서브스크립트는 모두 isolated 상태
- isolated 상태의 데이터에 접근 및 쓰기 작업은 해당 객체(self) 내에서만 사용이 가능함
- 따라서 예시 코드에서 발생한 컴파일 에러의 원인은 actor의 격리된(isolated) 프로퍼티인 balance에 외부 객체가 접근함으로써 발생하는 것임
그럼 외부에서 Actor 객체 내의 접근을 어케함?
actor 외부에서 isolated 데이터에 접근하는 것을 cross-actor reference 라고 함
그리고 이 참조는 크게 두 가지로 허용이 된다
- 불변 상태에 대한 참조
- 비동기 함수 호출
1. 불변 상태에 대한 참조
- 위 코드에서 other.accountNumber에 접근했을 때는 에러가 발생하지 않음
- 이 이유가 바로 accountNumber는 let으로 선언되었기 때문에 불변 상태에 대한 참조임
- let이기 때문에 데이터 레이스가 발생할 이유가 없고 -> isolated 상태일 필요가 없고 -> 외부에서 자유롭게 접근이 가능한 것
2. 비동기 함수 호출
- 이 내용은 위에서 알아보았듯이, Actor의 접근을 위해서는 await 키워드를 사용해야 함.
- 이것은 Actor의 접근이 Serial하게 동작하게 만들어줌.
- 더 자세하게 말해보면,
- 비동기 함수 호출은 actor가 안전하게 수행할 수 있을 때, 작업을 실행하도록 요청하는 메시지로 바뀜
- 이 메시지는 actor의 메일 박스에 저장됨
- actor는 메일 박스의 메시지를 순차적으로 처리함
- 즉, 비동기 함수 호출을 시작한 호출자는 메일 박스에서 메시지를 처리할 수 있을 때까지 일시 중단될 수 있음
- 궁극적으로 한 번에 하나의 접근을 하게 함으로써 Data Race를 방지함
- 따라서 actor의 isolated 데이터에 대한 접근을 허용하는 하나의 방법은 await 키워드를 통해 비동기 함수 호출 형식으로 바꾸는 것
정리
- Actor의 존재 이유는 Data Race가 발생할 수 있는 상황에서 안전하게 자원을 공유하기 위해서임.
- Actor가 공유 자원에 대한 Data Race를 방지할 수 있는 방법은 isolated 개념임
- Actor 내의 프로퍼티, 메서드, 서브스크립트는 isolated 상태를 가짐
- 이는 외부로부터 내부 자원을 동기적으로 사용할 수 있도록 하기 위해서임. 따라서 외부에서는 isolated 상태의 데이터에 접근을 못함
- 접근 할 수 있는 방법은 두 가지임
- 불변 상태에 대한 참조
- 비동기 함수 호출
- 이를 통해 Actor는 공유 자원에 대한 동시적 접근을 Serial하게 만들고, 궁극적으로 Data Race를 방지함
Actor non-isolation
isolated 한계 1
actor BankAccount {
let accountNumber: Int
var balance: Double
init(accountNumber: Int, initialDeposit: Double) {
self.accountNumber = accountNumber
self.balance = initialDeposit
}
func transfer(amount: Double, to account: BankAccount) {
assert(amount >= 0)
// 컴파일 에러: Actor-isolated property 'balance' cannot be referenced from a non-isolated context
account.balance += amount
}
}
- transfer() 메서드는 BankAccount 간의 돈을 이체하려고 함
- 위에서 얘기했지만, 외부에서 isolated 프로퍼티에 접근하는 것은 컴파일 에러를 발생시킴
- 이는 actor 간의 접근을 불편하게 만들기도 함.
- 컴파일 에러를 제거하기 위해서는 await 키워드를 사용해야 하고, async 메서드로 만들어줘야 함.
- 또한 이를 호출하는 쪽에서도 await이 필요함.
- 즉, 귀찮고, 복잡하다 이거임
isolated 한계 2
actor BankAccount {
var balance: Double
let accountName: String
init(accountName: String, initialDeposit: Double) {
self.accountName = accountName
self.balance = initialDeposit
}
var accountSummary: String {
return "Account: \(accountName), Balance: \(balance)"
}
}
let account = BankAccount(accountName: "jiho", initialDeposit: 0)
account.accountSummary // Actor-isolated property 'accountSummary' can not be referenced from a non-isolated context
- 위 코드에서도 accountSummary라는 계산 프로퍼티에 접근하려고 하지만 에러가 발생함
- 이를 위해서는 또 await와 async 혹은 Task를 통한 동시 문맥이 필요함.
- 귀찮음
계산 프로퍼티는 어차피 데이터 레이스의 발생 이유가 없을 것 같은데 왜 isolated 상태로 만들까? (논외)
- 그냥 궁금해서 생각해봄.
- 구현된 계산 프로퍼티 자체는 read-only로, 수정이 불가능함
- 하지만, 내부적으로 isolated된 프로퍼티를 참조하고 있음
- 따라서 계산 프로퍼티에 접근할 때, 참조하고 있는 isolated 프로퍼티에 데이터 레이스가 발생할 수 있기 때문에 정확한 데이터를 읽어오기 위해 isolated로 막아두는 것
isolated 한계 3
- Actor는 Hashable 프로토콜의 준수가 불가능함. (non-isolated 개념을 사용하면 가능)
- Actor의 인스턴스 메서드는 기본적으로 isolated 함.
- 따라서 외부에서 호출하기 위해서는 await이 필요하죠?
- 우리가 Hashable 프로토콜을 준수하기 위해서는 hash(into:) 메서드를 구현해줘야 합니다.
- 그리고 hash(into:) 메서드를 구현하기 위해서는 Actor 내부의 여러 속성에 접근해야 함
- 접근하기 위해서는 비동기적 접근인 await 키워드가 필요함
- 하지만 hash(into:) 메서드는 비동기가 아니라서 await 키워드 사용이 불가능함.
- 따라서 에러 발생
개선 방안
- isolated로 인해 여러 한계점이 있었음
- 개선 방안은 없을까?
- 고립 상태 파라미터 표시: 함수의 파라미터가 고립된 상태인지 명확히 표시함으로써, 함수가 actor의 고립된 상태에서 어떻게 작동할지를 명확히 할 수 있음
- 고립되지 않은 선언: 함수나 메서드를 actor의 고립 상태에서 벗어나도록 선언하여, 보다 유연하게 외부에서 동기적으로 접근할 수 있도록 함
1. 고립 상태 파라미터 표시 (isolated)
actor BankAccount {
let accountNumber: Int
var balance: Double
init(accountNumber: Int, initialDeposit: Double) {
self.accountNumber = accountNumber
self.balance = initialDeposit
}
func transfer(amount: Double, to account: BankAccount) {
assert(amount >= 0)
// 컴파일 에러: Actor-isolated property 'balance' cannot be referenced from a non-isolated context
account.balance += amount
}
}
- 한계 1번에서는 위와 같은 문제가 있었음.
- balance는 actor에 의해 격리된 상태이기 때문에 동기적인(non-isolated) 문맥에서 격리된 프로퍼티를 참조할 수 없다는 것임.
- 함수의 파라미터에 어떤 것이 고립된 상태인지를 명확히 표시함으로써 문제를 해결할 수 있음
func transfer(amount: Double, to account: isolated BankAccount) {
assert(amount >= 0)
// 'account' 파라미터가 고립된 상태로 취급됨
account.balance += amount
}
- isolated 키워드를 사용함으로써, 해당 파라미터를 고립된 상태로 처리함.
- 이는 비동기 접근이 필요한 actor의 격리된 프로퍼티도 동기적으로 접근할 수 있도록 만들어줌
- 즉, isolated로 지정된 파라미터는 동기적 문맥(non-isolated context)에서도 안전하게 접근이 가능해짐
2. 고립되지 않은 선언(nonisolated)
actor BankAccount {
var balance: Double
let accountName: String
init(accountName: String, initialDeposit: Double) {
self.accountName = accountName
self.balance = initialDeposit
}
nonisolated var accountSummary: String {
return "Account: \(accountName), Balance: \(balance)" // Actor-isolated property 'balance' can not be referenced from a non-isolated context
}
}
let account = BankAccount(accountName: "jiho", initialDeposit: 0)
print(account.accountSummary) // async/await 없이도 작동
- 위 한계 2번 코드에서 계산 프로퍼티(accountSummary)는 isolated 상태이기 때문에 외부에서 접근하면 컴파일 에러가 발생했었음
- nonisolated로 선언된 프로퍼티는 이제 actor의 격리 컨텍스트 외부에서도 접근이 가능해짐. 즉, await 키워드를 통한 비동기 접근없이도 동기적으로 접근이 가능해짐
nonisolated 특징
- nonisolated 선언을 통해 동기적 접근이 가능하도록 한다는 것은 해당 프로퍼티 혹은 메서드가 사용됨에 있어 data race의 문제를 발생시키지 않는다는 확신이 있어야 함
- accountSummary 내부에서 balance에 접근하는 코드를 보면, 또 다시 컴파일 에러가 발생하고 있음
- 이는 balance 프로퍼티는 data race가 발생할 수 있고, 그 때문에 isolated 상태이기 때문임.
그럼 balance 프로퍼티에 nonisolated 키워드를 또 붙이면 되겠네?
nonisolated var balance: Double // 'nonisolated' can not be applied to stored properties
- nonisolated는 저장 프로퍼티를 지원하지 않음
- 위에서 말했듯이 nonisolated는 data race를 발생시키지 않을 확신이 있는 코드에만 적용할 수 있음
- 근데 balance와 같은 저장 프로퍼티는 Thread-safe하지 않음
- 따라서 사용 불가!
해결 방법은?
nonisolated func getAccountSummary() async -> String {
let currentBalance = await balance // 비동기 접근을 통해 안전하게 접근
return "Account: \(accountName), Balance: \(currentBalance)"
}
- nonisolated로 선언된 계산 프로퍼티 내에서 balance에 접근하는 것은 불가능
- 메서드를 nonisolated로 선언하고, 메서드 내부에서는 data race가 발생하지 않도록 구현해야 함
정리하자면,
- nonisolated는 data race를 발생시키지 않을 확신이 있는 코드에만 적용 가능
- 저장 프로퍼티에는 nonisolated 키워드 사용 불가
Hashable 준수 가능
extension BankAccount: Hashable {
func hash(into hasher: inout Hasher) { } // Actor-isolated instance method 'hash(into:)' cannot be used to satisfy nonisolated protocol requirement
}
- 한계3에서도 보았듯이, actor 내부에 정의된 자원들은 isolated 상태임.
- hash(into:) 메서드를 구현하기 위해서는 내부 자원에 접근함에 있어 await이 필요함.
- 그럼 hash(into:) 메서드는 async 함수로 만들어 비동기적으로 호출되어야 함.
- 하지만 hash(into:) 메서드는 프로토콜의 요구사항으로, 이를 준수하기 위해서는 외부에서 동기적으로 호출되어야 함
- 따라서 에러가 발생했음
해결 방법은?
- hash(into:) 메서드를 nonisolated로 선언하면, actor 격리에서 벗어나 외부에서 동기적으로 호출될 수 있음
- 하지만 nonisolated로 인해 hash 메서드가 data race로부터 안전하다는 확신이 필요함
- 따라서 acotr의 내부 isolated 자원에 접근이 불가할 수 있음
Actor의 재진입
- 재진입이란, actor가 비동기적으로 작업을 수행하는 도중에, 다른 작업을 받아들여 처리할 수 있는 것을 말함
- 예를 들어, 비동기 작업이 실행되고 있는 동안, 다른 작업이 actor로 들어오면, 새로운 작업은 기존의 작업이 완료될 때까지 기다리지 않고 끼어들어 실행이 가능함
Actor의 기본 동작은 Serial 하다고 했자나, 근데 어떻게 재진입이 가능하다는거야?
- actor에 대한 접근은 한 번에 하나의 작업만 수행될 수 있도록 보장함
- 재진입은 actor가 비동기적인 코드를 실행 중일 경우, 자신이 처리하고 있는 작업을 잠시 중단하고, 다른 작업을 받아들여 처리할 수 있다는 것을 말함.
재진입의 동작
- await 키워드를 사용하면, 잠재적인 중단 지점으로 지정되어, 해당 작업에 대한 스케줄링은 시스템에게 넘어감.
- 해당 비동기 작업이 대기 상태에 이르는 동안, 다른 작업이 actor에 들어올 수 있음
- 예를 들어, actor가 외부에서 데이터를 받아오기 위해 await로 비동기 작업을 호출하고 대기하는 동안, actor는 그 시간을 활용해 다른 작업을 처리할 수 있음
따라서 두 가지 작업이 동시에 이루어지는 것이 아니라, 기존 작업이 대기되는 동안, 새로운 작업을 받아서 실행할 수 있다는 것임. 이것이 Actor의 재진입 개념
재진입이 왜 필요한지?
1. 처리 효율성 향상
- await으로 인한 중단 작업의 대기 시간 동안, 다른 작업을 처리함으로써 효율성 향상
2. 우선순위 역전 방지
- 재진입이 허용되면, 우선순위가 높은 작업이 비동기 작업 대기 중에도 실행될 수 있음. 이를 통해 우선순위가 낮은 작업이 높은 작업보다 우선적으로 처리되는 상황을 줄일 수 있음.
우선 순위 역전이란?
- 다중 스레드 환경에서 발생하는 문제로, 우선순위가 높은 작업이 우선순위가 낮은 작업에 의해 방해를 받아, 실행이 지연되는 현상임.
- 시스템의 응답성을 저하시킬 수 있다!
기존 GCD의 우선 순위 역전
- 큐는 기본적으로 FIFO 구조임. 따라서 먼저 들어온 작업이 먼저 처리됨
- 따라서 우선순위가 낮은 작업이 큐에 들어왔고, 이후 우선순위가 높은 작업이 큐에 들어가도 앞서 큐에 들어온 작업을 먼저 처리하게 됨
- 이것이 GCD에서 발생하는 우선순위 역전 현상이었음
- 초록색이 우선순위가 높은 작업임
- GCD에서 B작업을 실행하기 위해서는 우선순위가 낮은 1~5번 작업을 먼저 실행해야 함.
- 이는 우선순위 역전 현상임.
- 따라서 GCD에서는 우선순위 역전 현상을 완화하기 위해, 앞의 1~5번 작업의 우선순위를 높여버림
- 이를 우선순위 상속이라고 함 (우선순위가 낮은 작업들에 높은 우선순위를 상속하여, 빠르게 작업을 완료)
- 하지만 원초적으로 우선순위 역전을 해결하는 방법은 아님
Actor의 우선순위 역전 완화
- Actor의 재진입은 기존 GCD의 우선순위 역전을 좀 더 근본적으로 완화한다.
- actor 내부에서 비동기 작업이 실행되면서, await 키워드를 만나게 되면, 해당 작업은 잠재적 중단 지점으로 지정됨
- 해당 작업이 대기하는 동안, 우선순위가 높은 작업을 재진입 시킴으로써 먼저 처리할 수 있음
- 즉, 우선순위가 낮은 작업의 대기 시간동안, 우선순위가 높은 작업이 우선 처리됨으로써 역전 현상을 방지함
Actor 사용시 주의할 점
- 재진입으로 인한 상태 변화
- 메시지 병목 현상
재진입으로 인한 상태 변화
- 재진입은 한 작업을 처리하는 도중, 작업이 대기 상태에 이르렀을 때 다른 작업을 실행할 수 있도록 하는 것이라고 했음
- 만약 actor 내에 공유 자원이 있고, 기존 작업과 새로운 작업은 이 공유 자원을 사용한다고 가정해봅시다.
- 기존 작업이 await으로 대기하는 동안, 새로운 작업이 들어옴
- 새로운 작업은 공유 자원에 대한 상태를 수정함
- 다시 기존 작업이 재개되면서, 공유 자원의 변화된 상태를 인지하지 못하고 프로그래밍을 하는 경우, 문제가 발생할 수 있음
- 이렇게 기존 작업이 대기하는 동안, 내부 자원에 대한 상태에 변화가 있음을 유의해야 함
메시지(작업) 병목 현상
- 이는 Actor에게 한번에 너무 많은 작업을 할당했을 때, 발생하는 문제임
- actor는 자신에게 도착한 작업을 Serial하게 처리함.
- 만약 작업이 도착하는 속도가 actor가 작업을 처리하는 속도보다 빠를 경우 작업의 병목 현상이 발생함
- 이는 시스템의 응답성에 문제를 야기하겠죠?
그래서 어케 해결하는데?
- 간단하게 말하면, actor의 자원에 접근해야 하는 경우에만, serial하게 동작될 수 있도록 하는 것임.
- actor는 공유 자원에 대한 data race를 방지할 수 있도록 지원하는 타입임
- 그럼 공유 자원이 필요한 경우에만, actor의 isolated에 보장받을 수 있도록 하고, 이외의 작업에 대해서는 다른 스레드에서 동시적으로 실행될 수 있도록 만드는 것이 효율적임.
- 따라서 해당 메서드를 nonisolated로 만들고, 공유 자원에 접근하는 것에 await을 해주면 분리가 가능함.
- 따라서 작업을 효율적으로 처리함으로써 병목된 작업을 완화할 수 있음.
'Swift' 카테고리의 다른 글
CodingKey에 관하여 (0) | 2023.03.31 |
---|---|
메모리 안전에 관하여 (0) | 2023.03.17 |
Error Handling(에러 처리)에 관하여 (0) | 2023.03.16 |
Where절에 관하여 (0) | 2023.03.15 |
패턴에 관하여 (0) | 2023.03.15 |