개요
지난 편에 이어서 이제 본격적으로 Actor라는 Object Type에 대해 알아보겠습니다.
actor를 한글 키보드로 종종 잘못 치곤 하는데요, actor == ㅁㅊ색이라는 게 뭔가 잘 어울리네요
Task
Task는 독립적인 비동기 컨텍스트를 제공해줘서 비동기 메소드를 실행할 수 있는 공간이다.
WWDC에서는 Task를 동시성의 바다 위에 떠다니는 보트로 비유한다.
Task는 여러 개 생성할 수 있으며, 각각은 독립적이나 그 안에서의 데이터들은 값 타입인지 참조 타입인지에 따라 다르다. 참조 타입의 경우 아래 사진과 같이 독립적인 Task에서 내부적으로는 공유된 데이터를 사용할 수 있기에 이를 문제라 본다.
따라서 Task에서는 멀티 스레드에 의해 데이터 레이스가 발생할 수 있기 때문에 struct에서는 문제가 없으나,
그냥 class를 사용하면 Swift 6 버전부터는 에러를 발생시키며 데이터 레이스를 방지한다.
Class + Sendable
Task는 위 사진과 같이 반환 타입을 가질 수 있다.
그리고 반환 타입의 Success는 Sendable한 타입을 가져야 된다.
클래스에 final과 불변으로 프로퍼티를 설정하면 Sendable을 채택하여 비동기 컨텍스트에서 사용이 가능하지만,
사실 상 데이터 교환이 필요할 텐데, 이럴 거면 왜 씀 ?
그래서 등장한 것이 Actor
Actor
Actor는 공유되는 가변 데이터를 목적으로 나온 것이다.
레퍼런스 타입이고, 상속이 불가능하다.
참고로 Sendable도 채택하고 있다. 그래서 Task 내부에서 동작할 수 있는 것.
어떻게 Actor는 레퍼런스 타입인데 Task 내에서 에러 없이 안전하게 가변 데이터를 공유할 수 있을까 ?
Actor의 모든 것
Data Race의 이유
Data Race는 두 개 이상의 스레드를 사용하고, 동일한 메모리에 접근 하는 상황에서 하나 이상의 스레드가 쓰기 작업을 하는 경우 문제가 발생할 수 있다.
격리(isolate)
Actor는 여기서 공유 데이터에 한번에 하나씩만 접근하게 한다면, 즉 여러 곳에서 동시에 접근하는 상황을 막자 라는 개념을 적용했다.
그래서 하나의 Task 에서만 actor에 접근할 수 있는 것이다.
WWDC를 보다보면 isolate 라는 얘기도 나오고, 실제로 unsafe(nonisolate)
와 같은 문법이 있는데 여기서 말하는 isolated 격리란 actor 인스턴스를 다른 곳에서 접근하지 못 하도록 격리 시키는 것을 말한다. 그렇기에 동기화된 액세스를 보장할 수 있는 것.
코드 보며 Actor 익히기
actor SharedWallet {
let name = "공금 지갑"
var amount: Int = 0
init(amount: Int) {
self.amount = amount
}
func spendMoney(ammount: Int) {
self.amount -= ammount
}
}
Object Type이기 때문에 struct든, class든 사용법은 똑같다.
그러나 사용법이 다름.
Task {
let wallet = SharedWallet(amount: 10000)
let name = wallet.name // 1. 상수는 변경 불가능하기 때문에 어느 스레드에서 접근해도 안전함. actor 외부에서도 바로 접근 가능
let amount = await wallet.amount // 2. actor 외부에서 변수 접근시 await 필요
await wallet.spendMoney(ammount: 100) // 3. actor 외부에서 메서드 호출시 await 필요
await wallet.amount += 100 // 4. ❌ 컴파일 에러. actor 외부에서 actor 내부의 변수를 변경할 수 없음
}
주석으로 설명이 되어 있지만 정리하면 큰 특징은 다음과 같다.
- actor 프로퍼티 접근 제어자가 private가 아니어도,
외부에서는 변경할 수 없음 - actor
프로퍼티
접근 시 await 필요
private를 하면 당연히 접근 안 되고 ㅇㅇ - actor
메소드
접근 시 await 필요
내부 프로퍼티를 변경하고 싶으면, private 프로퍼티를 변경하는 것과 같이 메소드를 통해서 actor 내부에서 하면 된다.
또, 언제 접근되는지 확실하지 않기 때문에 프로퍼티든 메소드든 호출할 때 async 키워드가 없어도 await를 붙여서 호출해야 한다.
하나의 Task에서만 접근하는 것을 가능하게 하기 위해서 위와 같이 동작하는 것이다.
Actor Serialization
직렬화: 여러 작업이 동시에 실행되지 않고, 순차적으로 처리됨
Actor는 여러 스레드에서 동시에 실행되지 않는다.
즉, Actor 접근은 직렬화(Serialization) 된다.
actor Counter {
var value = 0
func increment() -> Int {
value += 1
return value
}
}
let counter = Counter()
Task.detached { print(await counter.increment()) // 1 2
Task.detached { print(await counter.increment()) // 2 1
위 코드의 경우 어떤 print가 먼저 찍힐 지는 모르지만, 결과 값은 1, 2 혹은 2, 1이다.
이런 걸 보면 GCD의 직렬 큐와 비슷하다.
Actor가 Serial하게 동작하는 이유
actor A { }
에서 actor는 Actor 프로토콜을 채택하는 구현체이다.
Actor 프로토콜은 다음과 같이 정의되어 있다.
public protocol Actor: AnyObject, Sendable {
nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}
unownedExecutor에 의해 직렬화를 보장하는 동작을 하는데, 이는 Executor
와 관련이 있다.
@available(SwiftStdlib 5.5, *)
public protocol Executor: AnyObject, Sendable {
func enqueue(_ job: UnownedJob)
}
Executor
프로토콜은 작업을 수행할 수 있는 Object를 정의하고, 필수 구현 메소드로 enqueue를 요구한다.
그리고 Executor
를 채택하는 SerialExecutor
프로토콜이 있다.
@available(SwiftStdlib 5.5, *)
public protocol SerialExecutor: Executor {
func asUnownedSerialExecutor() -> UnownedSerialExecutor
}
위 프로토콜을 통해 SerialExecutor에 대한 unowned reference으로 actor의 메소드를 호출하면 내부적으로 actor 내부 executor의 enqueue 메소드를 호출하여 순차적으로 처리된다.
Actor Scope 격리 이해하기
보트(Task)와 섬(Actor) 사이에도 Sendable 하지 않은 타입이 통과하지 않도록 하여 서로의 격리를 유지해야 한다.
예로, 보트(Task)에 있는 닭(class) 한마리를 섬(Actor)에 추가하려고 시도하는 상황을 떠올려보자.
이러면 둘 사이의 레퍼런스로 닭 객체에 참조가 각각 생성되어 Swift 컴파일러가 에러를 발생시킨다.
actor에 격리된 메소드에서 Sendable하지 않은 파라미터를 거부한 것.
반대의 경우, 섬에 있는 걸 보트에 옮기는 것도 마찬가지이다.
Actor와 Task 사이의 문제를 해결하기 위해 어떻게 격리된 코드를 구분할까 ?
→ Actor의 격리는 우리가 만든 Context에 따라 결정된다.
일단 actor의 프로퍼티와 메소드는 해당 actor로 격리된다.
let totalSlices = food.indices.reduce(0) { ... }
advanceTime 메소드의 reduce로 전달된 클로저와 같이 Sendable하지 않으면 Actor-isolated Context에 있을때 Actor-isolated 된다.
동일한 Actor로 관리된다는 뜻인듯, 즉 코드를 보면 await없이 내부 프로퍼티 flock을 접근한다.
Task { flock.map(Chicken.produce) }
Task Initializer 또한, 컨텍스트에서 Actor isolation을 상속하므로 생성된 Task는 처음 시작된 Actor와 동일한 Actor에 대해 관리된다.
Task.detached { … }
반면 detached
된 Task는 컨텍스트에서 Actor isolation을 상속하지 않음.
따라서 동일한 Actor로 격리되지 않는다.
클로저에 있는 코드는 Actor 외부로 간주하기 때문에 actor에 격리된 food 프로퍼티를 참조하려면 await를 사용해야 한다.
detached Task와 비슷하게, actor 내부에 있는 함수를 명시적으로 비격리로 만들기 위해 nonisolated
키워드를 사용할 수 있긴 하다.
이 경우도 마찬가지로 actor의 격리된 상태 flock를 읽고 싶으면 await를 사용해야 한다.
또, 동시에 await를 사용할 수 있게 async를 붙여준다.
참고로 let 불변 프로퍼티는 nonisolated
내에서 await를 사용하지 않아도 된다.
그러나 non-isolated async 코드는 글로벌 협동 풀(cooperative pool)
에서 실행된다.
즉, 격리된 섬(actor)를 떠나 바다 위에서 실행되는 상황이므로 Sendable한 데이터만 갖고 있는지를 컴파일러가 검사한다.
위 코드에서 닭은 클래스이다.
위처럼 map으로 닭들의 이름을 배열로 리턴하는데, 결과를 뱉으면 결국 참조만 얻게 되므로 컴파일러는 데이터 레이스 잠재 가능성을 파악하고 에러를 발생한다.
isolated 문법
기존에 위 코드와 같이 actor의 프로퍼티나 메소드 접근을 위해 await를 붙여 사용했는데, isolated 키워드를 통해 본 메소드를 해당 Actor의 도메인으로 격리한다.
는 의미로 사용할 수 있다.
그러면 매번 await로 호출할 필요가 없다.
메소드는 async가 아니지만, 호출하는 쪽에서는 await advanceTime(island: island)
로 await 호출은 동일하다.
Swift의 일반적인 격리 + Actor
일반적으로 Swift Concurrency가 아니라 우리가 기존에 사용하던 Swift 프로그래밍은 동기(sync) + 비격리(non-isolated)로 작동한다.
호출하는 쪽의 격리 도메인이 있는 경우
그럼 일반적인 코드는 Actor, Task, Concurrency를 모를텐데 Actor로 격리된 메소드에서 호출하면 어떨까 ?
func greet(_ friend: Chicken) {}
이 일반적인 메소드를 아래 메소드에서 돌려보자
actor Island {
var flock: [Chicken]
}
extension Island {
func greetOne() {
if let friend = flock.randomElement() {
greet(friend)
}
}
}
greet 메소드는 actor 내에서 호출되었기 때문에 마찬가지로 actor에 격리된다.
즉, actor에서 파라미터 flock을 await로 호출하지 않고 자연스레 사용할 수 있다.
격리 도메인이 없다면?
반면, 아래 코드와 같이 비격리(non-isolated) + 비동기(async)에서 greet를 호출하면, 이 함수는 동시성 바다에 떠다니는 보트(Task)에서 실행될 것이다.
자유롭게 존재하는 것.
func greetAny(flock: [Chicken]) async {
if let friend = flock.randomElement() {
greet(friend)
}
}
즉, greet 같이 일반적인 메소드는 호출하는 쪽에서 Actor 격리 도메인을 갖고 있다면 거기에 똑같이 격리되고, 없으면 자유롭게 존재하는 것이다.
위 사진의 좌측이 전자, 우측이 후자이다.
재진입 (Reentrancy)
actor는 한 번에 하나의 Task 실행만 허용한다. 그러면서 데이터 레이스 문제를 해결해왔다.
그러나, actor 자체에서 await를 통해 actor 실행을 중지하는 동안에는 다른 Task에서 actor로 진입해 코드를 실행할 수 있다.
이게 무슨 말이냐면 Actor는 섬이고, 그 섬에 접근하기 위해서는 Task 보트가 있어야 한다.
하나의 보트(Task)만 섬(Actor)에 접근해서 사용하기에 데이터 레이스가 유지된다는 말이다.
그런데, Actor 내부에서 await를 통해 접근한 보트(Task)가 계속 기다리면 비효율적이니까 현재 보트(Task)는 미루고 다음 보트(다른 Task)에서 섬(Actor)에 접근하는 것이다.
아래 코드는 actor 메소드의 중간에서 downloadImage
라는 비동기 메소드를 호출한다.
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
// 캐시 체크
if let cached = cache[url] {
return cached
}
// 이미지 다운로드
let image = try await downloadImage(from: url) // <-- suspension point
// 캐시에 이미지 저장
cache[url] = image
return image
}
}
actor 내부에서 await를 사용하고, downloadImage 결과를 기다리는 동안, 바깥(다른 Task)에서 actor를 사용하기 위해 await가 아닌 다른 코드를 실행하게 하는 것을 Reentrancy 재진입이라 한다.
여기서 actor는 우선순위가 가장 높은 작업을 먼저 실행하기 때문에 GCD에서 있었던 우선순위 역전 문제를 해결한다.
주의1. actor에서 await 호출 이후 내부상태 가정 금지
actor 내부에서 await를 호출한 이후, actor의 상태는 크게 변할 수 있다.
그러니까 await로 중단되어 있을 때 다른 Task가 actor에 접근해서 값을 바꿔버리면 데이터 일관성 문제가 생긴다는 뜻이다.
왜? Reentrancy 재진입 때문에 이게 가능하다.
아래 Counter 프로퍼티의 값 변화를 보겠다.
actor MyDownloader {
var counter = 0
func download(url: URL) async throws -> Data {
counter += 1 // counter 1 올리고
let num = counter // counter 를 num 에 할당
let result = try await URLSession.shared.data(from: url) // await 로 URLSession 함수 호출
print("num : " + num.description + ", " + "counter : " + counter.description)
print(num == counter) // counter 를 그대로 num 에 할당했으니까 true.. 일까나?
return result.0
}
}
- 두 개 이상의 Task에서 하나의 actor에 접근
- Task 1에서 counter를 1 증가시키고 await에 들어가고 actor가 중단됨
counter = 1, num = 1 - Reentrancy에 의해 Task 2가 actor를 사용하여 counter 값을 증가시킴
counter = 2, num = 2 - Task 2도 결국 await를 만나서 중단되고, Task 1이 다시 actor를 실행함
- Task 1이 actor로 다시 돌아왔을 때의 num은 1이나, counter가 2이다.
즉 false - Task 2는 await 작업이 끝나고 다시 돌아오면 counter = 2, num = 2이므로 true
두 결과가 다르게 나오는 것이다.
즉, actor 내부에 await가 있다면 내부 상태가 온전치 못할 수 있다.
주의2. actor에서 많은 양의 작업을 진행하지 않는다.
여러 Task에서 동일한 Actor를 동시에 사용하고, actor 내에서 많은 양의 작업을 하면 어떻게 될까 ?
actor는 Task의 접근을 직렬화해서 하나 씩 처리한다. 그러면 다른 Task들은 모두 대기하게 되기 때문에 각 Task가 빨리 끝나지 못 하는 것이다.
즉 Task가 actor의 데이터 접근이 필요한 경우에만 actor에서 실행되게 해야 한다.
여기서 Task가 Actor에서 실행된다는 것은 아래의 의미를 갖는다.
Task에서 actor 접근을 위해 await를 호출하면서 현재 스레드 제어권을 포기했다가, actor의 코드 실행이 가능한 시점이 오면 임의의 스레드를 할당받아 actor 코드를 실행한다.
아래는 예제 코드이다.
좌측 클래스는 Loop를 돌면서 actor의 로그 및 파일 압축을 실행하고 있다.
즉 아래와 같이 logs 프로퍼티와 compressionFile(url:) 메소드가 액터에 격리(isolated)되어 있다.
격리되어 있기 때문에 한 파일의 압축이 끝날 때까지 (compressionFile 메소드가 끝나기까지) 다른 파일은 압축을 할 수 없다.
그러나, 아래와 같이 actor의 메소드를 비격리(non-isolated) 코드를 바꾸면 다르다.
actor의 compressionFile(url:) 메소드를 비격리(non-isolated)로 변경했기 때문에 내부에서는 await를 통해 logs 프로퍼티에 접근한다.
메소드 자체가 격리된 것이 아니기 때문에 await를 제외한, 압축과 관련한 코드는 격리와 무관하게 동작할 수 있는 것이다.
또, 클래스에서는 Task가 actor context를 이어받지 않고, 별도 스레드에서 실행되도록 Task.detached를 생성해주었다.
이렇게 하면서 어느 스레드에서나 동시적으로 수행가능하게 되었다.
actor의 state에 접근(logs)할 때는 해당 함수도 actor 내에서 실행될 테지만,
그 작업이 끝나자마자 바로 스레드 풀로 돌아와서 자신의 작업을 이어서 할 수 있다.
Actor Hopping
액터 간의 전환
위 예시들은 보통 Task에서 actor 함수를 호출하는 예제들이다.
그러면 actor에서 다른 actor의 메소드를 호출하면 어떻게 될까 ?
이 때도 async/await를 통해 메세지를 주고 받는다.
actor의 동작은 cooperative thread pool 에서 수행되는데, 한 actor에서 다른 actor로 전환하는 것
을 Actor Hopping
이라 한다.
위 actor들이 있을 때 스레드 변화를 보겠다.
1. Sport Feed
actor가 cooperative 스레드 1번에서 동작하다, database actor에 저장하기로 했다.
2. 현재 database actor는 아무도 사용하지 않으므로 경쟁이 없는 상태이다.
따라서 Sport Feed
는 곧장 Database
actor로 이동(Hop)할 수 있다.
Sport Feed는 await database.save
에 의해 중단 상태가 된다.
3. Sport
Feed가 동작하던 스레드는 Suspend Point에 의해 다른 작업이 올 수 있게 된다. 아래 그림처럼 Database
Actor가 스레드에서 작업을 한다.
즉, Hopping은 다른 스레드를 필요로 하지 않는다.
4. 이 상황에서 Weather Feed
Actor가 다른 스레드에서 동작하다, Database
actor를 사용하려 한다. Database actor는 D2 작업을 생성한다.
그러나 실행중인 D1 작업이 있기에 D2는 보류 상태가 된다.

