Swift

[Swift] Swift의 동시성 프로그래밍 (2) | Swift Concurrency의 등장 (feat. async-await & Coroutine & Continuation)

하이D:) 2024. 3. 24. 15:27

앞선 Swift의 동시성 프로그래밍 (1) 글에서 본 것처럼 Swift 5.5에서 Swift Concurrency 개념이 등장하기 이전에는 GCD 기술을 이용해서 멀티 스레드 환경을 관리해 주었다. 그리고 completionHandler와 클로저를 사용해서 비동기 작업 처리를 해주곤 했다. 

 

하지만..! 근데 이런 식의 비동기 프로그래밍은 비동기 작업이 많을 때에 오류처리를 하기 매우 복잡하고 가독성이 좋은 코드를 짜기 어렵다.

 

 

 

 

이렇게 GCD 와 completion handler를 사용하는 코드에 있어서 여러 한계점과 문제점이 있었는데 이를 해결하기 위해 등장한 것이 바로 Swift Concurrency 이며  WWDC2021 에서도 소개된 async/await 이다!

 

 

 

 

 

그럼 본격적으로 completion handler 사용의 문제점 및 Swift Concurrency의 도입 이유와 함께async/await의 동작 과정에 대해 알아보자!

 

 

[Swift] Swift의 동시성 프로그래밍 (1) | GCD

[Swift] Swift의 동시성 프로그래밍 (2) | Swift Concurrency의 등장 (feat. async-await & Coroutine & Continuation)

[Swift] Swift의 동시성 프로그래밍 (3) | async/await (WWDC 2021)

 

 

 

# GCD & completion handler의 문제점과 새로운 동시성 생태계인 Swift Concurrency의 도입이유

 

 

예시 코드는 apple에서 작성한 async-await의 propsal에 대한 글에서 발췌해서 설명해 보겠다

https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md

 

 

📌 기존 비동기 처리에서 어떤 문제점이 있었을까?

  • 중첩되는 들여 쓰기로 인한 가독성 저하
  • 개발자의 실수로 인해 발생할 수 있는 오류
  • 클로저에서 self 캡처로 인해 순환 참조(retain cycle) 발생가능성
  • 복잡한 에러 핸들링 

 

 

 

아래에서 코드로 이 문제점을 살펴보면..

하나의 함수 안에서 여러 개의 비동기 처리를 해주어야 한다고 가정할 때!

 

 

 

 

 

콜백 기반 코드는 중첩 들여 쓰기로 완료 or 오류 처리를 해주어야 하는 것을 볼 수 있죠? 

 

그리고 그런 과정 중에 개발자라 비동기 처리 작업 중 하나의 case에서 완료 혹은 에러 처리를 하나 빼먹었다고 하더라도 , 즉, completionBlock에 대한 호출을 누락하더라도 Swift는 이런 문제를 눈치채지 못하고 그냥 앱을 빌드해 버리죠.

그걸 그냥 배포했을 때 앱 사용자는 영문도 모르고 에러를 맞닥뜨릴 수 있습니다.. (이럼 진짜 큰일..)

 

// (2a) Using a `guard` statement for each callback:
func processImageData2a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource, error in
        guard let dataResource = dataResource else {
            completionBlock(nil, error)
            return
        }
        loadWebResource("imagedata.dat") { imageResource, error in
            guard let imageResource = imageResource else {
                completionBlock(nil, error)
                return
            }
            decodeImage(dataResource, imageResource) { imageTmp, error in
                guard let imageTmp = imageTmp else {
                    completionBlock(nil, error)
                    return
                }
                dewarpAndCleanupImage(imageTmp) { imageResult, error in
                    guard let imageResult = imageResult else {
                        completionBlock(nil, error)
                        return
                    }
                    completionBlock(imageResult)
                }
            }
        }
    }
}

