Actor
- data race condition이 발생하는 문제를 해결하기 위한 synchronization 매커니즘
- mutuable state에 대해서 synchronization을 제공
- mutable state를 프로그램의 나머지 부분과 isolation함
- 무조건 하나의 process만 접근하도록 보장하며 mutable state는 actor를 통해서만 접근 가능
- 공유 데이터에 접근해야하는 여러 Task를 조정해서 한번에 하나의 Task만 내부 상태를 조작하도록 허용함
- 내부적으로 직렬화를 사용하기에 너무 많은 작업을 사용하는 것을 지양
- 암시적으로 sendable임
문제 상황
class Counter {
var value = 0
func increment() -> Int {
value += 1
return value
}
}
let counter = Counter()
Task.detached {
print(counter.increment())
}
Task.detached {
print(counter.increment())
}
// counter의 value가 동시에 접근하기에 1 1이 될수도 22 가 될수도 있음
해결하기 위해 Actor를 넣어보자!
public protocol Actor: AnyObject, Sendable {
nonisolated var unownedExecutor: UnownedSerialExecutor { get }
//race condition을 방지하기 위해 순차적으로 접근할 수 있도록 동기화된 접근이 필요해서 존재하는 변수
}
- Actor는
reference type
임- 왜냐면 목적이 공유되는 가변 상태를 표현하는 것이기 때문!(backlink 참고)
- extension, protocol 채택 가능(암시적으로 sendable 프로토콜을 채택하고 있음)
- properties, initilizer, subscript 등 기능도 사용 가능
- 상속은 불가능!
- actor 내에 정의된 stored, computed instance property, instance method, instance subscript 모두 actor-isolated된 상태가 된다
- actor-isolated끼리는 모두 self(같은 인스턴스)를 통해서만 동기적으로 접근 가능!
- actor 외부에서 actor 내부 프로퍼티를 변경할 수 없음
actor Counter {
var value = 0
func increment() -> Int {
value += 1
return value
}
}
let counter = Counter()
Task.detached {
print(counter.increment())
}
Task.detached {
print(counter.increment())
}
// 1 2 혹은 2 1만 출력되게 됨! 왜냐면 다른 호출이 시작되기 전에 진행되고 있던 호출을 완료하도록 보장하기 때문
- non-isolated는 actor-isolated에 동기적으로 접근 불가해서 비동기적으로 접근함
extension Counter {
func someFunction() {
Task.detached { // actor isolation을 상속받지 않기 때문에 await 비동기적으로 접근
print(await self.value)
}
}
}
nonisolated 키워드
- actor 안에 명시적으로 비격리함수로 만들기 위해서는 nonisolated를 붙여야 함
extension Counter {
nonisolated func 비격리함수() async {
let value = await value
}
}
- actor의 컨텍스트를 사용해서 일반적으로 함수를 호출하는데 이 키워드로 격리된 함수로 만들 수 있음
- 단, 이런 non-isolated async 코드는 actor를 떠나서 동시성의 바다에서 실행되는 상황이 되므로 sendable한 데이터만 가지고 있는지 체크해야 함!!
- computed property 앞에도 붙일 수 있어 동기적으로 참조하고 싶을 때 앞에 붙일 수도 있음
- 단, let과 같이 불변하는 애를 사용하는 computed property만 가능
import Foundation
actor Counter {
var value = 0
let name: String
nonisolated var computedProperty: String {
return "Counter 이름: \(name)"
}
var computedProperty2: String {
return "Counter2 이름: \(name)"
}
init(name: String) {
self.name = name
}
func increment() -> Int {
value += 1
return value
}
}
let counter = Counter(name: "subin's counter")
print(counter.name)
print(counter.computedProperty)
print(await counter.computedProperty2)
print(await counter.value)
/*
subin's counter
Counter 이름: subin's counter
Counter2 이름: subin's counter
0
*/
isolated 키워드
- 파라미터 앞에 isolated를 붙여서 해당 actor가 isolated임을 명시
actor BankAccount {
let accountNumber: Int
var balance: Double
func deposit(amount: Double, to account: **isolated** BankAccount) {
assert(amount >= 0)
account.balance = account.balance + amount // await 없이 바로 접근 가능!
}
func giveSomeGetSome(amount: Double, friend: BankAccount) async {
deposit(amount: amount, to: self) // okay to call synchronously, because self is isolated
await deposit(amount: amount, to: friend) // must call asynchronously, because friend is not isolated
}
}
Actor reentrancy
- actor는 무조건 한번에 하나의 task 실행을 허용하는데 actor 내에서 await를 통해서 actor 실행을 중지하는 동안에는 다른 task가 actor로 진입해도 됨!
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(url) // 여기서 await로 기다리는 동안 다른 코드가 actor에 접근해서 실행할수 있음
cache[url] = image
return image
}
}
- 이를 통해서 데드락의 가능성을 제거해줌!!
- 또한 응답성을 유지하기 위해 우선순위가 높은 작업 먼저 실행함
- 이는 선입선출되는 직렬 디스패치 큐와 매우 다른 차이가 있음!!
Actor reprioritization
- GCD에서의 우선순위 처리 방법
- 선입선출을 따르기 때문에 우선순위가 높다고 먼저 실행되지 않음
- 즉, priority inversion이 발생
- serial queue는 우선순위가 높은 작업보다 앞에 있는 우선순위를 높여서 priority inversion을 방지
- 선입선출을 따르기 때문에 우선순위가 높다고 먼저 실행되지 않음
- actor에서의 우선순위 처리 방법
- 우선순위가 높은 항목이 큐 맨 앞으로 이동하도록 선택할 수 있음
Actor hopping
- 한 actor에서 다른 actor로 전환되는 작업을 의미
Cross Actor reference
- 다른 actor의 property를 참조를 동기적으로 하는 건 안되지만 let과 같은 변하지 않는 거는 동기적으로 참조 가능!
actor BankAccount {
let accountNumber: Int
var balance: Double
init(accountNumber: Int, initialDeposit: Double) {
self.accountNumber = accountNumber
self.balance = initialDeposit
}
}
func transfer(amount: Double, to other: BankAccount) throws {
if amount > self.balance {
throw BankError.insufficientFunds
}
self.balance = balance - amount
// other.balance는 에러남!! 하지만 accountNumber는 let이니까 가능
other.accountNumber ✅
}
- 하지만, 같은 모듈내에서만 가능하고 외부 모듈에서는 무조건 await해줘야 함!!
- 이유: 만약에 내 모듈이 갑자기 accountNumber를 var로 바꿔버리면 외부 모듈도 전부다 await로 바꿔야하니까 그냥 무조건 비동기참조하도록 하는 것
Actor 사용시 주의할 점
- actor 내부에서 await를 할 때에는 무조건 상태를 가정하면 안됨!
- 위에 처럼 await할 때 다른 task가 진입할 수 있기 때문
- actor 안에서 너무 많은 일을 하면 안됨
- actor가 너무 많은 일을 하게 된다면 기다리는 시간이 많아질 수 밖에 없기 때문!
Actor의 특징
- hashable 프로토콜을 준수할 수 없다
- 이유: hasable을 준수한다는 의미는 actor외부에서 해당 hash(into:)함수가 호출된다는 건데 이 함수는 비동기가 아니니까 actor-isolated를 유지할 수 없음
결론
- actor는 공유 데이터에 접근해야하는 여러 task들을 조정해서 무조건 한번에 하나의 task만 내부 상태를 조작하도록 허용하는 매커니즘
- 기본적으로 reference type이며 상속이 불가능
- 기본적인 property, 함수들은 모두 다 비동기적으로 불러와야하며 nonisolated, isolated 키워드가 존재
- actor 내부에서 await를 호출하는 경우 그동안 다른 task가 actor로 진입해도 됨(actor reentrancy)
- GCD는 fifo로 동작하지만 actor의 경우 우선순위가 높은 항목이 큐의 맨 앞으로 이동하도록 선택할 수 있음
- let과 같은 변하지 않는 변수의 경우 다른 actor의 property를 동기적으로 참조 가능 (cross actor reference)
- actor에는 최대한 많은 일을 하는 것을 지양하고 actor 내부에서 await를 하는 경우 다른 task가 진입할 수 있음을 유의할 것