본문 바로가기

iOS/Concurrency

[Swift 5.5] GCD의 문제? Swift 5.5 Concurrency model 특징과 async/await 개념 정복하기!! | Concurrency

 
안녕하세요. 이번 포스트는 Swift 5.5 WWDC async/await에서 새롭게 등장한 async/await에 대해서 공부를 하고 배웠던 내용을 정리하려고 합니다. Async/await는 WWDC를 통해 새롭게 알게 되었는데 원래 async/await pattern이 있고 여러 프로그램에서 디자인 되었다는 것도 새롭게 알게 되었습니다. Structured concurrency원칙을 기반으로 Swift 5.5 concurrecy가 설계 되었습니다. 기존에 존재하는 concurrenct APIs가 있음에도 불구하고 새로운 Concurrency model이 도입된 이유가 궁금했었는데, 그 이유중 하나로 async/await은 코드 길이가 줄어지고, 단순해지는 등의 장점이 있다는 것을 알게 되었습니다.
 

Swift에서 기존에 존재하던 asynchronous APIs 문제?

Swift 5.5이전까지 저는 GCD랑 Combine을 많이 사용했습니다. 비동기적인 처리로는 notificationCenter, Combine, Rx등 더 있긴한데,, 동시성 까지 포함하는 GCD를 예로 들자면, DispatchQueue를 통해 직접 thread 제어를 하지 않고도 queue에 sync(), async()함수를 통해 tasks를 dispatch함으로 알아서 thread 관리와 tasks 실행이 됩니다. GCD의 disaptchWorkItem을 사용한 task를 관리하면 cancel도 가능한데 왜 Swift 5.5에선 GCD를 대체할 수 있는 새로운 concurrent APIs를 개발했을까요..
GCD's queue-based model은 대표적으로 3가지 문제가 있습니다.

 

  • Thread explosion 

동시에 너무 많은 threads 생성은 활동적인 thread와 끊임없는 switching을 해야 합니다. (앱 속도 저하) queue는 그에 맞는 thread를 생성 -> 분배해야 하는 책임이 있습니다.(thread를 재사용할 수도 있지만,,)

 

  • Priority inversion

GCD의 QualityofService(QoS)는 특정 queue에서 high priority-tasks의 completion을 위해 lower-priority의 system resources를 뺐습니다. 

 

  • Lack of execution hierarchy

Asynchronous code block은 계층적인 수행이 없습니다. 즉 각각의 tasks는 독립적으로 관리됩니다. 이는 cancel, 수행중인 tasks간 접근하기 어려워집니다. 수행중인 task가 더이상 필요로 하지 않을 경우에 대한 조건 처리를 dispatchWorkItem의 cancel을 하지 않는 한 해당 task는 계속해서 수행됩니다.(컴파일러가 인지 불가능) 하지만 Stuctured concurrency(tasks의 계층) 덕에 async/await를 사용한 tasks는 (사용자가 이미지 로딩 중인 화면을 나갔을 때) 이미지 로딩 중인 task가 취소될 수 있습니다.(SwiftUI에서)
 
기존 multi thread의 단점인데, asynchronous code가 실행되면 코드가 control을 포기하기 전까지 CPU core를 정상적으로 회수할 수 없습니다. (더 이상 task수행 필요하지 않아도 resource소비하며 task를 실행합니다.) 반면 Swift 5.5 concurrency model은 await를 통해 중간 중간 suspension point가 있고, tasks의 계층 구조가 있습니다. suspension point에서 특정 코드가 await 덕에 suspended, resume을 반복할 때 런타임에서 확인할 수 있고 cancel할 수 있는 기회까지 있습니다.
 
GCD의 특징 말고도 Swift 5.5 이전에 존재하는 대부분의 asynchronous APIs의 completionHandler는 거의 클로저를 통한 callback을 사용했습니다. 
 
GCD를 사용해 동시적으로 tasks를 작업할 경우 해당 task가 언제 끝날 지 모르기 때문에 callback 클로저를 통해 완료 처리를 하거나 에러 처리 기능까지 포함할 수 있는 Result<Success,Failure>를 사용합니다. 대부분의 비동기를 지원하는 APIs 중 예를들어   dataTask(with:completionHandler:)의 경우에도 마찬가지로 서버에서 data를 fetch 완료했을 경우 사용할 수 있는 completionHandler 클로저 또한 callback 메서드로 구현되어 있습니다.
 
