2021년 Apple 에서는 Swift Concurrency 를 소개하면서 async/await, Task, structured concurrency, Actor 와 같은 많은 언어적인 특징들을 도입했습니다.
Apple에서는 Concurrency에 대해 이렇게 설명합니다.
Concurrency : Perform asynchronous and parallel operations
동시성 : 비동기 및 병렬 연산 수행
기본적으로, async로 표시된 함수는 비동기적으로 실행될 수 있는 함수이며,
await는 해당 비동기 함수의 결과가 필요할 때까지 실행을 일시 중지하고 결과를 기다립니다.
다들 콜백(call back)지옥 이라는 단어를 한번쯤 들어 보셨을거라 생각합니다.
처음에는 괜찮을지도 모르지만
중첩된 형태의 callback 함수(Completion Handler)를 여러번 사용하다 보면 일명 callback 지옥이 시작됩니다.
이런식으로 말이죠.
이러한 중첩된 형태의 callback 함수는 코드의 가독성을 해칠 뿐만 아니라 Error Handling의 복잡성과 개발자의 실수 등 여러 문제를 야기할 수 있습니다.
이러한 복합적인 문제를 해결하기 위해 Swift 5.5부터 Swift Concurrency가 등장하였다고 보시면 됩니다.
여기서 잠깐 GCD에 대해 짚고 넘어가자면,
GCD(Grand Central Dispatch)는 C기반의 저수준 API로 Apple의 다중 스레드 프로그래밍을 위한 기술입니다.
GCD는 비동기적으로 작업을 수행할 수 있는 큐(Queue)를 제공합니다.
이 큐(Queue)는 우리가 알고 있는 DispatchQueue와 같다고 이해하고 넘어가시면 됩니다.
아래 예시 코드를 통해 다시 한번 비동기로 수행되는 함수에 @escaping 속성으로 탈출 클로저를 전달하여,
비동기 Task 이후에 수행할 작업을 Completion Handler로 처리하는 구체적인 코드를 살펴봅시다.
func addNumWithGCD(completion: @escaping (Int) -> ()) {
DispatchQueue.global().async {
var result: Int = 0
for i in 1...10 {
result += i
}
completion(result)
}
}
func callGCDFunction() {
addNumWithGCD(completion: { result in
print(result)
})
}
callGCDFunction() //55
addNumWithGCD(completion:)
함수의 내부에는 1부터 10까지 수를 더하는 과정이 DispatchQueue를 통해 비동기로 수행되고
callGCDFunction()
함수에서는 addNumWithGCD(completion:)
함수를 호출하고 매개변수 completion으로 탈출 클로저를 전달하여
비동기 Task가 종료된 이후에 수행할 작업을 수행하고 있습니다.
위의 과정을 DispatchQueue를 사용하지 않고 async와 await 만을 이용하여 코드를 작성하면 다음과 같습니다.
func addNumWithAsync() async -> Int {
var result: Int = 0
for i in 1...10 {
result += i
}
return result
}
func callAsyncFunction() async {
print(await addNumWithAsync())
}
Task {
await callAsyncFunction() //55
}
DispatchQueue로 인한 들여 쓰기가 없어지고 매개변수로 탈출 클로저를 전달하지 않아도 되기 때문에 코드가 간결해지고 가독성도 향상된 모습을 볼 수 있습니다.
특히 중첩된 형태의 Completion Handler를 async와 await로 구현한다면 그 효과는 극대화 될 것입니다.
async와 await를 사용한다면 Completion Halder를 사용하지 않아도 비동기 Task가 완료된 시점에 비동기 함수가 호출된 위치(Suspension Point)로 돌아와 그 다음 코드를 이어서 진행할 수 있도록 보장하고 개발자 입장에서는 코드를 마치 동기 코드처럼 작성할 수 있어 개발에 편리함을 줄 수 있습니다.
지금부터 async / await 문법을 어떻게 사용하는지 코드 라인을 따라 하나씩 살펴 봅시다.
func addNumWithAsync() async -> Int
함수 이름 뒤에 async라는 키워드를 사용하면 해당 함수를 비동기로 실행하겠다는 의미입니다.
만약 함수 수행 중에 Error가 발생할 가능성이 있어 throws 키워드를 사용해야 한다면 아래의 코드와 같이 async 키워드 뒤에 throws 키워드를 사용하도록 합니다.
func addNumWithAsync() async throws -> Int
추가적으로 async로 선언된 함수는 다른 async로 선언된 함수 내부, 혹은 Task 구조체의 이니셜라이저로 전달되는 클로저 내부와 같은 asynchronous context
에서만 호출될 수 있습니다.
Task에 대한 설명은 아래에서 추가적으로 다루겠습니다.
async로 선언한 함수를 호출하기 위해서는 아래의 코드와 같이 await 키워드와 함께 호출해야 합니다.
func callAsyncFunction() async {
print(await addNumWithAsync())
}
참고로 await 뒤에 호출되는 함수는 반드시 비동기로 수행되는 함수가 와야합니다.
위에서 언급한 것과 같이 async 함수를 호출하기 위해서는 asynchronous context
내부에서만 호출할 수 있고 결국 await 또한 asynchronous context
내부에서만 사용될 수 있습니다.
만약 await로 호출하는 함수가 Error 발생의 가능성이 있다면 아래의 코드와 같이 await 앞에 try 키워드를 사용합니다.
func callAsyncFunction() async {
do {
try await addNumWithAsync()
} catch {
print(error)
}
}
await 키워드를 사용하여 async 함수를 호출하게 되면 해당 지점을 Suspension Point라고 합니다.
Apple에서는 Suspension Point에 대해 다음과 같이 설명합니다.
A suspension point is a point in the execution of an asynchronous function where it has to give up its thread
Suspension point는 비동기 기능을 실행할 때 스레드를 포기해야 하는 지점이다.
비동기 함수의 실행 지점인건 알겠는데, 해당 스레드를 포기해야 한다는 것은 무슨 의미일까?
이것을 이해하기 위해서는 동기와 비동기 함수에서 thread 제어권이 어떻게 이동하는지 이해할 필요가 있습니다.
먼저 callAsyncFunction() 함수에서 addNum()이라는 동기 함수를 호출 한다고 가정했을때 thread 제어권의 흐름은 다음과 같습니다.
callAsyncFunction() 함수에서 시작된 thread는 동기 함수인 addNum() 함수의 호출과 함께 thread 제어권을 해당 함수로 넘겨 주게되고 addNum() 함수가 종료 되었을때 다시 callAsyncFunction() 함수로 thread 제어권을 넘겨주게 됩니다.
이런 경우 동기 함수에 thread 제어권을 넘겨 주었기 때문에 동기 함수가 종료 될때까지 thread는 다른 작업을 수행할 수 없게 됩니다.
다음으로 callAsyncFunction() 함수에서 비동기 함수인 addNumWithAsync()를 호출했을때 thread 제어권의 흐름은 다음과 같습니다.
위에서 설명한 것과 같이 await를 통해 비동기 함수를 호출하게 되면 그 지점이 Suspension Point가 되고 해당 함수를 호출한 thread의 thread 제어권을 System에게 넘깁니다.
System은 해당 thread 제어권을 가져와 다른 Task에 할당하여 다른 Task를 수행하다가 비동기 함수가 완료 되었을때 다시 thread 제어권을 돌려 줌으로서 비동기 함수 호출 이후의 작업을 이어서 수행할 수 있습니다.
위에서 언급 했듯이 async 함수는 다른 async 함수 내부 혹은 Task 구조체 이니셜라이저로 전달되는 클로저 내부와 같은 asynchronous context
에서만 호출 가능하다고 했습니다.
그렇다면 Task와 TaskGroup이 무엇인지 알아보도록 합시다.
Apple 개발자 문서에 소개 되어있는 Task의 정의는 다음과 같습니다.
Task
A unit of asynchronous work.
비동기 작업 단위 (A unit of asynchronous work)
Task는 비동기로 수행할 작업을 클로저를 통해 전달하여 asynchronous context
를 생성합니다.
전달된 작업들은 Task 인스턴스 생성과 동시에 수행하게 됩니다.
이해를 위해 위의 코드를 다시 가져오자면 아래와 같습니다.
Task {
await callAsyncFunction() //55
}
또 다른 예로 SwiftUI에서 View의 instance method로 task라는 modifier를 사용할 수 있는데 해당 View가 생성될때 수행할 비동기 작업을 수행할 수 있습니다.
Image(systemName: "info.circle")
.task {
await callAsyncFunction()
}
Apple 개발자 문서에 소개 되어있는 TaskGroup의 정의는 다음과 같습니다.
TaskGroup
A group that contains dynamically created child tasks.
동적으로 생성된 하위 태스크가 포함된 그룹
taskGrou안의 task들은 공통의 parent task를 가지고 각각의 task는 역시 child task를 가질 수 있습니다.
taskGroup 을 사용하면 그 taskGroup에 child task를 "add" 하여서 개발자가 task의 우선순위와 task의 cancellation을 통제할 수 있다.
class TaskGroupTest {
func test() async {
// starts a new scope that can contain a dynamic number of child task
await withTaskGroup(of: Data.self) { taskGroup in
let photoNames = await listPhotos(inGallery: "Summer Vacation")
for name in photoNames {
taskGroup.addTask {
await self.downloadPhotos(name: name).data(using: .utf8)!
}
}
}
}
taskgroup을 통해 시간이 소요되는 작업(task)이 비동기적으로 수행되게 하고 task가 모두 끝날 때까지 await한 후에 그 결과물로 이어 작업을 할 수 있습니다.
여기서 withTaskGroup
함수 of: 에는 각 child task가 반환하는 결과값의 Type을 넘깁니다.
body 부분에는 아래와 같은 closure가 들어가는데 위의 코드와 함께 보면, closure의 param에는 taskGroup이 넘어오므로,
이 taskGroup 안에 내가 원하는 task를 addTask 하면 그 child task들이 실행됩니다.
mutating func addTask(
priority: TaskPriority? = nil,
operation: @escaping () async -> ChildTaskResult
)
addTask의 operation 안에서 "비동기적으로 처리되기 원하는, 복잡하고 시간이 소요되는 작업"을 수행하면 됩니다.
주의할 점이 있다면 childTaskResult로 반환되는 값의 type이 당연히 of: 의 type 과 동일해야 합니다.
위의 샘플 코드의 경우 Data가 반환되는 것을 확인할 수 있습니다.
레퍼런스:
https://sujinnaljin.medium.com/swift-actor-뿌시기-249aee2b732d
https://velog.io/@jeunghun2/Swift-async-await
https://velog.io/@yujinj/Concurrency-TaskGroup