
GCD 를 이용한 비동기 처리
- 어떤 작업을 비동기 처리한 후 “비동기 처리 완료의 시점을 파악”하기 위해서 보통 completionHandler를 사용했었다.
func printNumbers() {
print("1")
asyncFunction {
print("비동기 코드가 끝났습니다.")
}
print("3")
}
func asyncFunction(completionHandler: @escaping () -> Void) {
DispatchQueue.main.async {
print("2")
completionHandler()
}
}
printNumbers()
- 근데 CompletionHandler는 다음과 같은 문제가 존재한다.
- CompletionHandler 호출을 실수로 까먹을 수 있음.
- 비동기 코드가 많을 경우, CompletionHandler를 무한정으로 콜백하는 콜백 지옥에 빠질 수 있음. ⭐️
- 가장 큰 단점은 CompletionHandler는 가독성이 매우 떨어진다는 것. ⭐️⭐️
async / await
- CompletionHandler의 단점을 보완하고자 새로운 Swift Concurrency인 async / await이 도입되었다.
- 함수 이름 뒤에 async가 붙으면 해당 함수는 비동기로 동작하는 함수를 의미한다.
- 비동기 함수를 호출하려면 반드시 await를 붙여서 호출해야 한다.
- 여기서 await라는 말은 현재 머물러 있는 “스레드에 대한 제어권을 잠시동안 포기하고 시스템에게 넘긴다는 의미”이다. 예를 들어, await 함수가 A 스레드에서 호출되었을 때 await 함수는 제어권을 시스템에게 넘겨주고 시스템이 알아서 추후에 해당 함수를 적절하게 실행될 수 있게끔 맡긴다는 거다. ⭐️⭐️
- 그런데 시스템이 알아서 await 함수를 적절히 실행한다는 것은 시스템이 기존 스레드가 아닌 다른 스레드에서 await 함수 실행할 수도 있다는 것이다. 예를 들어, await 함수가 A 스레드에서 호출되면 await 함수가 시스템에게 제어권을 넘긴 후 시스템은 적절한 시점에 await 함수를 B 스레드에서 호출시킬 수 있다는 것이다. ⭐️⭐️
- async / await을 사용하려면 반드시 Task라는 비동기 작업을 나타내는 Context와 함께 사용되어야 한다. 반드시 이 안에서만 await 함수를 호출한다.
- Task 내부에서는 코드가 순차적으로 진행된다. ⭐️⭐️
Task {
let fish = await catchFish()
let dinner = await cook(fish)
await eat(dinner)
}
기존 GCD와 Swift Concurrency(Async / Await) 의 동시성 처리 방법 차이점
- GCD ⇒ 필요한 Task만큼 “스레드를 여러 개 만들어서 일을 동시에 진행”한다. ⭐️⭐️
- 스레드를 필요 이상으로 너무 많이 늘리게 되면 “메모리 문제가 발생”할 수 있다.
- 스레드가 많아지게 되면 “경쟁 상태(Race Condition)가 발생”할 수 있음
- 따라서 경쟁 상태를 해결하기 위해 “특정 데이터에 접근할 수 있는 스레드의 최대 수를 제한하는 Semaphore를 사용”한다.
- Swift Concurrency (Async / Await) ⇒ “CPU 코어의 수만큼만 스레드를 만들어서 일을 동시에 진행”한다. ⭐️⭐️
- await 키워드를 통해 비동기 코드를 잠시동안 기다리게 할 수 있다.
- “기다리는 동안 스레드에 대한 제어권을 시스템에게 넘긴다는 점”이 GCD와 다르다. ⭐️
Task의 특징
- Task 블록 자체는 비동기적으로 실행된다. 따라서 Task 블록 내에서 async 함수를 호출할 수 있다.
- Task 안에서의 작업은 처음부터 끝까지 순차적으로 진행된다.
- Task는 격리되어 있고(isolated), 다른 Task들과 독립적으로 동작한다. ⭐️⭐️⭐️
- 값이 공유될 상황이 있을 때는 Sendable 프로토콜 체킹을 통해 Task가 격리된 (isolated) 상태로 남아있는지 확인한다. ⭐️⭐️
- 3번의 특징을 조금 더 풀어서 설명해보겠다. 만약 서로 독립적으로 동작하는 Task들이 서로의 데이터를 공유하게 된다면 어떻게 될까? 예를 들면 아래와 같은 상황이다.
Task {
let pineApplePicking = Task {
let pineApples = await harvestPineApples()
return pineApples.randomElement()
}
let pineApple = await pineApplePicking.value
}
- 이 때, pineApple이 “struct와 같은 value 타입”이라면 그냥 복사해주고 서로 갈 길 가면 된다. 한 Task에서 다른 Task로 pineApple 복사본을 전달하고, 각 Task는 각자의 복사본을 챙겨서 떠난다.
- 복사본을 전달하면 매우 좋은 점이 Task에서 각각 속성값을 변경해도 서로 복사본을 나누어가졌기 때문에 서로의 Task에 아무런 영향도 주지 않는다는 것이다.
- 그런데 만약 pineApple이 “class와 같은 reference 타입”이라면 메모리 주소값을 서로 공유하게 되기 때문에 Task에서 각각 속성값을 변경하게 되면 서로의 Task에 큰 영향을 줄 수 있게 된다. 이것을 “Data Race” 라고 한다. ⭐️⭐️⭐️ 즉, 공유된 가변 데이터 (Shared Mutable Data) 문제가 발생할 수 있다는 것이다. ⭐️⭐️⭐️
Sendable 프로토콜
- Sendable 프로토콜이란 Data Race를 생성하지 않고, 서로 다른 격리 도메인 간에 안전하게 공유할 수 있는 타입을 의미한다. 즉, 복사를 통해 값을 동시성 도메인에 안전하게 전달할 수 있는 타입이다.
- Sendable 프로토콜을 채택하는 조건은 다음과 같다.
- 값 타입
- Mutable Storage가 없는 참조 타입
- 내부적으로 상태에 대한 액세스를 관리하는 참조 타입
- @Sendable로 표시된 함수 및 클로저
Actor가 생겨난 이유
- 즉, 1) Data Race를 발생시키지 않으면서도, 2) Task 간에 안전하게 가변 데이터를 공유할 방법이 필요하다. 바로 이 자리에서 Actor가 탄생하였다. ⭐️⭐️
- Actor는 1) 공유 데이터에 접근해야 하는 여러 Task 를 조정하는 역할을 한다. 2) 외부로부터 데이터를 격리하고, 3) 한 번에 하나의 Task만 내부 상태를 조작하도록 함으로써 동시 변경으로 인한 Data Race를 피한다. ⭐️⭐️
Actor란?
- Actor의 목적은 기본적으로 공유되는 가변 상태를 표현하는 것에 있다.
- Actor는 class, struct와 마찬가지로 타입이다.
- class와 같은 reference type이다.
- 상속은 지원하지 않는다.
- Sendable 프로토콜을 지원한다. ⭐️⭐️
Actor의 특징 ⭐️⭐️
- Actor의 특징을 설명하기에 앞서, Data Race가 왜 생겨나는지부터 곰곰이 생각해보자.
- Data Race가 발생하는 이유는 두 개 이상의 스레드가 “서로 공유하고 있는 데이터에 접근”하여 “데이터를 수정하고자 할 때 발생”한다. 즉, 데이터에 접근하되 "데이터를 수정하지 않고 읽기만 한다면” 아무런 문제가 발생하지 않는다. ⭐️⭐️ 그렇다면 공유 데이터에 한 번에 하나씩만 접근하게 한다면? 결과적으로 Data Race를 막을 수 있지 않은가?
- 이렇게만 한다면 Data Race가 발생할 일은 없다. 그리고 Actor가 바로 이 특징을 가지고 있다! Actor에서는 동시에 하나의 Task만 접근할 수 있도록 되어 있다. 즉, Actor 내에서 사용되는 속성은 여러 스레드에서 동시에 접근할 수 없다. ⭐️⭐️
- Actor는 Data Race를 피하기 위해 “잠시동안 호출코드를 기다리게 할 수 있다.” 이를 위해 await 코드를 사용한다. ⭐️⭐️
- Actor 외부에서 Actor에 있는 “변수” 나 “메소드”에 접근한다면 await이 반드시 필요하다. ⭐️⭐️
- 또한 Actor는 가장 우선순위가 높은 작업을 먼저 실행한다. (Actor Reentrancy)
actor SharedWallet {
let name = "공금 지갑"
var amount: Int = 0
init(amount: Int) {
self.amount = amount
}
func spendMoney(ammount: Int) {
self.amount -= ammount
}
}
Task {
let wallet = SharedWallet(amount: 10000)
let name = wallet.name
let amount = await wallet.amount
await wallet.spendMoney(ammount: 100)
await wallet.amount += 100
}
Actor Reentrancy ⭐️⭐️
혹시 잘못된 부분이 있다면 댓글로 피드백 주시면 감사하겠습니다!
출처
https://zeddios.tistory.com/1290
https://sujinnaljin.medium.com/swift-actor-%EB%BF%8C%EC%8B%9C%EA%B8%B0-249aee2b732d
Actor에서 await을 썼다면 이후 상태가 어떻다고 섣불리 단정하지 말라고 하죠
await된 사이에 어떤 일이 일어날 지 모르니까요
마치 냉동인간이 해동 됐더니 세상이 변해버렸다는 이야기처럼요
라인 엔지니어링 블로그에서 GCD랑 Concurrecy를 일종의 부하테스트를 통해 성능을 비교한 게 있는 데 관심 있으시면 읽어보시는 것도 좋을 것 같습니다