[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/Concurrency & GCD

[Concurrency] Actor

inu 2023. 3. 31. 20:44
반응형

Task 복습

  • Task는 block 내부를 비동기적으로 실행해준다.
print("\\(Thread.current)")
Task {
    print("\\(Thread.current)")
}
print("\\(Thread.current)")

// <_NSMainThread: 0x6000024981c0>{number = 1, name = main}
// <NSThread: 0x60000249c300>{number = 6, name = (null)}// <_NSMainThread: 0x6000024981c0>{number = 1, name = main}
  • 그런만큼 각 Task는 독립적인 동작을 수행한다.
  • 만약 참조타입의 값을 공유할 경우, 데이터에 동시적으로 접근하는 Data Race가 발생할 수 있다.
  • 다만 무조건으로 동시성을 보장하는 것은 아닌데, 부모의 Context를 물려받는 경우 Task를 열어 비동기 컨텍스트를 만들어도 같은 스레드에서 실행될 수 있기 때문이다.

Sendable

  • Sendable은 동시성 도메인에서 사용해도 Data Race를 생성하지 않는 타입에 대한 프로토콜이다.
  • 이를 준수해야 동시성 작업에서도 안전하게 데이터를 주고 받을 수 있다.
  • 하지만 대부분의 Sendable은 '데이터가 독립적으로만 변경됨'이 보장되는 타입이나 마찬가지이기 때문에, 서로 다른 컨텍스트에서 데이터를 공유하고 변경하기를 원한다면 사용할 수 없다.
  • Sendable하면서도 서로 다른 컨텍스트에서 데이터를 공유하고 변경할 수는 없을까?
    • Actor가 답.

Actor

  • Actor는 데이터변경을 허용하면서도 한번에 하나의 컨텍스트만 데이터를 변경할수 있도록해서 Data Race를 방지한다.
  • 또한 참조 타입이기 때문에 여러 컨텍스트에서 공유가 가능하다.
  • Actor의 변수 혹은 메서드를 호출할 때는 await 키워드를 통해 접근에 대한 대기를 걸어야한다.
    • await 키워드를 사용해야하기 때문에 Task 사용이 필수적이다. (일반 코드에서는 스레드에 대한 대기를 처리할 수 없음)
    • Actor에 대한 접근은 직렬화 되어있기 때문에 특정 컨텍스트에서의 접근이 종료된 이후에만 다른 컨텍스트에서의 접근이 이루어진다.
  • 컴파일 단위에서 잠재적 Data Race를 방지하고 이를 사전에 체크할 수 있기 떄문에 편리하다.
  • 다만 Actor에 접근하는 단위는 한번에 하나로 제한하고 있는만큼, 여러개의 컨텍스트에서 한번에 접근하면 그만큼 시간이 오래걸릴 수 있다는 점도 고려해야한다.

Actor isolated, nonisolated

  • 특정 메서드의 파라미터에 isolated 키워드를 붙이면 해당 메서드에 대한 접근 컨텍스트는 Actor에 대한 접근으로 제한된다.
func deposit(amount: Double, to account: isolated BankAccount) {
    assert(amount >= 0)
    account.balance = account.balance + amount
}
  • 위와 같은 메서드가 있다면 해당 메서드를 사용할 때는 await를 통해 BankAccount라는 Actor의 컨텍스트에 대기를 해야 사용이 가능하다.
actor BankAccount {
    let accountNumber: Int
    var balance: Double
    let accountName: String

    nonisolated var displayName: String {
        return self.accountName + "입니다"
    }

    init() {
        ...
    }
}
  • nonisolated 키워드를 붙이면 해당 부분은 Actor의 컨텍스트에서 벗어난다.
  • computed property나 let property, function에 적용이 가능하다.
  • 다만 해당 컨텍스트는 Actor 외부의 컨텍스트와 마찬가지로 취급되기 때문에 변경이 가능한 var property에 접근하면 외부에서 접근할 때와 마찬가지로 Acotr 컨텍스트에 대한 await가 필요하다.
  • nonisolated를 사용하면 Actor 내부의 코드도 필요한 경우 최대한 동시적으로 처리할 수 있다.
    • 서버에서 데이터를 가져와 리턴하는 메서드가 있다고 해보자. 사용자는 이를 여러번 호출해서 여러개의 데이터를 불러와야 한다.
    • 이를 그냥 Actor로 구현하면 접근할 때마다 Actor의 직렬화된 작업목록에서 하나씩 처리되기 때문에 시간이 오래걸린다.
    • 하지만 해당 메서드를 nonisolated로 등록해놓고 필요한 부분에서만 await로 처리해놓으면 해당 작업을 병렬적으로 처리할 수 있어 소요시간을 줄일 수 있다.

Actor reentrancy

  • Actor 내부에서 비동기 메서드를 호출하여 Actor의 실행을 중지한 경우, Actor의 컨텍스트는 다른 곳에서 접근가능한 상태가 된다. 따라서 다른 컨텍스트에서 Actor에 접근하는 것이 허용된다.
  • 이러한 특성을 actor의 reentrancy(재진입성)라고 부른다. 이는 스레드 데드락의 가능성을 제거해준다.
  • 기존 GCD의 Serial Queue에서는 모든 작업이 선입선출로 처리된다. 따라서 우선순위가 낮은 작업이 우선순위가 높은 작업보다 앞에 있을 경우, 우선순위가 낮은 작업들이 모두 처리된 이후에 우선순위가 높은 작업이 처리된다.
    • 다른 queue에서 우선순위가 높은 작업에 대한 역전을 최대한 방지하기 위해 앞에 있는 우선순위가 낮은 작업의 우선순위를 높인다.
    • 하지만 이 방법 역시 앞선 기존 우선순위가 낮았던 작업들이 먼저 처리되어야한다는 문제점은 해결하지 못한다.
  • 하지만 Actor에서는 Actor reentrancy 덕분에 우선순위가 높은 작업을 큐의 제일 앞으로 이동할 수 있다.
  • 물론 Actor 내부에서 await를 사용할 때도 Actor reentrancy로 접근이 가능하다는 것은 그 사이에 내부상태가 변화될 수 있다는 것을 의미한다. 따라서 내부상태에 대한 가정은 예상치 못한 동작을 발생시킬 수 있으니 주의해야 한다.

Main Actor

// Main Actor에서 실행되는 코드
let button = UIButton()
button.setTitle("Start", for: .normal)

// Main Actor에서 실행되지 않는 코드
Task.detached {
    await doSomething()
    await MainActor.run {
        button.setTitle("Done", for: .normal)
    }
}
  • Main Actor를 사용하면 간편하게 현재 컨텍스트에서 벗어나 메인스레드에서 작업이 처리되도록 할 수 있다.
  • 기존의 GCD와 다르게 Actor에 기반되어 구성되어 있기 때문에 동시성 작업을 보다 명시적이고 쉽게 처리할 수 있도록 도와준다.
  • 주의점) Task는 coninuation 객체를 통해 컨텍스트 스위칭 비용을 최소화하였지만, 메인스레드와 협력 스레드풀 간의 전환에서는 컨텍스트 스위칭이 발생한다. 따라서 메인스레드와 협력스레드풀 간의 전환이 잦은 코드를 작성할 경우 이를 최소화하는 방향으로 가는 것이 좋다.

