GGURUPiOS

동시성 프로그래밍 - Concurrency (1) async/await 본문

Swift/동시성 프로그래밍

동시성 프로그래밍 - Concurrency (1) async/await

꾸럽 2023. 4. 24. 15:06

Concurrency

Concurrency는 Swift 공식문서 정리할 때도 한 번 봤었는데 그 때 헷갈렸던 내용을 이번에 제대로 정리하고자 함

Concurrency 는 Swift 5.5에서 구현된 새로운 기능으로, 비동기적인 코드 작성을 보다 쉽게 만들어 줌

이를 위해 몇 가지 새로운 기능과 개념이 도입됨

공식문서에 있는 내용들

크게 세부분으로 나눌 수 있음

  • async/await
  • structured concurrency
  • actors

Async/await 등장 배경 (그동안의 문제점? 정도)

자세한 건 하나하나 알아보겠습니다.


명시적 콜백(completion, completionHandler)를 사용하는 비동기 프로그래밍 에는 많은 문제가 크게 5가지가 있음

1. Pyramid of doom

간단한 비동기 작업은 종종 깊게 중첩된 클로저를 가짐 ( 콜백 지옥 )

아래의 예와 같이 간단한 이미지를 보여주는 작업에도 많은 중첩 구조가 생길 수 있음

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)
}

이러한 “Pyramid of doom” 은 읽고 추적하기 어렵게 만듬

2. Error Handling

콜백은 오류 처리를 어렵고 장황하게 만듬

// (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)
}

위의 예제와 같이 ( se 깃헙 본문 에서는 do-catch 구문을 사용한 에러처리, switch 구문을 사용한 에러처리 모두 구현되어있음 )

코드가 가독성이 떨어지고, 중첩된 구조로 인해 장황해짐

3. Conditional execution is hard and error-prone ( 조건부 실행이 어렵고 에러 발생이 쉬움 )

비동기 함수를 조건부로 실행하는 것은 어려움

func processImageData3(recipient: Person, completionBlock: (_ result: Image) -> Void) {
    let swizzle: (_ contents: Image) -> Void = {
      // ... continuation closure that calls completionBlock eventually
    }
    if recipient.hasProfilePicture {
        swizzle(recipient.profilePicture)
    } else {
        decodeImage { image in
            swizzle(image)
        }
    }
}

나중에 쓰일 코드(swizzle)를 미리작성하기 때문에 top-down approach 를 망침

연속 클로저의 캡쳐링 부분도 신중하게 생각해야 함

4. Many mistakes are easy to make ( 실수할 경우가 많음 )

completionBlock 호출을 잊는다던가, return 문을 잊는다 던가하는 실수가 많을 수 있음

컴파일러에서는 completionBlock 을 빼먹어도 에러를 뱉지 않기 때문에 나중에 디버그하기 어려울 수 있음

func processImageData4a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource, error in
        guard let dataResource = dataResource else {
            return // <- forgot to call the block
        }
        loadWebResource("imagedata.dat") { imageResource, error in
            guard let imageResource = imageResource else {
                return // <- forgot to call the block
            }
            ...
        }
    }
}

5. Because completion handlers are awkward, too many APIs are defined synchronously

비동기 API 정의 및 사용의 어색함으로 인해 차단할 수 있는 경우에도 많은 API 가 동기적인 동작으로 정의 되었다고 생각 됨


아래의 내용은 WWDC2021 에 나온 Meet async/await in Swift 영상 내용을 정리했습니다

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

async/await 소개

  • async/await 는 장황하고 복잡한 부정확한 비동기 코드를 더 나은 코드로 도움을 줄 수 있음
  • 일반 코드를 작성하는 것 만큼 쉽게 작성 가능 ( 더 안전함 )
  • SDK에는 수백가지 메서드가 있음

아래는 위 챕터에 있는 문제점에 대한 예시
이미지를 String → UIImage로 바꿔주는 fetchThumbnail 메서드를 가정

< String 값을 UIImage로 바꾸는 메서드 >

뷰모델에서의 동작 순서

  1. viewmodel 의 thumbnailURLRequest() 를 통해 URLRequest로 변환
  2. dataTask(with:completion:) 메서드로 request의 data를 가져옴
  3. UIImage(data:) 메서드로 UIImage로 변환
  4. UIImage를 렌더링 하는 prepareThumbnail(of:completionHandler:) 메서드 호출

각 항목은 그 전 메서드의 결과에 의존하고 있음 → 순서대로 실행 되어야 함

이러한 작업 중 일부는 값을 바로 반환 (동기 코드임) ( 위에서는 thumbnailURLRequest, UIImage(data:)등 )

하지만 데이터를 다운로드 하는 작업같은 경우 시간이 소요됨

→ 비동기 작업 필요

async/await 가 나오기 전에는 보통 completionHandler를 사용해왔음

< 위의 예제를 completion을 사용해 잘못 구현한 예시 1 - completion 호출 잊음 >

< 수정된 코드 >

첫 예시에서는 guard문 에 익숙해서, completion의 호출을 잊음 (에러를 안던짐)

→ completion 호출을 잊었지만 컴파일러에서는 오류가 없으니 컴파일은 됨