기존에 존재하는 APIs들 대부분의 공통적인 특징은 callback 구조입니다. Callback 구조로 인한 블럭이 중첩되는 코드는 한눈에 알아보기 어려움이 있습니다. 중간 중간 error throw 작성 안하는 실수를 할 때, 컴파일러가 인지하지 못해 아무런 변화가 일어나지 않는 상황에 빠질 수도 있습니다. 더 많은 정보는 이 링크에서 공부하시면 좋을 것 같습니다. 또한 WWDC Meet async/await in Swift에서도 정말 상세하게 나옵니다.

Swift 5.5 Concurrency model

기존에 있던 asynchronouls APIs 의 불편함과 GCD의 단점을 보완하기 위해서 새로운 동시성 모델이 WWDC에서 소개되었습니다. 이들의 새로운 특징은 크게 약 4가지가 있습니다. 

1. A cooperative thread pool

New Swift 5.5 concurrency model은 threads pool을 관리하는데, 이용가능한 CPU core 수를 초과하지 않도록 관리합니다. Runtime 때, thread switching에 끊임없이 많은 비용을 들이지 않고 thread의 생성, 삭제 할 필요가 없습니다. 기본적으로 정의된 스레드를 최대한으로 활용한다는,, 그대신 code가 pool 안에 있는 특정 thread에서 빠르게  suspended, resume될 수 있습니다.

2. A cooperative thread pool

Compile, runtime 환경에서 코드가 suspend, resume 될 지 알 수 있습니다. Runtime이 균일하게 처리하기 때문에 굳이 threads와 cores에 대해 걱정하지 않아도 됩니다.
기존의 asynchronous APIs는 callback를 많이 사용한다고 했는데 클로저는 캡쳐를 할 가능성이 있고 약한참조를 통해 이를 강한 참조가 발생되지 않는 주의를 기울여야 합니다.
Swift 5.5에서 등장한 async, await는 이런 걱정을 할 이유가 없습니다. @escaping closure를 거의 선언하지 않고 synchronous func처럼 연산 처리 된 결과 값을 반환하면 되기 때문입니다.

3.  Structured concurrency

이전 포스트에서 좀 공부를 해봤는데 계층 구조를 강화했고, parent, child task간 자연스러운 흐름이 가능해서 cancel도 가능하고 우선순위 또한 상위 task로부터 상속받을 수 있습니다. DispatchGroup.wait() 처럼 parent task's completion전에 child tasks들이 전부 끝나야 기다릴 수 있도록 할 수 있습니다.

4. Context-aware code compilation

Compiler가 asynchronously code 수행을 지속적으로 tracking합니다. 그래서 mutating share state에 대해서, 잠재적으로 발생가능한 thread-unsafe한 code를 사전에 방지해줍니다. 그게 바로 actor(개념 정리 포스트)입니다. State isolation을 통해 mutable state를 actor내부에서만 처리되도록 함으로 concurrent problem을 방지할 수있다는 장점이 있습니다. 

Swift 5.5 async/await

Swift 5.5 concurrency APIs에서 actor, globalActor, AsyncSequence등의 개념이 있지만 가장 많이 사용되는 개념은 async/await일 것입니다. 기존에 존재하는 asynchronous APIs는 대부분 callback 클로저로 완료 헨들러 처리를 하는데, 이번에 새로 도입된 async/await 버전 따로 라이브러리에 함수들이 추가 됬습니다. 

기존 asynchronous func, async func 차이

https://developer.apple.com/documentation/foundation/urlsession/1407613-datatask

 
(completionHandler.. @escaping(비동기적으로, 이 함수를 실행한 이~후~에, 추후에 데이터를 받아오기 때문에 클로저가 나중에 수행됩니다.) @Sendable(actor에서 사용될 수있습니다.)) 기존 asynchronous API 중 하나입니다.
 

https://developer.apple.com/documentation/foundation/urlsession/1407613-datatask

 
async가 적용된 새로운 async API입니다.
 
이들은 공통적으로 Data, URLResponse 받아서 차후의 작업을 수행하거나 Data, URLResponse를 반환하거나, 에러를 던집니다. 차이점이라면 많이 존재합니다.

기존 asynchronous func

