Swift Concurrency 문법 관련 내용은 이전 포스트 내용 참조
과거 Objective-C 시절부터 동시성 프로그래밍을 위해 오랜 세월 존재했던 GCD(Grand-Centeral-Dispatch)를 제쳐두고 Swift 5.5 버전과 함께 혜성처럼 등장한 Swift Concurrency는 초기 iOS 15 버전 이상부터 사용할 수 있었지만 추후 업데이트를 통해 iOS 13 버전 이상으로 사용할 수 있는 버전이 확대되었다. 왜 Apple은 GCD라는 도구를 뒤로하고 Swift Concurrency를 도입했을까?
이전 포스트에서 GCD에서 발생하는 코드 문법적인 문제에 대해서 다루었기 때문에, 이번에는 Computer Science의 성능적인 측면에서 GCD가 가지는 문제점에 대해서 알아보려고 한다.
동시성 프로그래밍을 위한 도구인 GCD는 여러 종류의 Queue에 작업을 할당하면 시스템이 동적으로 관리하고 필요에 따라 스레드를 생성하거나 기존 스레드를 재사용하여 작업을 수행한다. 특히 Concurrent Queue는 말그대로 동시적으로 작업을 실행하게 되고, GCD는 Concurrent Queue에 할당된 작업을 동시적으로 처리하기 위해 CPU에서 가용할 수 있는 최대의 스레드를 생성 후 각 스레드에 작업을 할당한다. 그럼에도 불구하고 Concurrent Queue에 더 많은 작업이 할당되어 있다면, GCD는 추가적으로 스레드를 더 생성하게 된다.
오..! 더 많은 스레드는 다다익선..?
CPU 능력 이상의 스레드를 가진 상태에서 동시적으로 작업을 수행할 수 있는 것은 사실 하나의 CPU가 여러 스레드를 번갈아 가면서 실행하기 때문에 가능하다. 이것을 Computer Science에서는 Context Switching이라고 한다. Context Switching은 우리가 눈치채지 못할 정도로 빠른 시간 안에 일어나기 때문에 우리는 이것이 마치 동시에 실행되고 있는 것처럼 느끼게 된다.
하지만 Context Switching이 매우 짧은 시간 안에 이루어진다 해도 스레드의 수와 스위칭 횟수가 많아질수록 당연히 그만큼 오버헤드가 증가하게 된다. 이러한 오버헤드는 전체 시스템의 성능 저하로 이어질 수 있으며, 특히 스레드 간 빈번한 전환이 필요한 경우에는 퍼포먼스를 저하 시키는 원인이 된다.
Swift에서는 이렇듯 불필요하게 많은 스레드가 생성되는 것을 Thread Explosion이라고 표현한다.
Swift Concurrency가 기존 GCD에 비해 가지는 장점은 다음과 같다.
Swift Concurrency에서는 GCD에서 문제가 되었던 Thread Explosion을 방지하기 위해 여러 스레드를 생성하는 대신 딱 CPU 코어 수만큼 스레드를 생성하고 Continuation이라는 객체를 사용하여 CPU에서 Context Switching으로 발생할 수 있는 오버헤드 또한 사라지게 된다. Continuation에 대한 내용은 아래에서 추가적으로 다룰 예정이다.
그렇다면 Swift Concurrency에서는 어떻게 작업을 동시적으로 수행할 수 있는걸까?
가령 아래의 코드는 네트워크 요청이 발생하는 비동기 함수에서 Swift Concurrency를 사용한 코드와 이 함수의 스레드 제어권을 도식화한 그림이다.
func fetchThumbnail(for id: String) async throws -> UIImage {
let request = thumbnailURLRequest(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badID }
let maybeImage = UIImage(data: data)
guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage }
return thumbnail
}
기본적으로 일반적인 함수가 호출되면 이 함수는 완전히 종료될 때까지 스레드 제어권을 가지게 된다. 따라서 해당 함수가 종료되기 전까지 다른 작업을 수행할 수 없게 되는 것이다.
하지만 Swift Concurrency 문법을 사용하여 await
키워드를 만나면 해당 지점은 Suspension Point가 된다. Suspension Point는 스레드 제어권을 잠시 시스템에 반납하고 await
키워드 뒤에 위치한 async
함수가 완료될 때까지 기다린 뒤, 이 async
함수가 완료 되었을 때 다시 가용한 스레드로부터 스레드 제어권을 받아와 이후 작업을 재개할 수 있게 된다.
위의 그림은 Swift Concurrency를 사용했을 때 CPU와 스레드의 모습을 형상화한 그림이다. 기존 GCD를 사용할 때와 달리 CPU 코어당 하나의 스레드를 다루게 되면서 Thread Block이 존재하지 않고 그로 인한 Context Switching 또한 사라졌다. 대신 Continuation이라고 하는 특정 지점에서 함수 실행 컨텍스트를 추적할 수 있는 객체만이 자리하고 있다. 다시말해, Continuation은 Suspension Point에서 동작을 재개하기 위한 정보를 담고 있는 객체이다.
Suspension Point에 의해 스레드 제어권을 시스템에 반납하게 되면 해당 스레드는 시스템에 의해 자유롭게 다른 작업을 수행할 수 있는 상태가 된다. 심지어 동일 스레드 내에서 다른 작업으로 전환되기 때문에 함수 호출로 인한 비용만이 발생할 뿐이다.
await
키워드 이후에 발생한 Suspension Point에 의해 재개되는 시점에 await
키워드 이전과 동일한 스레드에서의 동작을 보장하지 않는다. 스레드 제어권은 시스템이 가지고 있기 때문에 Suspension Point 이후 작업될 작업에 대해서는 시스템에서 가용한 적절한 스레드를 할당해 주기 때문이다. 이것은 데이터의 원자성이 깨질 수 있다는 의미이며, 따라서 다른 스레드에서 접근해도 공유자원을 보호할 수 있는 NSLock
또는 Actor와 같은 동기화 로직 사용에 주의해야 한다.
WWDC21 Meet async/await in Swift
WWDC21 Swift concurrency: Behind the scenes