5. Weather Feed
가 동작하던 스레드는 Suspend Point가 되어 해당 스레드에 다른 작업이 올 수 있다. Health Feed
actor가 왔다고 가정하자.
6. 시간이 지나 D1 작업이 종료되었다. 이제 보류중인 D2를 할지, 기존에 멈춘 S1을 할지, W1을 할 지는 우선순위에 달렸다.
actor 재진입 Reentrancy는 시스템이 우선순위에 따라 잘 설정하게 설게되었다.
헷갈렸던 점
Q. sendable & actor-isolated 상관관계
클로저에 @Sendable을 붙이면, 왜 actor-isolated 하지 않은가.
동기적인 Sendable 클로저는 actor-isolated할 수 없음
→ Sendable을 붙이면 다른 Task에서 사용할 수 있어야 함. 그러나 actor에 격리되면 다른 곳에서 await로 호출해야 됨
이거는 말 안되니까 클로저에 @Sendable을 붙이면 actor-non-isolated라고 말하는 것.
Q. actor-isolated가 뭔데요 ?
isolated → Self만이 접근할 수 있는 것, 안 그러면 await를 통해 호출해야 함
레퍼런스
https://sujinnaljin.medium.com/swift-actor-뿌시기-249aee2b732d
[Swift] Actor 뿌시기
근데 이제 async, Task 를 곁들인..
sujinnaljin.medium.com
https://developer.apple.com/news/?id=o140tv24
Get started with Swift concurrency - Discover - Apple Developer
Take charge of concurrent tasks with this comprehensive toolkit.
developer.apple.com
https://developer.apple.com/kr/videos/play/wwdc2021/10133/
Protect mutable state with Swift actors - WWDC21 - 비디오 - Apple Developer
Data races occur when two separate threads concurrently access the same mutable state. They are trivial to construct, but are notoriously...
developer.apple.com
https://developer.apple.com/videos/play/wwdc2021/10254
Swift concurrency: Behind the scenes - WWDC21 - Videos - Apple Developer
Dive into the details of Swift concurrency and discover how Swift provides greater safety from data races and thread explosion while...
developer.apple.com
https://developer.apple.com/videos/play/wwdc2022/110351
Eliminate data races using Swift Concurrency - WWDC22 - Videos - Apple Developer
Join us as we explore one of the core concepts in Swift concurrency: isolation of tasks and actors. We'll take you through Swift's...
developer.apple.com
'📱 iOS > Swift Concurrency' 카테고리의 다른 글
[문제해결] @Sendable과 isolated 실전 응용: AVCaptureDevice.requestAccess(for:) (0) | 2025.03.15 |
---|---|
[Swift Concurrency 11편] Main Actor (0) | 2025.02.26 |
[Swift Concurrency 9편] Sendable 프로토콜 (0) | 2025.02.26 |
[Swift Concurrency 8편] Task와 구조화된 동시성(= Structed Concurrency) (0) | 2025.02.26 |
[Swift Concurrency 7편] 비동기 호출에서의 스레드 제어권 (0) | 2025.02.26 |