전자의 경우 클로저를 통해서 dataTask()가 완료되었을 때 escaping 됬던 completionHandler 클로저가 호출 된다는 점입니다. Thread가 dataTask()를 호출했을 때 바로 completionHandler가 호출되지 않고 우선 dataTask()가 리턴됩니다. 언젠간 completionHandler가 실행됩니다. completionHandler가 호출 될 그 언젠가는 데이터 request작업이 완료 됬을 때 입니다. 이런 특징 덕분에 현재 실행중인 코드의 실행점이 역으로 위로 올라가는 상황이 발생할 수 있고 디버깅 할 때도 까다롭습니다.
 

https://developer.apple.com/videos/play/wwdc2021/10095/

이미지를 fetch하는 코드의 대략적인 흐름입니다.
 
대부분 기존의 asynchronous APIs func는 @escaping closure 타입의 completionHandler가 있습니다. 그 이유는 asynchronous func가 실행되고 함수가 메모리에서 해제된 후 thread가 asynchronous func 아래에 있는 코드를 수행하는 상황에서, 함수의 인자값으로 지정된 @escaping closure타입의 completionHandler는 클로저의 특징과 @escaping을 이용해 메모리에 남아 있을 수 있기 때문입니다.
이 덕에, dataTask의 url에서 data를 받아오는 데 일정 이상의 시간이 필요함으로 나중을 기약하며 dataTask(with:completionHandler:)의 두번째 매개변수로 작성한 completionHandler 클로저가 실행되는 것을 끝까지 기다리지 않고 아래의 코드를 실행해 나갑니다. dataTask()를 호출했을 당장 data가 받아지지 않았기에 우선적으로 dataTask() 다음 코드인task.resume()를 실행해 나간다!! 상당히 효율적입니다.
 
무엇보다 중요한 것은 클로저 안에서 data 처리를 한 후에 또 다시 fetchPhoto 매개변수인 completionHandler로 성공적으로 data를 UIImage로 반환한 값을 전송하도록 호출해야 합니다. fetchPhoto가 직접 UIImage를 반환시킬 수없는 이유는 dataTask(with:completionHandler:) 완료 후 수행 시점이 두 번째 매개변수 클로저 '' 이라는 점이기 때문입니다.
 
이 클로저가 언제 실행될지 모르기 때문에 fetchPhoto에 UIImage 객체를 만들어 dataTask()가 완료된 시점에 값을 대입해도 이미 fetchPhoto 빈 값을 반환하고 종료됬을 가능성이 있습니다... 그래서 클로저 안에서 처리를 하고 또 @escaping 타입의 completionHandler에 변환한 값을 인자값으로 호출해야 하는 것입니다.
 
한 번 @escaping을 사용하면 해당 클로저는 비동기적으로 호출되기 때문에 내부의 값을 받기 위해서는 @escaping을 계속해서 사용하게 되고, 코드는 피라미드 구조를 띄게 됩니다. 클로저의 장점과 @escaping의 장점도 있지만 이를 중첩으로 계속해서 사용하게 될 경우 아래와 같은 코드에서 많이 발견할 수 있습니다.

func processImageData1(completionBlock: (_ result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}
processImageData1 { image in
    display(image)
}

 
각각의 경우마다 에러 처리도 해야하기 때문에 코드 길이가 늘어나게 됩니다. 만약 실수로 에러 처리를 하지 않았을 경우 컴파일러도 인지를 하지 못하기 때문에,, 어디서 에러가 났는지 모를 수 있는 상황이 발생될 수 있습니다.

Combine framework

컴바인의 경우는 어떨까요?

 

enum DogsError: Error {
    case invalidServerResponse
    case unsupportedImage
}

 

기본적인 에러를 정의하고,,

func fetchPhoto(url: URL) -> AnyPublisher<UIImage,DogsError> {
    return URLSession.shared
        .dataTaskPublisher(for: url)
        .tryMap { (data,response) -> UIImage in
            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                throw DogsError.invalidServerResponse
            }
            guard let image = UIImage(data: data) else {
                throw DogsError.unsupportedImage
            }
            return image
        }
        .mapError {
            return $0 as! DogsError
        }
        .eraseToAnyPublisher() 
}

 

