GCD와 Swift Concurrency
GCD와 Swift Concurrency인 async/await를 비교하는 포스팅은 이미 몇 개 작성했습니다.
이번 포스팅에서는 성능에 초점을 맞춰서 어떤 차이가 있는지 알아보겠습니다.
문법 비교
여기에서도 다루긴 했지만
GCD와 Swift Concurrency의 문법은 어떤 차이가 있는지
한 번 더 짚고 넘어가기로 하죠.
가독성
기존 escaping 클로저와 completion handler를 이용한 비동기 코드는 가독성이 떨어집니다.
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)
}
}
}
}
}
위 코드는 콜백의 개수가 많아져 Depth가 지나치게 깊어진 예시입니다.
Swift Concurrency를 이용하면 이를 해결할 수 있습니다.
func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i : Image) async throws -> Image
func processImageData() async throws -> Image {
let dataResource = try await loadWebResource("dataprofile.txt")
let imageResource = try await loadWebResource("imagedata.dat")
let imageTmp = try await decodeImage(dataResource, imageResource)
let imageResult = try await dewarpAndCleanupImage(imageTmp)
return imageResult
}
Swift Concurrency는 비동기 코드를 동기 코드처럼 작성할 수 있어
가독성면에서 압도적인 이점을 보여줍니다.
에러 핸들링
completion handler를 이용한 에러 핸들링은 여러 문제가 있었습니다.
첫 번째는 에러 핸들링을 잊어버렸을 때 컴파일 에러가 발생하지 않는다는 점입니다.
func processImageData2a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
loadWebResource("dataprofile.txt") { dataResource, error in
guard let dataResource = dataResource else {
completionBlock(nil, error)
return
}
dewarpAndCleanupImage(dataResource) { imageResult, error in
guard let imageResult = imageResult else {
completionBlock(nil, error)
return
}
completionBlock(imageResult)
}
}
}
guard let 안에 에러 핸들링 코드인 completionBlock(nil, error)가 있습니다.
모두 필수적인 에러 핸들링이지만 코드를 삭제한다고 컴파일 에러가 발생하지는 않습니다.
개발자도 사람인지라 언제든지 빼먹는 실수를 할 수 있기 때문에
컴파일러가 이러한 에러를 잡아주지 않는다는 것은 단점으로 적용됩니다.
두 번째는 결과값 반환과 에러 핸들링의 구분이 어렵다는 것입니다.
위 코드에서는 모든 에러 핸들링 코드를 통과하면
그제서야 completionBlock(imageReslut)로 데이터 전달을 합니다.
하지만 둘 모두 completionBlock(~~~)으로 작성되어 자세히 들여다보기 전까지는 구분하기 쉽지 않습니다.
모든 비동기 처리 코드가 이런 방식이라면 코드를 읽을 때 피로감이 상당할 것입니다.
Swift Concurrency를 사용하면 두 개의 문제를 모두 해결 가능합니다.
func processImageData2a() async throws -> Image? {
let (data, error) = try await loadImageData("dataprofile.txt")
guard let dataResource = dataResource else {
throw DownloadManagerError.networkFail
}
guard let image = UIImage(data: data) else {
throw DownloadManagerError.invalidData
}
return image
}
약간은 축약하여 Swift Concurrency로 변경해보았습니다.
에러 핸들링은 throw로, 데이터 반환은 image로 명확히 구분됩니다.
guard let의 에러 throw를 잊어버리면 컴파일러가 알려줘 실수를 방지할 수도 있습니다.
동기화 처리(데이터 레이스 방지)
completion handler는 컴파일 단계에서 데이터 레이스를 막아주지 않습니다.
이를 해결하기 위해서는 sync를 이용해 순서를 보장하거나 뮤텍스(mutex), 세마포어(semaphore)를 이용해야 합니다.
Swift Concurrency는 데이터 레이스의 위험이 있을 때 컴파일 에러를 발생시켜 미연에 방지할 수 있습니다.
(이건 데이터 레이스와 함께 따로 포스팅 예정)
성능 비교
성능 측면에서는 어떤 차이가 있는지 알아보겠습니다.
Thread 성능 비교
thread explosion이란 스레드가 과도하게 많아지는 현상을 말합니다.
스레드가 많아지면 Context Switching이 과하게 일어나고
이는 스케줄링 오버헤드로 인해 성능 저하가 발생합니다.
따라서 thread explosion을 막기 위해 하나의 DispatchQueue를 사용하는 것을 권장합니다.
하지만 Dispatch Queue의 async는 새로운 스레드를 생성하여 작업을 할당하므로
개발자는 이를 고려하여 안전한 코드 작성이 필요합니다.
Swift Concurrency는 보다 편하게 스레드를 관리할 수 있습니다.
Swift Concurrency에서 await로 작업이 중단됐을 때
CPU가 Context Switching을 해서 다른 스레드를 불러오는 것이 아니라
같은 스레드에서 다음 함수를 실행시킵니다.
즉, 하나의 코어가 하나의 스레드를 실행하도록 유지하는 것을 보장합니다.
GCD에서는 스레드의 Context Switching으로 진행되었지만
Swift Concurrency는 같은 스레드 내의 함수 호출로 진행되는 것입니다.
Thread 성능 테스트
먼저 정확한 context switching은 확인하지 못한 점 양해 부탁드립니다.
라인 테크블로그 글을 가이드 삼아 진행하였으니
제가 한 테스트는 참고만 해주시고 더욱 정확한 결과는 링크를 타고 확인하시면 됩니다.
(혹시 Context Switching까지 확인할 수 있는 방법에 대해 아신다면 댓글로 알려주세요...
Xcode에서 이리저리 시도해봤는데 결국엔 못 했네요 ㅠㅠ)
GCD
GCD는 아래 코드로 확인해보았습니다.
func gcdConcurrency() {
let backgroundWorkItem = DispatchWorkItem(qos: .background) {
let arr = (0..<self.time).map { num in self.time - num }
let _ = arr.sorted(by: <)
}
let group = DispatchGroup()
let queue = DispatchQueue(label: "GCD", qos: .background, attributes: .concurrent)
for _ in 0..<100 {
queue.async(execute: backgroundWorkItem)
}
group.wait()
}
하나의 DispatchWorkItem은 100만 개의 역순으로 만들어진 Int형 배열을 오름차순 정렬합니다.
이 작업을 100개 생성하여 DispatchQueue에서 비동기로 진행합니다.
Xcode의 프로파일을 통해 확인해보면
여러 개의 Thread가 생기고 각각의 Thread에서 오름차순 정렬을 수행합니다.
정확히 어떤 지점에서 Context Switching이 발생했는지는 모르겠으나,
여러 Thread를 왔다갔다하며 작업된 것을 통해
Context Switching이 빈번하게 일어났다는 것을 간접적으로 알 수 있었습니다.
Swift Concurrency
Swift Concurrency는 아래 코드로 진행했습니다.
func swiftConcurrency() {
Task {
await withTaskGroup(of: Double.self) { group in
for _ in 0..<100 {
group.addTask(priority: .background) {
await self.sortArray()
return 0
}
}
}
}
}
func sortArray() async {
await Task.yield()
let arr = (0..<self.time).map { num in time - num }
await Task.yield()
let _ = arr.sorted(by: <)
}
여기에서 주의할 점은 새로운 async 함수를 구현할 때에는 중단 지점을 지정해줘야 한다는 것입니다.
이를 위해 sortArray에 Task.yield()를 작성해주었습니다.
Task.yield()를 통해 스레드를 할당받거나 양보하는 지점을 정해줄 수 있습니다.
만약 await를 이용한 중단 지점이 함수 내에 없으면 비동기가 아니라 동기로 동작합니다.
이에 대한 프로파일 결과는 아래와 같습니다.
Thread의 수와 Context Switching 수가 월등히 적을 것을 확인할 수 있습니다.
위에서 소개한 라인 테크 글의 결과를 가져와보면
무려 10배의 차이가 발생했습니다.
이렇게 동시성 프로그래밍을 할 때 DispatchQueue 대신 Swift Concurrency를 사용하면
스레드 생성량이나 Context Switching을 줄일 수 있을 것입니다.
DispatchQueue에서도 DispatchSemaphore를 이용해 최대 스레드 할당 개수를 설정하여
thread explosion을 방지할 수 있습니다.
우선순위 반전
GCD의 Dispatch Queue는 QoS를 설정하더라도
Queue의 자료구조 특성상 우선순위 역전이 발생할 수 있습니다.
예를 들어 Background QoS인 태스크들이 큐에 추가된 후 UserInitiated 태스크가 추가됐다고 해봅시다.
이런 경우 GCD는 앞에 있는 background 태스크들의 우선순위를 user initiated로 높여서
새로 추가된 태스크가 앞선 태스크에 의해 너무 오래 기다리지 않도록 해줍니다.
GCD도 나름의 해결 방법이 있지만
Swift Concurrency는 조금 더 근본적인 문제를 해결했습니다.
Dispatch Queue는 큐의 FIFO 특성때문에 앞의 태스크의 우선순위를 높이는 방식으로 문제를 해결했습니다.
Swift Concurrency는 큐가 아니기 때문에 우선순위가 높은 태스크를 먼저 실행시킬 수 있습니다.
위의 예시를 직접 테스트해보겠습니다.
let backgroundWorkItem = DispatchWorkItem(qos: .background) {
print("Background Start")
for _ in 0..<100000 { }
print("Background End")
}
let userinitiatedWorkItem = DispatchWorkItem(qos: .userInitiated) {
print("UserInitiated Start")
for _ in 0..<100000 { }
print("UserInitiated End")
}
let queue = DispatchQueue(label: "Queue", qos: .userInitiated, attributes: .concurrent)
for _ in 0..<10 {
queue.async(execute: backgroundWorkItem)
}
queue.async(execute: userinitiatedWorkItem)
backgroundWorkItem 10개를 먼저 Queue에 넣고
userinitiatedWorkItem을 Queue에 넣었습니다.
우선순위대로 수행이 된다면
마지막에 넣은 userinitiatedWorkItem부터 수행이 되야 합니다.
하지만 결과는 모든 Background Start가 모두 출력되고 마지막에 UserInitiated Start가 출력됩니다.Concurrent Dispatch Queue여도 작업이 시작되는 순서는 FIFO이기 때문에User Initiated 작업이 가장 늦게 시작된 것입니다.
같은 상황을 Swift Concurrency로 수행해보겠습니다.
for _ in 0..<10 {
Task(priority: .background) {
print("Background Start")
for _ in 0..<100000 { }
print("Background End")
}
}
Task(priority: .userInitiated) {
print("UserInitiated Start")
for _ in 0..<100000 { }
print("UserInitiated End")
}
GCD와 동일하게 background Task 10개를 먼저 등록하고 userInitiated Task를 등록했습니다.
우선순위대로 작업이 시작한 것을 알 수 있습니다.Swif Concurrency는 작업이 실행되는 순서가 FIFO가 아닙니다.먼저 추가된 작업이 실행되고 있을 떄 우선순위가 더 높은 작업이 동시성 작업에 추가되면해당 작업을 먼저 실행한 뒤 우선순위가 낮은 작업을 수행합니다.
이렇게 Swift Concurrency는 우선순위 역전을 방지할 수 있습니다.
참고
https://engineering.linecorp.com/ko/blog/about-swift-concurrency
https://velog.io/@hansangjin96/CS-Deadlock
https://developer.apple.com/documentation/swift/task/yield()
https://www.swiftpal.io/articles/how-to-avoid-thread-explosion-and-deadlock-with-gcd-in-swift
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.