• 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

Untitled

  • GCD에서의 우선순위 처리 방법
    • 선입선출을 따르기 때문에 우선순위가 높다고 먼저 실행되지 않음
      • 즉, priority inversion이 발생
    • serial queue는 우선순위가 높은 작업보다 앞에 있는 우선순위를 높여서 priority inversion을 방지

Untitled

  • actor에서의 우선순위 처리 방법
    • 우선순위가 높은 항목이 큐 맨 앞으로 이동하도록 선택할 수 있음

Untitled

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가 진입할 수 있음을 유의할 것

참고

[Swift] Actor 뿌시기

Swift ) Actor (2) - Actor isolation