dataTaskPublisher와 Combine의 연산자를 사용합니다. 각각의 연산자는 또 다른 publisher를 통해  downstream으로 값을 흘려보냅니다. 위의 asynchronous dataTask() 함수와 다르게 dataTaskPublisher는 dataTask를 완료했을 때 각각의 operators를 실행시킵니다. 근데 아래에서 보게 될 async/await를 사용했을 때가 더 코드가 간결합니다...

Swift 5.5 async func

https://developer.apple.com/videos/play/wwdc2021/10095/

 
이 경우엔 async/await을 통해 이미지를 fetch하는 코드입니다. 크으.. Top-down으로 코드가 분명 실행됩니다. 마치 synchronous func의 로직 흐름처럼,, 분명 async라고 명시되어 있는데 말입니다.
 
앞에서 언급한 기존 asynchronous func는 에러가 발생할 때마다 이 또한 fetchPhoto 매개 변수 completionHandler의 인자값으로 전달을 했습니다. fetchPhoto에 completionHandler를 많이 작성했습니다.(코드 길이 늘어남)
 
반면 위 사진의 경우 에러 발생시 즉시 throw 해버리면 됩니다. Throw가 발생되지 않으면 성공적으로 image를 반환합니다. 하지만 분명히 url로부터 데이터를 받아오는데 일정 이상의 시간이 필요합니다. 그리고 위에서 말했듯 기존의 asynchronous API는 callback 메서드나 Result를 활용했습니다. 
 
이 경우에는 await라는 키워드를 사용해 대체합니다. 여기서 알아야 할 것이 있습니다. async, await, task의 개념을 알아야 합니다.

async

async는 throws키워드처럼 함수에 사용됩니다. 

func defineAsyncFunc() async -> Void { ... }

async를 표기하면 함수가 asynchronous한 상태임을 나타냅니다. 함수 내부는 일반적인 함수(synchronous func), async func 둘 다 사용할 수 있습니다. 다만 async func를 사용하기 위해선 await 키워드를 함께 사용해야 합니다.
평범한 함수(viewDidLoad() 등)나 main thread 등 synchronous한 공간에선 await 키워드를 붙여 async func를 사용할 수 없습니다. Asynchronous가 작업할 수 있는 환경 Task()안에서 또는 async func 내부에서만 사용해야 합니다. 그리고 async 타입의 func 내부 코드는 top-down, synchronous하게 동작합니다. 디버깅, 코드 훑어볼때, profiling할 때 등 여러모로 유리한 점이 많습니다.
 
Synchronous 함수처럼 async type func 또한 top-down 이후 함수가 반환됩니다. 차이점은 synchronous func는 이 함수를 호출한 thread, stack의 혜택을 온전히 사용해 함수의 내부 로직을 실행할 수 있습니다. 반면 async type func 는 stack을 포기하고 그들 자신의 분리된 저장소를 사용합니다. async func만이 점유중인 thread를 포기할 수 있습니다.(suspend)

await

func asyncTask() async { ... }
func defineAsyncFunc() async -> Void { 
	print("async func start")
    await asyncTask() // suspend, resume 반복 이 코드가 수행되야 다음 코드 수행됩니다.
    print("async func end")
}

 
await은 async한 함수가 asynchronous한 공간(async 타입 func 내부, Task 내부)에서 사용될 때 반드시 사용해야 하는 키워드입니다. await를 표기한 지점은 suspension point입니다. 위에서 설명했지만, 일반적인 synchronous func와 달리 await 키워드는 점유중인 thread를 포기하고(해당 함수 실행을 하지 않는다는 의미입니다.) 우선 suspend 상태(물론 상황이 좋으면 바로 수행될 수도 있습니다)에 진입합니다.(해당 코드 로직 수행x) 그리고 차후에 수행해야 할 시점의 제어권을 system에게 맡겨 system 상황에 따라서 resume, suspend를 반복하면서(실행 권한은 system 마음입니다.) 비동기적으로 실행됩니다. 이런 이유 때문에 await!! 표기를 한 것입니다.
 
"System, 할 일이 많은거 알고 있는데 시간 날때 data(for: request) async func 수행해줘!!" 요런 느낌입니다.
 

