본문 바로가기

iOS/Concurrency

[Swift 5.5] actor, actor isolation, cross-actor reference 개념 완벽 뿌수기 | No2. Actor

 
안녕하세요.
저번 포스트에선 actor의 개념과 thread-safe, actor's serial executor에 대해 공부했던 개념을 정리했습니다. 이번 포스트는 actor와 isolated state, Sendable(관련 개념 정리)을 준수하며 actor를 사용하는 방법, actor isolated state, cross-actor reference, mainActor, nonisolated를 공부했던 내용을 정리하려 합니다.
 
지난번 actor에 대한 개념만 다시 간략하게 정리하겠습니다.
 

1. Actor concept

Concurrent domain간 shared mutable state를 access할 때 동시성 문제가 발생할 수 있습니다. multi thread에서 mutable 값이 담긴 메모리에 접근할 때 동시성 문제가 발생하는 코드를 무심코 작성할 수 있습니다. Swift 5.5 concurrent 개념 중에 actor를 사용함으로 이 문제를 해결 할 수 있습니다. actor는 shared mutable state를 보호합니다!
 
actor 내부 영역에 대해 컴파일러는 data race가 발생될 수 있는 코드를 사전에 감지할 수 있습니다. Actor 내부 변수나 함수등의 상태(state)를 고립(isolated)시킵니다. 일반 클래스와 달리 actor 내부의 멤버들은 isolated state(고립된 상태)를 띄게 됩니다.(일반적인 클래스의 멤버와 다릅니다. isolated state이기 때문입니다_actor의 독특한 특징!)
 
그리고 이제부터 actor 내부 멤버(함수 변수 등)을 isolated state로 부를 것입니다.
 
각각의 isolated state가 호출되거나 관리될 때 actor's serial executor를 통해 수행됩니다. Actor executor는 synchronization한 수행을 제공하기 때문에 concurrent domains간 access할 때 mutable state가 thread safe한 연산을 할 수 있습니다. 즉 actor는 self 영역 이외에서 해당 actor's shared mutable state를 access하려 할 때, not isolated-state로 여기고 에러를 발생합니다. (중요한 개념입니다.)
 
actor's mutable state를 access하려면 async한 요청을 통해 특정 actor's serial executor에게 요구해야 합니다.(물론 이 경우에도 actor에 의해 직접적으로 mutating 되는게 아닐 경우 에러가 납니다.) 이 덕에 actor의 내부 isolated state는 actor's serial executor에 의해 안전하게 access가 가능합니다. 그리고 isolated state의 mutating은 actor actor 내부에서, self영역 안에서 실행되야 합니다.
 
동시성 도메인 간 mutable value를 접근해야 할 때, data race가 발생할 수 있을 때, 이를 관리하는 lock, semaphore등의 알고리즘을 사용할 수 있으나 이들이 안전하게 수행되는지는 컴파일러가 체크를 못합니다. actor를 사용하면 컴파일러가 검사해주고, data race도 방지해줍니다. 일석이조!!
 

actor BankAccount {
    let uuid: Int
    var balance: Double
    init(uuid: Int, balance: Double) {
        self.balance = balance
        self.uuid = uuid
    }
}

 
클래스처럼 init, method, peroperties, subscripts, extended 등이 가능합니다. 
 

2. Actor isolation concept

위에서 설명을 했지만 actor의 내부는 class와 struct와 다르게 private, internal 등에 의한 접근 제어에 따른 멤버가 아니라!! 각각의 멤버(프로퍼티, 함수 등)를 isolatetd시킵니다.(차별성 부여). Actor 각각의 멤버들은 isolated된 state를 띄게 됩니다. 이 대신에 actor 내부의 mutable isolated state는 actor's serial executor에 의해서만 처리가 됩니다. 그리고 actror executor는 serial하게 호출된 isolated state를 처리합니다. 대신 mutable state는 actor에 의해서만 처리되기 때문에 data race가 발생하지 않습니다. self영역 이외에서 access될 수 없습니다.
 

Example 1.

extension BankAccount {
	enum BankError: Error {
    	case insufficientFunds
    }
    func checkMyBalance(amount: Double) throws {
    	if amount > balance { throw BankError.insufficientFunds }
    }
}

 
transfer함수를 사용한 BankAccount에서 다른 계좌에 amount만큼을 입금하는 기능을 구현하기 전에 에러타입을 정의합니다.

