본문 바로가기
Swift

Swift Concurrency

by iOS 개린이 2024. 9. 22.

 

비동기란?

  • 요청과 응답이 동시에 이루어지지 않는 것
  • 따라서 요청에 대한 응답이 오지 않아도, 다음 작업을 수행할 수 있음
  • 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