@MainActor

actor MyActor {
    @MainActor
    var count = 0

    @MainActor
    func increment() {
        print("\\(Thread.current)")
        count += 1
    }

    func subIncrement() async {
        print("\\(Thread.current)")
        await increment()
        await print(count)
    }
}

let a = MyActor()

Task {
    await a.subIncrement()
}

// <NSThread: 0x600002fd0440>{number = 5, name = (null)}
// <_NSMainThread: 0x600002fcc1c0>{number = 1, name = main}
// 1
  • 해당 어노테이션을 메서드 혹은 프로퍼티 앞에 붙이면 해당 메서드 혹은 프로퍼티를 실행(접근)할 때 MainActor를 사용하게 된다.
  • 특정 메서드가 MainActor에서 실행되어야 하거나 (UI작업) MainActor와 상호작용해야 한다면 유용하게 사용할 수 있다.
  • 이는 해당 프로퍼티(메서드)의 컨텍스트를 MainActor로 제한하는 것이기 때문에 같은 Actor에서 호출해도 await를 통해 작업에 대한 대기를 해줘야 한다.
  • 출력을 보면 정상적으로 메인 스레드에서 작업이 이루어지고 있음을 알 수 있다.
@MainActor
class A {
    var count = 0

    func increment() {
        print(count)
        print("\\(Thread.current)")
        count += 1
        print(count)
    }
}

Task {
    let a = await A()
    await a.increment()
}

// 0// <_NSMainThread: 0x6000025701c0>{number = 1, name = main}// 1
  • 전체 타입에 @MainActor 어노테이션을 붙이면 해당 타입 내부에 존재하는 프로퍼티 및 메서드가 Main Actor로 격리된다.
    • 인터페이스과 관련된 Cocoa Class들은 이미 모두 @MainActor로 마킹되어 있다.
반응형