extension BankAccount {
    func transfer(amount: Double, to other: BankAccount) {
    	try checkMyBalance(amount: amount)
        //1.
        print("Transferring \(amount) from \(uuid) to \(other.uuid)")
        //2.
        self.balance = self.balance - amount
        //3. self.other x. Can't be mutated on a non-isolated actor instance
        other.balance = other.balance + amount
        //4. Error. Actor-isolated property 'balance' can not be mutated on a non-isolated actor instance
        print(other.balance)
        Task() {
            //5.
            print("my balance: \(balance), other balance: \(await other.balance)")
            //6. Error. Actor-isolated property 'balance' can not be mutated from a non-isolated context
            await other.balance = await other.balance + amount
        }
    }
}

 
trasnfer함수의 두 번째 인자값의 타입은 actor. 즉, 다른 actor's 인스턴스가 참조됩니다(Actor's type is reference). 
 
1. other.uuid는 let 타입의 immutable한 값이므로 외부에서 참조할 수 있습니다.
2. 자기 자신 BankAccount 내에서, 해당 actor's isolated state는 자유롭게 사용될 수 있습니다. 이들은 actor's serial executor에 의해 처리됩니다. (actor's serial executor 관련 지난 포스트)
3. 다른 actor's isolated property인 muatable balance를 해당 other actor's serial executor를 통해 mutating하는게 아니라 transfer를 호출한 actor가 access(read,write ...)하려 하기 때문에 문제가 발생합니다. Actor의 주 기능은 mutable state를 유지하며 + Sendalbe 준수, actor 자신만의 serial executor에 의한 제거 수정 등의 관리되야 하는 개념이 핵심입니다. -> concurrent problem 방지.
 
당연히 self.other.balace로 호출할 수 없습니다. 자기 영역 이외의 것이란 말입니다!!.(other 매개변수=외부에서 선언되어 참조된 다른 actor 인스턴스)
 
위에서 언급했지만 actor의 내부 isolated state가 mutating될 때, 해당 actor's serial executor에 의해서만 처리되야 합니다. 현재 transfer를 수행하는 actor를 A라고 부르겠습니다. 원칙대로라면 A의 transfer함수는 actor A내에 있는 isolated state들만 값을 변경할 수 있습니다. 하지만 other actor의 isolated state를 access하려 한다는 말은, 이 코드가 돌아간다는 말은 곧! 여러 thread에서 한개의 BankAccount를 동시에 수행했을 때 동시에 변경할 수 있다는 말이 되고 data race가 발생할 수 있다는 말입니다.(synchronous하게 접근이 안된다는 말 입니다.) 반면 actor내부에 있는 isolated-state들은 자유롭게 access가 가능합니다. 해당 actor's serial executor에 의해 관리 됩니다.
 
4. transfer함수 안에 other.balance 단순 read로 참조하는 것으로도 에러가 납니다. other.balance의 입장에서 transfer함수는 타 actor이므로 non-isolated actor instance로 분류됩니다. 
5. mutable isolated state를 read-only처럼 참조하기 위해서는 asynchronously하게 호출해야 합니다.
6. 그렇다고 await를 써가며 other's isolated state를 변경한다는 것은 또 안됩니다. 이 경우가 허용되는 것이라면 마찬가지로 서로 다른 thread에서 other.balance isolated state인 변수의 값을 동시에 수정할 때 참조한 값이 전부 특정한 값일 경우 최종 결과는 특정한 값+ amount가 되기 때문입니다.-> data race문제. 물론 Task()안에 await 때문에 trasnfer를 수행중인 thread와 또 다른 thread에서 수행되는 context입니다.
 

Actor's feature

아! 여기서 알 수 있는 actor의 정말 신기한 특징이 있습니다. 

  • actor 내부에서 해당 actor's isolated state는 자유롭게 access가 된다.
  • actor 내부 isolated state인 함수에서 다른 actor의 immutable isolated state를 접근할 때는 참조(read-only)된다. (Cross-actor reference)
  • actor 내부 함수에서 다른 actor의 mutable isolated state를 변경할 수 없다. 

 
이 경우 다른 actor에서 non-isolated state로 취급됩니다. 당연하게도 actor 내부 함수의 self는 매개변수의 actor의 isolated state가 아니기 때문입니다.

  • actor 내부 함수에서 다른 actor의 mutable isolated state를 sync하게 참조(read-only)할 수 없다.
  • actor 내부 함수에서 다른 actor의 mutable isolated state를 참조(read-only)하기 위해선 asynchronously하게 접근해야 한다. (Cross-actor reference

 
이 경우 A actor에서 다른 actor's mutable isolated state를 read할 때, 다른 actor입장에선 A actor는 다른 actor의 isolated state가 아니기 때문에 외부 객체로 봅니다.(actor의 특징) 외부 객체는 isolated state를 mutate 할 수 없습니다. 오직 self 범위 내에서만 ( 위 코드 주석 2번처럼) mutating 되야 합니다. 그리고 actor's mutable isolated state를 외부에서 read할 경우 asynchronously하게 접근해야 합니다. 이 점이 정말 신기합니다. 
 
Cf.
Swift 5.5 async/await의 특징은 async 키워드를 부착해야만 await 키워드를 사용할 수 있습니다. 하지만 actor의 또 다른 특징으로 actor executor에 의해 직접 실행 되는 것이 아닐 경우 전부 asynchronously 접근을 해야 합니다. 물론 안 되는 경우가 있습니다. 외부에서 actor's mutable isolated state를 직접 변경할 때입니다.(이 경우 asynchronos한 접근 마저도 안됩니다.)
actor isolated state를 actor 내부 함수에서 access할 땐 자유롭게, 외부에서 access할 땐 asynchronosly 호출을 해야 하는 특징으로 바뀝니다.(access가 mutable isolated state를 직접(actor의 내부함수를 통해서가 아니라 mutate 하려는 경우엔 x) -> (Cross-actor reference
 
 
Actor 인스턴스 생성 후 transfer를 호출할 때는 asynchronous한 환경에서 호출해야 합니다.

//main thread에서 실행!
let myAccount = BankAccount(uuid: 1, balance: 2.0)
let otherAccount = BankAccount(uuid: 2, balance: 3.0)
Task() {
    try await myAccount.transfer(amount: 1.0, to: otherAccount)
}

 
transfer(amount:to:) 코드에서, 가장 큰 기능 중 하나는 다른 계좌에 일정 금액을 입금하는 것입니다. ( 위 위 코드 주석 3번.) 하지만 other actor에서 self의 범위 내에 수행되는 것이 아니기에 transfer를 호출한 actor instance는 non-isolated 취급 받습니다. == 외부인 취급
 
그렇다면 

"외부에서 actor's  isolated state중 mutable state는 값을 수정할 수 없을까?" 

직접 값을 변경할 수는 없습니다. 하지만 해당 actor에 함수를 만들어 특정 actor 내에서 직접 수정할 수 있도록 하는 함수를 추가로 구현하고, asynchronus한 접근으로 호출하면 됩니다.

extension BankAccount {
    ...
    func deposit(amount: Double) {
    	assert(amount >= 0)
    	balance = balance + amount
    }
    func transfer(amount: Double, to other: BankAccount) throws {
        ...
        //Error. other.balance = other.balance + amount
        Task() { await other.deopsit(amount: amount) }
    }
}

 
여기서 알 수 있는 점은 위에서 경우와 비슷합니다. 당연히 주석처럼 해당 코드는 other의 actor영역이 아닌 외부에서 isolated state를 mutating하려 하는 경우이고 당연히 오류가 납니다.
 
other.deposit(amont: amount). 외부에서 isolated state를 호출 할 때는  actor's isoated state가 asynchronous한 특성을 띄게 됩니다. 그래서 async task 환경에서 await 키워드를 통해 호출해야 합니다.
 

Another really simple example :]

actor Count {
    let uuid: String //Immutable, Sendable, value type
    var value: Int //mutable, Sendalbe type
    init(uuid: String, value: Int) { self.uuid = uuid; self.value = value}
}

 
이 인스턴스를 선언한 후에 value 값을 1 증가시킬 것입니다.

...
override func viewDidLoad() {
    //main actor관여. main therad에 의해 top-down으로 코드가 수행됩니다.
    let obj = Count(value:0)
    //Error. Actor-isolated property 'value' can not be mutated from the main actor
    obj.value += 1
}

 
하지만 수행되지 않습니다. 위의 BankAccount와 똑같은 예입니다. 역시나 obj actor의 self 영역 외, main actor(새로운 개념 등장!!) 영역에서 access가 되기 때문에 에러가 납니다. 해결 방법이 있을까요?
 
...
 
obj한테 직접 mutating 하도록 맡기면 됩니다. 그리고 외부 영역인 main actor는 obj의 isolated state를 asynchronously access하면 됩니다.

extension Count {
    func plus() async {
        value += 1
    }
    func add(_ num: Int) {
        value += num
    }
}

 
사실 여기서 plus는 async 키워드를, add는 async를 붙이지 않았습니다.

...
override func viewDidLoad() {
    //main actor관여. main therad에 의해 top-down으로 코드가 수행됩니다.
    let obj = Count(uuid: "방문자 수", value:0)
    Task() { 
        await obj.plus()
        await obj.add(100)
        print("\(obj.uuid) value: \(await obj.value)") // 방문자수 value: 101
    }
}

 
둘 다 정상 작동합니다. 두 번째로 말하지만 viewDidLoad는 main actor의 영역입니다(main thread에 의해 코드들이 실행됩니다). nonisolated type를 제외한 obj의 isolated state들은 외부에서 호출될 때는 asynchronously하게 바뀌고, await를 통해서 access가 가능합니다. 그래서 add()는 async 성질이 붙습니다. 하지만 plus는 원래 async 함수였습니다. 이 차이점은 외부에서 호출할 때 async 키워드가 붙던 붙지 않던 다 async 가 적용이 되지만, actor 내부에서 호출될 때 plus() async한 곳에서 호출되야 함을 의미합니다. 그리고 이 경우에선 async가 굳이 필요 없음으로 지워야 올바른 코드입니다.( 근데 코드에 정답은 없다고 합니다. 그래도,,)
 
사실 이 경우는 other actor에 actor가 접근 하는 경우. Cross-actor reference라 불립니다. 그리고 obj.uuid도 isolated state입니다. 근데 immutable입니다. 이런 read-only의 immutable value인 경우 사실 어디에서 호출하나 data race가 발생하지 않습니다. data race는 오직 shared, mutable value에서 발생하기 때문입니다. 고로 actor에서 isolated state를 굳이 유지하고 있지 않아도 됩니다.
 

nonisolated

이땐 nonisolated를 let 앞에 붙여주면 좋습니다. actor 내 mutable 연산이나 프로퍼티가 아닐 경우 actor내의 프로퍼티, 함수에 대해서 nonisolated 키워드를 붙이면 컴파일러 성능 향상인가,, 좋다고 합니다.
 
그리고 Task() 안 print(...)에서 마찬가지로 obj.value를 호출할 때는 await 키워드를 사용해야 합니다. obj의 value는 mutable, isolated state인데 외부(main actor)에서 접근하기 때문입니다. 근데 deadlock등 concurrent problem이 발생가능할 경우엔 사용하지 않고 actor의 설질을 유지시키는게 좋습니다.
 

3. Cross-actor Reference

위에서 (Cross-actor reference)를 보셨을 텐데 이 경우들이 Cross-actor reference인 경우입니다. actor의 isolated state를 other actor's state에서 접근하는 경우입니다. 이를 Cross-actor reference라고 합니다.
 
그리고 globalActor도 있지만 대부분,, 코드가 실행되는 thread는 main thread. main actor의 영역에 속합니다. 그래서 내가 정의한 actor instance의 isolated state를 self가 아닌 곳에서 호출할 때는 해당 actor instance입장에선 외부 영역state(not 해당 actor's isolated state)이 되어서 Cross-actor reference로 분류될 수 있습니다. 물론 BankAccount.transfer(amount:to:) other 매개변수의 경우도 마찬가지입니다. background thread에서 actor가 실행될 때도 외부의 context에 속할 수 있습니다.
 
Cross-actor reference일 때의 경우는 두 가지가 있습니다.
 

  • actor's isolated state가 immutable한 경우 그리고 other actor(외부)에서 access할 경우
  • 특정 actor's isolated state를 (Not mutable state 일 때) other actor(외부)에서 access할 경우

 
위의 example들에서 전부 나온 개념이긴 합니다. 
 
전자의 경우(immutable할 때) concurrent domains간 access(read-only)를 아무리 해도 값은 변하지 않습니다. 사실 이 경우 actor에서 isolated state분류하고 actor's serial executor에서 관리하지 않아도 됩니다. 그래서 nonisolated 키워드를 부여합니다.(위의 Count.uuid처럼)
 
만약 actor가 isolated state가 없었다면 + Sendable을 준수하지 않을 경우(클래스와 다른게 없음으로) serialization이 무너지게 되고(애초에 이렇게 설계가 되지 않아서 말이 안됩니다), concurrent threads가 동시에 접근을 하게 됩니다.
 
후자의 경우 isolated state를 유지하면서 해당 actor's serial executor에게 수행을 맡겨야 합니다. 그래서인지 asynchronously한 접근을 해야 합니다. Asynchronous한 접근은 다른 말로, actor's mutable한 isolated state를 변경하는 로직이 있는 func가 동시간대에 여러 thread에서 호출 될 경우 각각의 thread에서 호출된 func가 곧 바로 실행되지 않고 바로 함수를 suspend한 채로 return합니다. 이 말은 동시에 호출 되어도 동시에 수행되지 않는다는 뜻입니다. 
 
other's actor에서 특정 actor의 isolated state를 접근할 때는 해당 actor's isolated state가 async하게 변경된다고 했습니다.(물론 자기 actor 영역안에서는 sync하게 서로 접근 가능합니다.) 이 때문에 await키워드를 반드시 붙여야하고 await 키워드 덕에 시스템의 상황에 따라 널널한 thread에서 suspend된  async task를 suspend, resume 반복하며 실행이 됩니다.
 

4. MainActor's concept

MainActor는 사실 dispatchQueue.main과 유사하게 동작된다고 볼 수 있고,, main thread에서 UI관련 작업을 수행하듯 MainActor 또한 UI관련 작업을 수행합니다. 그리고 MainActor 또한 actor인데 MainActor's serial executor가 main dispatch queue와 동등하다고 생각하면 됩니다. 특정 actor's isolated state property가 UI관련 task를 수행해야 할 때가 분명히 있습니다.
 
이땐 해당 isolated state property를 @MainActor 어노테이션을 통해 MainActor's serial executor에 의해 수행될 수 있도록 관리한다면 main thread에서 실행될 때 Task{DispatchQueue.main.async{...}}를 사용하지 않고 그냥 일반 변수 쓰듯이 사용하면 됩니다. 그대신 actor내 @MainActor 어노테이션된 isolated state property는 더 이상 해당 actor's isolated state property가 아니라 @MainActor의 isolated state가 된다는 점!! 고로 @MainActor's isolated state의 값을 특정 actor에서 변경할 때 해당 @MainActor의 영역 안에서 실행을 해줄 수 있도록 MainActor.run{ ... } 안에서 연산 처리된 값을 대입해야 합니다. run함수를 통해 비동기적으로 MainActor's serial executor에게 정중히 부탁해야 합니다.
 
 
지금까지 actor, actor-isolated, cross-actor reference의 경우, MainActor에 대해서 정리했는데 같은 개념이 몇 번 반복해서 나옵니다.(글솜씨가 빨리 좋아지면 좋겠는데,,) 틀린 부분 있다면 댓글로 알려주신다면 정말 감사합니다.
 

Swift 5.5 다른 포스트

GCD의 문제, Concurrency model 특징과 async/await 개념 정리 포스트

Actor No1. actor, thraed-safe, actor's serial executor 개념 정리 포스트

Sendable protocol 개념 정리 포스트

Structured concurrency 개념 정리 포스트

 

추가 공부하면 좋을 자료

https://github.com/apple/swift-evolution/blob/main/proposals/0313-actor-isolation-control.md

References

https://github.com/apple/swift-evolution/blob/main/proposals/0306-actors.md
https://www.kodeco.com/books/modern-concurrency-in-swift

 

Modern Concurrency in Swift

Master Swift’s modern concurrency model! For years, writing powerful and safe concurrent apps with Swift could easily turn into a daunting task, full of race conditions and unexplained crashes hidden in a massive nesting of callback closures. In Swift 5.

www.kodeco.com