processImageData2a { image, error in
    guard let image = image else {
        display("No image today", error)
        return
    }
    display(image)
}

 

 

 

 

그리고 클로저는 클래스와 같이 "참조타입"이며, 힙영역에 저장되고 ARC 모델을 통해 메모리 관리가 되는데

 

클로저 내부에서 self를 사용해야 할 때 self에 해당하는 인스턴스클로저 가 서로를 참조하는 상태인 retain cycle(순환 참조)이 일어날 수 있는 위험이 있다. 강한 순환 참조에서는 인스턴스에 nil을 할당해도 RC가 0이 되지 않는 상황, 즉, 메모리 관리에 실패하여 memory leak의 위험성이 있는 것..

 

그렇다고 메모리 누수를 해결하기 위해 약한 참조로 [weak self] 를 많이 쓰면 인스턴스에 대한 reference count는 올리지 않지만 가리키고 있는 인스턴스가 nil인지 추적하는 작업 때문에 오버헤드가 발생할 수도 있다고 합니다?!!

 

 

 

 

 

위의 문제를 조금이나마 해결하기 위해 Result타입을 사용해 본다고 하면 어떨까?

밑에 코드를 보면 Result를 사용했을 때 오류를 처리하는 것이 더 쉽지만 completionBlock에 대한 호출을 누락할 수 있는 것과 클로저 중첩을 해야 하는 문제는 여전하다.

// (2b) Using a `do-catch` statement for each callback:
func processImageData2b(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        do {
            let dataResource = try dataResourceResult.get()
            loadWebResource("imagedata.dat") { imageResourceResult in
                do {
                    let imageResource = try imageResourceResult.get()
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        do {
                            let imageTmp = try imageTmpResult.get()
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        } catch {
                            completionBlock(.failure(error))
                        }
                    }
                } catch {
                    completionBlock(.failure(error))
                }
            }
        } catch {
            completionBlock(.failure(error))
        }
    }
}

processImageData2b { result in
    do {
        let image = try result.get()
        display(image)
    } catch {
        display("No image today", error)
    }
}

 

 

 

 

 

이렇게 보니 참.. 가독성도 그렇고 위에 설명한 것처럼 문제가 많은 것 같아요 그죠??

 

 

그래서 Swift는 문법적 패턴을 도입해서 보다 명확하고 안전하게 문제를 해결하고자 했습니다. 그게 바로 async/await에 대한 개념이죠!

 

 

 

 

 

 

 

# async/await와 Coroutine

 

일단 async/await를 적용하면 코드가 얼마나 단순해지는지 아래를 보면 알 수 있습니다!

위와 같은 비동기 코드인데 마치 동기 코드인 것처럼 작성할 수가 있어요!!

 

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
}

 

 

  • 이렇게 async 키워드를 사용해서 비동기적으로 동작하는 비동기 함수를 만들 수 있는데,
    • => 이때 비동기 함수는 코루틴(Coroutine) 으로 만들어집니다

 

📌 그렇담 Coroutine이란?

  • 함수가 동작하는 도중 특정 지점에서 suspend(일시정지)할 수 있고 resume(재개)할 수 있도록 하는 것!
  • 비동기 함수가 일시정지(suspend)될 수 있는 지점을 알려주는 장치가 바로 await 키워드인 것!

 

** 위키피디아에서의 정의 : 코루틴은 실행을 일시 중지하고 재개할 수 있는 컴퓨터 프로그램 구성 요소

 

 

 

 

 

 

 

# async/await에서의 Continuation이란?

async 키워드로 만들어진 비동기 함수는 일시정지 & 재개될 수 있는 Coroutine으로 만들어진다고 했는데

 

여기서 추가적으로 Continuation이라는 개념이 등장한다.

 

둘 다 Co..blah blah 여서 헷갈리지만 굴하지 않고 Continuation에 대해 알아보자!!

 

 

 

 