→ completionHandler의 호출과 확인은 작성자에게 달려있음

이러한 점을 Result 타입을 써서 조금 더 안전하게 만들 수 있음

→ 하지만, 코드를 더 길게 만듬

결론: async/await 를 사용해서 더 나은 코드를 만들 수 있음


async function 문법

선언

func fetchThumbnail(for id: String) async throws -> UIImage {

}
  • async 키워드를 throw 전에 위치
  • 만약 throw 하지 않으면, 화살표(→) 앞에 위치
  • completionHandler대신 try 키워드를 사용하여 선언

호출

  • throw - try 처럼, async 함수에는 await 키워드가 필요함
  • 만약, async throw 함수라면 호출은 모두 try await 처럼, await 앞에 try 를 써줘야 함

예시) 만약 위의 completion 예제를 async throw 함수로 바꾼다면 아래와 같음

completion 예제와 비교했을 때

  • completionHandler 를 사용한것과 달리 무조건 오류 혹은 반환 값을 던짐
  • 20줄의 코드 대신 6줄로 훨씬 짧고 명확해짐
  • 6줄의 코드는 모두 직선적임 (가독성이 높음)

async 프로퍼티

위의 예제에서 guard let thumbnail = await maybeImage?.thumbnail 에 await호출이 가능한 것은 익스텐션에 읽기전용 프로퍼티를 구현해서 가능함

두가지 주의할 점

  • 명백한 async키워드를 사용한 게터가 있다 → 프로퍼티 게터 또한 throw 가 가능함. async 함수와 마찬가지로 만약 async throw 프로퍼티라면 async 키워드는 throw 전에 위치함
  • 프로퍼티에는 setter 가 없다. 오직 읽기 전용 프로퍼티만 async 프로퍼티가 될 수 있음

async function 은 중단될 수 있다는 의미

await키워드는 async 함수, 프로퍼티, 이니셜라이저, 시퀀스 등 많은 곳에서 쓰일 수 있음

await는 무슨의미 일까?

async 함수를 통해 무슨일이 일어나는지 알아보자

일반함수(동기함수)의 스레드 제어

어떤 함수를 호출할 때 함수가 동작하는 동안 스레드의 제어권 가짐

만약 일반 함수라면 (thumbnailURLRequest) 스레드는 해당 함수의 작업을 수행하는 데 완전히 사용됨

그 작업은 함수의 본문 안에 있을 수도 있고, 그걸 호출한 다른 함수에 있을수도 있음

결국 값을 반환하거나 오류를 발생시켜 함수가 종료됨

그러면 스레드 소유권을 다시 호출한 함수로 넘겨줌

비동기 함수의 스레드 제어

일단 실행되면, 비동기 함수는 일시 중단되고 스레드에 대한 제어권을 포기함

함수에 제어권을 부여하는 대신 시스템에 제어권을 제공함 ( 함수도 일시중단 )

그러면 시스템은 우선순위에 따라 다른 작업을 수행하고, 이전에 중단된 함수의 차례가 오면 다시 시작됨

이제 비동기 함수는 다시 스레드를 제어하고 계속 작업할 수 있음

그러나, 일시 중단 할 필요가 전혀 없을 수 도 있음

일시 중단 될 수 있지만 async 로 표시되어 있다고 해서 일시중단 되는것은 아님

또한, await 를 본다고 해서 그 함수가 확실히 거기서 일시중단 되는것도 아님

그러나 결국에는 값 또는 오류와 함께 스레드 제어를 다시 넘겨줌

async/await 중요 특징

  1. 함수를 async 로 표시하면 suspend(일시중단 되는 것) 을 허용 한다
  2. 함수가 스스로 중단되면, 그걸 호출한 호출자도 중단 됨 ( 따라서 호출자 또한 async 여야함 )
  3. async 함수에서 한 번 또는 여러 번 일시 중단할 수 있는 위치를 가리키기 위해 await 키워드 사용
  4. async 함수가 일시 중단된 동안, 스레드는 차단되지 않음 ( async 함수가 중단된 동안, 앱의 상태가 변경될 수 있음을 말함 )
  5. async 함수가 다시 시작되면 async 함수가 호출한 결과가 원래 함수로 다시 이동하고 중단된 곳에서 바로 실행이 계속 됨

위의 4번에 대해서 이 영상에서 설명하는 개념이 있는데 Continuations 라고 함

async/await 구문을 사용하여 코드 실행을 일시 중단하고, 결과를 반환하거나 에러를 처리한 후 다 시 실행을 재개하는 개념

영상의 마지막에서는 비동기 API 중 하나이고 Continuation을 직접 수동으로 생성, 처리하는 withUnsafeThrowingContinuation 와 withUnsafeContinuation 에 대해 설명하고 있음


요약: async/await 키워드로 어떻게 런타임에 작동하는지 , 어떻게 작성하는지에 대해 간단히 알아봄

다루지 못한 async Sequence 는 나중에 wwdc에 나온 영상을 보면서 다시 정리할 예정

참고

Meet async/await in Swift - WWDC21 - Videos - Apple Developer

Documentation