
class Counter {
private(set) var value: Int = 0
func increment() -> Int {
value += 1
return value
}
}
let counter = Counter()
Task.detached {
print("Task 1: \(counter.increment())")
}
Task.detached {
print("Task 2: \(counter.increment())")
}
기존 class 타입은 서로 다른 비동기 컨텍스트에서 실행될 때, counter에 동시에 접근하게 되면, 값이 첫 번째의 접근에서도 value 값은 0, 두 번째 접근에서도 value 값이 0이 되어 첫 번째와 두 번째의 결과 모두 1이 될 수 있다.
class Counter {
private var value: Int = 0
private let queue = DispatchQueue(label: "com.example.counter.serial")
func increment() -> Int {
return queue.sync {
value += 1
return value
}
}
}
이러한 데이터 경합 문제를 방지하기 위해서 기존에는 lock 혹은 DispatchQueue를 통해 순차적으로 공유 자원에 접근하여 위와 같이 동시에 공유 자원에 접근하여 예상치 못한 결과를 방지할 수 있었다.
하지만, 이러한 방식은 휴먼 에러로 런타임에 예기치 못한 데이터 경합 상태가 발생할 수 있으며, 특히 DispatchQueue를 사용하는 경우에는 DispatchQueue.global().async 내부에서 다른 시리얼 큐의 sync를 과도하게 호출하다 보면, 시스템이 대기 중인 작업들을 처리하기 위해 스레드를 수십~수백 개씩 계속 만들어내는 Thread Explosion 현상이 발생할 수 있었다.
actor 타입은 공유 자원에 대한 순차적인 접근을 언어 레벨에서 보장하는 타입으로 다른 타입과 마찬가지로 아래의 특징을 가진다.
protocol 채택, extension도 모두 사용 가능하다.actor Counter {
private(set) var value: Int = 0
func increment() -> Int {
value += 1
return value
}
}
Counter 타입을 actor로 변경하면, 여러 비동기 컨텍스트에서 공유 자원인 value에 대한 접근 즉, increment() 메서드의 순차적인 호출이 보장된다.
이와 같이 actor는 가변 상태 데이터 보호를 위해 actor에 접근 방식을 제한하게 되는데, 이것을 actor-isolation이라고 한다. 예를 들어 actor의 프로퍼티, 메서드, 서브스크립트는 actor-isolated 되어있다고 한다. actor로 격리된(actor-isolated) 자원은 외부에서 직접 접근할 수 없으며, 오직 actor 내부에서만 직접 접근이 가능하다. 위의 코드와 같이 actor의 메서드인 increment()에서 value에 직접 접근 가능하다.
actor 외부에서 actor-isolated 상태의 자원을 참조하는 것을 cross-actor reference라고 한다. cross-actor reference는 두 가지 방법으로만 접근할 수 있도록 허용하는데 그 방법은 아래와 같다.
async/await를 활용한 cross-actor reference는 허용된다.첫 번째, 불변 상태의 값은 한번 초기화되면 수정할 수 없으므로, 동시성 환경에서 데이터 경합을 발생시키지 않기 때문에, 다른 컨텍스트에서 직접 접근이 가능하다.
두 번째, 비동기 함수 호출은 actor가 비동기 함수를 안전하게 수행할 수 있도록 messages 형태로 변환된다. 이 messages는 actor의 mailbox라는 곳에 저장 되고 비동기 함수의 호출자는 mailbox에 저장된 messages가 실행될 때까지 대기할 수 있다. 또한 actor의 mailbox는 한 번에 하나의 messages가 수행됨을 보장하므로 가변 상태의 데이터 경합은 발생하지 않는 것을 보장한다.
let counter = Counter()
Task.detached {
print(await counter.increment())
}
Task.detached {
print(counter.increment())
}
cross-actor reference의 규칙에 따라, 서로 다른 컨텍스트에서 actor 타입인 Counter의 인스턴스에 접근하려면, await를 사용하여야 한다는 것을 확인할 수 있다. actor는 내부 자원에 대한 동시 접근을 제한하여 데이터 경합을 원천 차단하므로, 모든 작업이 완료된 후 counter의 최종 value 값은 안전하게 2가 됨을 보장한다. 하지만 어떤 Task가 먼저 counter에 접근할지는 보장되지 않기 때문에 실행 순서 자체가 보장되지는 않는다.
actor에서 동기 코드를 활용하면 동시성 환경에서 데이터 경합은 해결 되었지만, actor 내부에서 비동기 함수를 실행할 때의 문제를 살펴보자.
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
cache[url] = image
return image
}
}
현재 위의 코드에서 공유 자원인 cache는 actor에 의해 데이터 경합으로부터 안전하게 보호되고 있다.
하지만 문제는 비동기 함수 실행 중 발생할 수 있는데, actor의 비동기 함수도 await 키워드를 마주하면 마찬가지로 스레드 제어권을 시스템에게 전달한다. actor에서는 await 지점에서 중단된 코드가 다시 실행될 때까지 다른 컨텍스트에서 비동기 함수에 접근할 수 있다.
예를 들어, 첫 번째 Task에서 이미지를 다운로드 중 스레드 제어권이 시스템에게 전달되면, 두 번째 Task에서 ImageDownloader에 접근하여 동일한 url에 대해 이미지 다운로드를 실행한다. 그 후, 첫 번째 Task에 의해서 다운로드를 완료 후 cache에 저장 후 잠시 뒤, 두 번째 Task에 의한 다운로드가 완료되면 cache에 같은 url에 대해 다른 이미지가 저장되는 일종의 버그가 발생할 수 있다.
따라서 이런 경우, 이미 동일한 url에 대해 다운로드가 진행 중이라면, 새로운 요청에 대한 중복 다운로드가 발생하지 않도록 방지하는 방법을 사용할 수 있을 것이다.
actor LibraryAccount {
let idNumber: Int
var booksOnLoan: [Book] = []
func selectRandomBook() -> Book? { ... }
}
struct Book {
var title: String
var authors: [Author]
}
func visit(_ account: LibraryAccount) async {
guard var book = await account.selectRandomBook() else {
return
}
book.title = "\(book.title)!!!"
}
위의 예시에서 idNumber와 booksOnLoan, selectRandomBook() 은 actor에 의해 보호되고 있다. 또한 Book이 value type이기 때문에, selectRandomBook()에서 Book을 반환하더라도 복사를 통해 반환한다. 그렇게 때문에 visit(_:) 함수에서 selectRandomBook()을 호출하더라도 데이터 경합이 발생하지 않는다.
class Book {
var title: String
var authors: [Author]
}
func visit(_ account: LibraryAccount) async {
guard var book = await account.selectRandomBook() else { // Non-Sendable 'Book?'-typed result can not be returned from actor-isolated instance method 'selectRandomBook()' to nonisolated context
return
}
book.title = "\(book.title)!!!"
}
만약, Book이 reference type이라면 어떻게 될까? selectRandomBook() 메서드에서 Book 인스턴스의 참조를 전달하기 때문에 메서드에서 반환되어 actor 외부로 참조가 전달되는 경우, 데이터 경합이 발생하여 더 이상 안전하지 않다.
이렇게 서로 다른 컨텍스트에서 안전하게 데이터를 전달하기 위해서는 Sendable 프로토콜을 채택해야 한다.
다시 말해,
Sendable프로토콜은 동시성 컨텍스트 사이에서 데이터 경합 없이 안전하게 데이터를 공유할 수 있는 thread-safe 타입을 말한다.
Sendable 프로토콜을 채택할 수 있는 타입은 아래와 같다.
@Sendable)Sendable 프로토콜은 다른 프로토콜과 달리 명시적인 프로퍼티 요구나 메서드 요구 사항은 없지만, Sendable 프로토콜을 채택하기 위한 조건이 있는데, 각 타입별 조건은 아래와 같다.
구조체와 열거형에서 Sendable 프로토콜을 채택하기 위해서는 구조체와 열거형이 가지는 멤버와 연관값이 모두 Sendable을 만족하면 된다.
actor 타입은 암시적으로 Sendable 프로토콜을 준수한다.
class 타입은 Sendable 프로토콜을 채택하기 위해서는 아래와 같은 조건을 만족해야 한다.
final 클래스Sendable protocol을 채택한 경우NSObject를 부모 클래스로 가지는 경우또한 클래스가 @MainActor로 선언되어 있는 경우, 암시적으로 Sendable protocol을 따른다.
클로저는 파라미터 정의 앞에 @Sendable 속성을 명시하여 해당 클로저가 안전하게 동시성 환경에서 사용될 수 있음을 보장할 수 있다. @Sendable이 적용된 함수와 클로저는 캡처 시 값 복사를 통해 동작하며, 캡처된 모든 값은 반드시 Sendable 프로토콜을 준수해야 한다.
let sendableClosure = { @Sendable (number: Int) -> String in
if number > 12 {
return "More than a dozen."
} else {
return "Less than a dozen"
}
}
튜플 내부 요소가 모두 Sendable protocol을 채택한다면, 암묵적으로 해당 튜플은 Sendable protocol을 채택한다.