본문 바로가기

iOS

[Swift] Modern Concurrency Swift ( 3편 - async-await 알아보기 )

Modern Concurrency Swift ( 1편 - Thread Class로 Thread 문제 파헤치기 )

Modern Concurrency Swift ( 2편 - GCD와 Operation의 특징과 문제점 요약 )


지난 2편의 글에서는, Thread Class부터 시작하여 GCD 및 Operation까지, iOS에서의 전통적인 멀티스레딩 방식에 대해 알아보았습니다. 본문은 WWDCSwift Evolution을 참고하여 작성했습니다.

async-await

앞서 알아본 기존의 멀티스레딩 방식들이 가진 문제점을, Swift Concurrency가 해결할 수 있는지 알아보기 전에 Swift Concurrency의 기본이 되는 async-await에 대해 알 필요가 있습니다. async-await는 coroutine model을 swift에 도입한 것으로, async와 await를 이용하여 복잡한 비동기 처리 로직을 동기적인 코드의 흐름과 동일하게 구성할 수 있도록 합니다.

 

먼저 async-await가 도입된 동기를 알아보겠습니다.

Motivation: async-await의 필요성

기존의 비동기 처리는 call back 함수 또는 delegate를 사용했습니다. 이는 여러가지 문제점을 가지고 있습니다.

 

1. pyramid of doom : 중복된 콜백으로 인한 가독성 하락

2. Error Handling : 에러 핸들링의 어려움

 

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

Result를 추가하여 조금은 쉬워졌지만, 그래도 복잡합니다.

func processImageData2c(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        switch dataResourceResult {
        case .success(let dataResource):
            loadWebResource("imagedata.dat") { imageResourceResult in
                switch imageResourceResult {
                case .success(let imageResource):
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        switch imageTmpResult {
                        case .success(let imageTmp):
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        case .failure(let error):
                            completionBlock(.failure(error))
                        }
                    }
                case .failure(let error):
                    completionBlock(.failure(error))
                }
            }
        case .failure(let error):
            completionBlock(.failure(error))
        }
    }
}

 

3. Conditional execution : 조건문과 함께 사용하기 어렵고 에러가 나기 쉽다.

 

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

 

4. Many Mistakes : Human Error가 발생하기 쉬운 구조

아래와 같은 구조에서 return문을 실수로 빼먹기 쉽습니다. 컴파일러가 체크해주지 못하기 때문입니다.

 

func processImageData4b(recipient:Person, completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    if recipient.hasProfilePicture {
        if let image = recipient.profilePicture {
            completionBlock(image) // <- forgot to return after calling the block
        }
    }
    ...
}

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
}

1. 비동기 코드의 성능 더 좋습니다.

2. 비동기 코드를 디버깅하기 더 쉽습니다.

3. swift concurrency의 task priority나 cancellation과 같은 기능의 기반이 됩니다.

Potential Suspension Point

async-await의 역할을 알기 위해 가장 중요한 개념 중의 하나는 Potential Suspension Point입니다.

 

그리고 suspending이란 function이 system에게 '현재 시점에서 가장 중요한 일이 무엇인지 결정하고, 그에 따라 내 일의 우선순위를 부여하여 실행해 달라' 라고 말하는 것입니다. 만약 우선순위가 낮다면 해당 함수의 실행이 밀리는, 즉 기다리는(await) 상황이 올 수 있습니다.

 

이를 async와 await가 하는 역할로 살펴보면 :

async : 특정 function이 suspend 될 수 있음을 나타냅니다.

await : async function이 '어디에서' suspend 될 가능성이 있는지를 나타냅니다.

 

쉽게 말해 async는 특정 함수가 비동기 맥락에 진입하여 suspend 될 수 있음, 즉 비동기 함수임을 나타내기 위해 사용됩니다.

그리고 await는 비동기 맥락의 어느 line에서 suspend(기다림)가 발생할 가능성이 있는지를 나타냅니다.

 

아래와 같이 async-await를 활용하는 코드를 생각해 봅시다.

일반적으로 함수가 호출되면, 해당 함수가 실행중인 스레드의 제어를 얻을 수 있습니다. 함수가 종료될 때까지 해당 스레드를 완전히 제어하게 됩니다. 일반적인 함수가 스레드의 제어권을 넘기는 방법은 오직 함수의 '종료'밖에 없습니다. 하지만 비동기 맥락에서 suspending을 이용한다면, 이러한 상황이 완전히 달라집니다.

 

위의 fetchTuhmbnail 함수는 async를 명시하였기에 비동기 컨텍스트에 진입할 수 있고 compiler는 suspending 될 수 있다는 것을 알고 있습니다. 해당 함수 안에서 명령을 실행하다 보니, 'try await URLSession.shared'를 만났습니다. 이 시점에서 함수는 시스템에게 판단을 위임합니다. 일단 function이 suspend되면, 함수는 스레드의 제어권을 포기하고 시스템에 넘깁니다. 해당 스레드는 시스템에 의해 자유롭게 사용될 수 있는 상태가 되기에, 다른 작업에도 사용될 가능성도 있습니다.

 

특정 시점에서 시스템은 suspend 되었던 async function을 재개하는 것이 가장 중요도가 높은 일이라고 판단하면, async function이 resume됩니다. 이 때 async function은 멈췄던 일을 처리하여 결과를 return 할 수 있습니다. 만약 중요도가 높은 일들이 다른 비동기 맥락에서 다량으로 발생한다면, system은 다시 function을 suspend 시킬 수 있습니다.

 

그러나 await가 있다고 해서 반드시 그 지점에 suspend가 발생하는 것은 아닙니다. 만약 시스템의 리소스가 충분하고, async function을 실행하는 일이 가장 시급한 일이 판단되면 바로 resume이 되는 것이죠. 이러한 맥락에서 awiat가 존재하는 곳을 '잠재적인 suspend의 가능성을 가지는 곳'이라는 뜻에서, potential suspension point라 칭하고 있는 것입니다.

 

async를 함수에 명시함으로써, 개발자는 해당 함수가 중간에 suspend 될 수 있으며, 변화하는 시스템의 환경에 의해 다양한 결과로 나타날 수 있다는 것을 인지하게 됩니다.

 

Async-Awiat를 사용할 때 중요한 점

 

1. async 함수를 호출하는 것은 비동기(async) 맥락이어야 한다. async 함수가 suspend되면 caller 또한 suspend되기 때문이다.

2. suspend가 발생할 수 있는 곳에는 await를 통해 명시해야 한다.

3. async function이 suspend 된 동안데, thread는 blocked되는 것이 아니다. 시스템이 이 스레드를 다른 일에 자유롭게 쓸 수 있기에, 해당 작업보다 늦게 호출된 async function이 우선순위에 따라 먼저 처리될 수도 있다. 따라서 suspend 된 동안 시스템의 환경 또는 자원이 다채롭게 변화할 수 있다.

 

맺으며

 

이번 편에서는 asnyc-await의 동작 원리에 대해 간단히 알아보았습니다. 다음 편에서는 swift concurrecny의 continuation이 동작하는 원리와 기존에 존재하던 context siwtching 문제를 어떻게 해결했는지에 대해 다루어 보겠습니다.