📌 Continuation?

  • "연속성"이라고 직역할 수도 있는 Coroutine은 함수의 컨텍스트!! 를 "추적"!! 하는 객체이다
    • await 키워드로 인해 일시정지suspend 될 수 있는 지점을 알려주고 다시 resume 될 수 있는.. 이런 복잡한 컨텍스트를 가질 수 있는 async 비동기 함수는 Cotinuation 덕분에 task가 일시정지되어도 적절한 지점에서 스레드 제어권을 다시 task에 넘겨 resume 될 때 suspension point로 돌아갈 수 있는 것이죠!
  • async로 만들어진 비동기 함수 내부의각 task에 함수 컨텍스트를 추적하는 continuation가 할당됩니다!
    • continuation가 할당되는 시점은 await 키워드를 만나 각 task가 suspend 될 때라고 합니다!

 

 

 

아래 코드에서는 loadWebResource, loadWebResource, decodeImage, dewarpAndCleanupImage 각각의 task 앞에 이 과정에서 일시정지될 수 있다는 것을 알리기 위해 await키워드가 붙어있는데

 

task가 suspend 되었을 때 continuation이 할당되는 것!!

그리고 그 task가 다시 resume 될 때 suspension point로 돌아갈 수 있는 것은 모두 continuation 덕분이라는 것!!이다 ㅎㅎ

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
}

 

 

 

 

 

 

 

 

 

# GCD와 Swift Concurrency의 스레드 생성

 

우리는 지금 동시성 프로그래밍에 대한 것을 이해하기 위해 이 모든 것을 정리하고 있다는 점을 망각하면 안 됩니다!

 

그렇다면 동시성 프로그래밍을 위해 존재하는 두가 지 개념인 GCD와 Swift Concurrncy는 각각 스레드를 어떻게 관리하고 있을까?

 

 

 

 

📌  GCD를 사용할 때는 비동기 task마다 스레드를 생성

GCD를 사용할 때는 비동기 task를 실행하면 기존 작업을 suspend 하고 

스레드를 +1 생성한다

 

 

 

 

📌  그렇다면 과연 스레드 수가 많아지면 좋은걸까?

no !! 디바이스의 cpu 코어수보다 많아지면 실제 용량을 초과해 요구하는 셈이 된다(overcommit)

  • → 실행 중이었던 스레드 block( block 된 스레드가 다시 실행되기를 기다리면서 가지고 있는 메모리 및 리소스 때문에 메모리 오버헤드 가능성)
  • 스레드가 과도하게 생성돌 경우 빈번한 컨텍스트 스위칭으로 인한 과도한 컨텍스트 스위칭(스케줄링 오버헤드 가능성)

 

 

 

📌  Swift Concurrency (async-await 문법) 에서는 스레드 관리를 시스템 자체에서 처리해 안전성을 보장하고자 한다 (executor개념 등장)

 

  • 어떤 비동기 task가 일시정지(suspend)되면 ⭐️ 시스템에게 스레드 제어권 넘겨줌 ⭐️
  • -> 시스템이 넘겨받은 작업의 우선순위와 실행하기에 적절한 스레드를 고려하여 task가 resume 될 때 스레드 제어권을 task에 다시 넘겨줌
  • GCD와 다르게 함수가 suspend 되는 과정에서 추가적인 스레드의 생성 없이도 작업할 수 있도록 한다.
  • executor가 task를 적절한 스레드로 배정하는 과정이 포함되어 있다.

 

 

 

 

여기 문서에 executor에 대한 자세한 내용이 나오는데 나에겐 다소 생소하고 어려워서 다음에 다시 공부해서 소개해보기로!

https://github.com/apple/swift-evolution/blob/main/proposals/0392-custom-actor-executors.md#introduction

 

 

 

다음 글에서는 aync-await의 동작 방법Coroutine, Continucation이라는 게 어떤 의미를 갖는지 더 자세하게 정리해보려 합니다! :)