https://developer.apple.com/videos/play/wwdc2021/10132/

 
await 키워드를 사용한 코드는 runtime때 suspended, resume을 반복하며 system resource를 잘 점유하여 처리됩니다. 근데 await를 사용한다고 반드시 곧 바로 정지 상태가 아니라 상황에 따라서 바로 실행될 수 있는데, (제 기준에서,,)보통은 suspend가 됩니다. 만약 suspend 상태가 됬을 때, 해당 await 코드 아래에 코드가 존재한다고 해도 thread는 아래의 코드를 실행하지 않습니다.
await의 async func 작업이 완료해야 그 이후의 코드가 실행 됩니다. await 덕분에 synchronous한 동작처럼 코드가 실행되고, 맨 마지막의 경우엔 결과를 or 중간에 throwing error를 도출할 수 있습니다. 기존에 존재했던 callback을 통한 결과 반환과 다른 점입니다.
 
await 키워드와 suspension point 덕분에 system은 우선순위를 끊임없이 바꿀 수 있습니다. 그리고 cancel 하기도 수월합니다.
 

https://developer.apple.com/videos/play/wwdc2021/10132/

 
깨알 요약을 하자면, async 가 붙은 func는 해당 함수가 실행중일 때 suspend가 반복적으로 될 수 있습니다. async func 내부에 sync, async func 사용 가능한데 async func를 사용할 땐 await를 사용해야 합니다. await는 suspension point를 의미합니다.
 
기존 비동기 처리 코드를 async로 바꾸는 대표적인 예는 WWDC가 정말 자세하게 알려주는 것 같습니다.

Task

Asynchronous func가 작업할 수있는 환경을 구성해주는 unit입니다. Task는 취소할 수 있고 완료될 때 까지 기다릴 수 있습니다. actor 관점에서 isolated state인지 non-isolated state인지 구별하는 것처럼, async 관점에서 context는 async한 공간, non-async한 공간으로 구분지을 수 있습니다. Synchronous context에서 async func가 실행되야 할 때 반드시 asynchronous한 공간이 조성되야 하는데 이를 Task가 담당하게 됩니다.

...
override func viewDidLoad() {
    //Main thread
    
    defineAsyncFunc() // error. non-async context
    Task() {
    	// asynchronous context.
        await defineAsyncFunc() // async는 당연히 시스템에 의해 관리되니까 await키워드를 붙여야 합니다.
    }
}

 
Task는 이를 호출한 actor에서 수행됩니다. 기본적으로 Task(priority:operation:)을 통해 우선순위도 지정할 수 있는데 우선순위를 지정하지 않는다면 현재 Task() {...}를 수행한 context의 priority를 기본적으로 상속받습니다. Task() 호출한 context의 priority를 기본적으로 상속받지 않으려면 Task.detached(priority:operation:)을 사용하면 됩니다.
 
요게 value가 있는데 Combine과도 믹스가 잘 됩니다. 그밖에도 취소 등 다양한 기능을 지원합니다.
 
앞에서 언급했지만 asynchronous execution은 Swift 5.5의 async/await, GCD, notificationCenter, rx, combine 등 여러 가지 방법이 있습니다. await 표기가 붙은 함수가 resume, suspend를 반복한 끝에 수행됬을 때, await 이후의 코드들을 계속해서 진행합니다.
 
Swift 5.5의 async/await나 AsyncSequence는 asynchronous execution을 할 수 있다는 것입니다. 하지만 동시성을 사용할 때는 async let이나, TaskGroup를 활용해야 합니다. 동시성을 사용하다보면 동시간대에 서로 다른 thread에서 shared mutable state를 access하는 상황이 있는데 actor를 통해 해결할 수 있습니다.

Swift 5.5 다른 포스트

Actor No1. actor, thraed-safe, actor's serial executor 개념 정리 포스트
Actor No2. actor, actor isolation, cross-actor reference 개념 정리 포스트
Sendable protocol 개념 정리 포스트
Structured concurrency 개념 정리 포스트
 

References 

https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md
https://developer.apple.com/videos/play/wwdc2021/10095/
https://developer.apple.com/videos/play/wwdc2021/10132
https://www.kodeco.com/books/modern-concurrency-in-swift

 

Modern Concurrency in Swift

Master Swift’s modern concurrency model! For years, writing powerful and safe concurrent apps with Swift could easily turn into a daunting task, full of race conditions and unexplained crashes hidden in a massive nesting of callback closures. In Swift 5.

www.kodeco.com