asyncawait는 Swift5.5에서 추가된 Concurrency Model로  safe, easy, fast 하게 다루기 위해 나온 기능이다. 

 

 

call-back기반의 비동기 함수의 문제점

기존의 비동기 작업이 끝나는 시점을 completion handler를 통해 알 수 있었는데,

이는 procedual(절차적인) 코드가 아니게 되기에 읽기 어려운 부분이 있다.

 

썸네일을 가져오는 fetchThumbnail 함수가 있다고 정의해 보자. 

fetchThumbnail 함수 내부에서 위와 같은 작업들이 진행된다. 

이 중, dataTaskpreapreThumbnail오래 걸리는 작업이기에 비동기 작업으로 진행되어야 한다. 

 

또한, dataTask의 return 값이 이후의 과정에서 쓰이기 때문에, 

이후의 과정을 dataTask의 completion Handler내부에 구현해야 한다.

 

이를 코드로 옮겨 보게 되면 다음과 같다. 

func fetchThumbnail(for id: String, completion: @escaping (UIImage?, Error?) -> Void) {
    let request = thumbnailURLRequest(for: id)
    
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        if let error = error {
            completion(nil, error)
            
        } else if (response as? HTTPResponse)?.statusCode != 200 {
            completion(nil, error)
            
        } else {
            guard let image = UIImage(data: data!) else {
                return
            }
            
            image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
                guard let thumbnail = thumbnail else {
                    return
                }
                completion(thumbnail, nil)
            }
        }
    }
    task.resume()
}

우선 completion Handler내부에 구현하기에 가독성이 떨어진다. 

 

또한, 위 코드에는 error에 대한 처리가 제대로 이루어지지 않았는데,

해당 부분에 completion을 통해 error를 전달하지 않고 return 하게 된다. 

컴파일러는 이런 "개발자의 실수"로 인한 오류를 감지할 수 없게 된다. 

 

즉 정리하자면 아래와 같다. 

  • Completion Handler가 nested해질 경우 가독성이 떨어진다. 
  • 에러에 대한 모든 케이스에 대해 completion handler를 리턴을 깜빡할 수 있다.
  • 이러한 개발자의 실수를 컴파일러가 알려주지 않는다. 

Swift는 더욱 safety하고 simple하고 fast 하게 하기 위해 async & await를 통해 새로운 concurrency model을 도입하게 되었다.

 

 

async & await

우선 위의 코드를 새로운 concurrency model를 도입하게 되면 아래와 같다. 

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
}

이전 completion Handler코드에 비해 procedual하기 때문에 가독성이 더 좋아졌을 뿐만 아니라, 

try await 키워드를 빼먹은 경우에 컴파일러가 알려주어 safety하게 에러 핸들링이 가능하다. 

 

async

아래와 같이 함수나 메서드에 async를 붙이게 되어 비동기 함수 혹은 메서드란 것을 명시한다.

에러를 반환할 경우 async throws를 사용한다.

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

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

 

위의 코드만 봐도 completion Handler가 없어지고 return type이 명시적으로 표기되어 함수의 목적성이 더 명확해진다. 

 

async 코드는 concurrent context에서만 실행 가능하기 때문에, 

async 함수/메서드는 다른 async 함수/메서드 혹은 Task{}를 통해 호출하여야 한다.

 

await

async 함수/메서드를 호출할 때는 await키워드를 

async throw일 경우 try await를 명시해주어야 한다. 

let thumbnailImage = await fetchThumbnail(for: thumbnailID)

let thumbnailImage = try await fetchThumbnail(for: thumbnailID)

program이 await키워드를 마주치게 되면, 호출된 함수가 suspend 될 수 있다.

이는 시스템에 의해 suspend의 여부를 결정되며,

호출된 함수가 suspend 된다면 caller도 suspend 된다.

 

따라서, await키워드가 있는 곳을 potential suspension Point라 부른다.

 

 

Thread Control 

호출된 함수가 suspend되게 되면, thread control(제어권)을 포기하게 되는데,

이를 sync 함수와 비교해 보자.

sync function

fetchThumbnail함수(caller)에서 thumbnailURLRequest 동기함수가 호출되면,

  • caller는 호출된 함수에게 Thread Control을 넘겨주게 된다.
  • 호출된 함수의 수행을 마치고 return 되면, Thread Control을 다시 caller에게 돌려준다.

 

async function

fetchThumbnail 함수(caller)에서 data(for:)async함수를 호출하게 되면,

(await키워드에서 suspend 되었다고 가정한다.)

  • caller는 호출된 함수에게 Thread Control을 넘겨주게 된다.
  • 호출된 함수는 Thread Control을 포기하고, 이를 System에 넘겨주게 된다. 
  • System은 해당 Thread를 우선순위가 높은 작업부터 처리한다. 
  • 작업이 다 처리되면 System은 Thread Control을 호출된 함수에게 넘겨주게 된다. 
  • 호출된 함수의 resume 되어 수행을 마치고 return 되면, Thread Control을 다시 caller에게 돌려준다.

이때 시스템에 의해 Thread Control을 다시 전달받게 될 때,

할당된 Thread는 이전과 다른 Thread일 수 있다.

 

 

Continuation

Continuation이란 "비동기 호출 후에 일어나는 일"을 의미한다. 

기존 call-back기반 비동기 함수의 경우, completion Handler 내부가,

async 함수/메서드의 경우 await 이후 실행되는 코드들이 Continuation이다. 

 

비동기 함수가 끝나는 시점에 진행할 코드를 실행해야 하기 때문에, Continuation을 획득해야 한다.

새로운 Concurrency Model에서는 Continuation을 다른 방식으로 획득하여 성능적인 개선을 하게 되었다. 

 

성능적인 개선은 다음과 같다. 

  • Thread Control을 다시 넘겨받을 때, 다른 Thread더라도 context Switching이 일어나지 않는다.
  • Thread Control을 포기함으로 인해, Thread block없이 다른 task를 진행할 수 있다.

 

Non-Async Function 

우선 동시성 프로그래밍(1)에서 말하였듯이, 

각 Thread는 고유의 Stack 메모리 공간을 할당받고, 나머지 공간은 process 내에서 공유한다.

 

함수가 호출되게 되면, Stack 영역에 stack frame을 생성하여 쌓이게 된다. 

stack frame에는 함수의 local variable, return address 등 필요한 정보들이 저장된다.

stack 메모리 영역은 Thread마다 할당되기 때문에,

비동기 함수의 completion Handler와 같은 continuation이

다른 Thread에서 수행되게 되면 context Switching이 일어나게 된다.

이는 스케쥴링 오버헤드를 초래하기 때문에, 지정된 Thread에서 처리해야 한다.

 

따라서, 일반적으로 Non-async Function의 continuation은 같은 Thread에서 수행되며, 

concurrency Task가 많을 경우 Thread의 수가 지나치게 많아져 Thread Explosion을 초래한다.

 

Async-Function

다음과 같이 updataDatabase함수에서 add함수를 호출해 보자. (둘 다 async function이다)

// on Feed
func add(_ newArticles: [Article]) async throws {
    let ids = try await database.save (newArticles, for: self)
    for (id, article) in zip(ids, newArticles) {
        articles[id] = article
    }
}

func updateDatabase (...) async {
    // skip old articles ..
    await feed.add(articles)
}

우선 add 함수의 호출로 stack영역에 add를 위한 stack frame이 생성된다.

 

add function 내부에서 await키워드를 만나게 되면,

해당 함수에 대한 정보를 heap 영역에 async frame을 생성하여 저장한다.

heap영역에 add async frame을 생성하고 save가 호출되면, 

stack 영역에선 add stack frame을 save stack frame으로 대체된다.

즉, 새롭게 생성하는 것이 아닌 최상위 stack frame을 대체하게 된다. 

 

따라서, suspension 된 async function은 otherWork를 수행하고, 

continuation을 픽업하여 stack Frame으로 불러와 resume 하게 된다.

Heap 영역은 process내의 모든 Thread가 공유하는 메모리 영역이기 때문에

다른 Thread에서 재게 된다 해도 context Switching 없이 Continuation을 불러오는 것만으로 다시 재게 할 수 있다. 

 

 

id, article 같은 suspension point이후로만 사용이 되고 있기 때문에 tracking 할 필요가 없다.

따라서 stack frame에 저장된다.

 

반면 async frame에는

newArticles와 같은 suspension Point전후로 사용해야 하는 값을 저장한다.

 

call-back 기반 함수에 async 함수 적용 

기존의 call-back기반 비동기 함수에 continuation을 explicit 하게 명시하여 async 함수로 적용이 가능하다. 

우선, call-back 기반 함수를 withCheckedContinuation 메서드로 감싸주어, contination을 생성한다.

error throw를 하고 싶다면 withCheckedThrowingContinuation 메서드로 감싸준다.

 

감싸주게 되면, 해당 부분이 potential suspension point가 된다.

func asyncDataTask(for id: URLRequest) async throws -> UIImage {
    
    // potential suspension point
    return try await withCheckedThrowingContinuation({ continuation in
        URLSession.shared.dataTask(with: id) { (data, res, err) in
            ...
        }
    })
}

 

withCheckedThrowingContinuationwithCheckedContinuation은 

클로져를 통해 Continuation인 CheckContinuation 구조체를 전달한다.

func withCheckedContinuation<T>(
    function: String = #function,
    _ body: (CheckedContinuation<T, Never>) -> Void
) async -> T


func withCheckedThrowingContinuation<T>(
    function: String = #function,
    _ body: (CheckedContinuation<T, Error>) -> Void
) async throws -> T

 

이렇게 전달받은 continuation의 resume메서드를 호출하여suspension 된 비동기 함수를 재게 한다.

func asyncDataTask(for id: URLRequest) async throws -> UIImage {

    return try await withCheckedThrowingContinuation({ continuation in
        URLSession.shared.dataTask(with: id) { (data, res, err) in
            if let err = err {
                continuation.resume(throwing: err)
            } else if (res as? HTTPURLResponse)?.statusCode != 200 {
                continuation.resume(throwing: FetchError.badID)
            }
            
            ...
            
            continuation.resume(returning: image)
        }
    })
}

 

 

get async properties

read-only property일 경우 awaitable 할 수 있다.

struct CellData {
    let id: String

    var thumbnailImage: UIImage {
        get async throws {
            return try await fetchThumbnail(for: id)
        }
    }
}
let imageData = try await someCell.thumbnailImage

 

 

동기 컨텍스트에서 비동기 호출

동기 context에서 직접 async function을 호출할 수 없는데, 

이는 앞서 말했듯이, async 함수/메서드는 concurrency context에서만 호출이 가능하기 때문이다. 

따라서, 다른 async 함수/메서드에서 호출해야 했다. 

 

하지만, Taks{}를 통해 동기 context에서도 async function의 호출이 가능하다. 

TaskConcurrenct Context를 제공하여 내부에 async 작업들을 수행가능하게 한다.

func someFunction(for id: URLRequest) {
    Task {
        let resultImage = try await asyncDataTask(for: id)
    }
}

 

 

Reference

WWDC 21

Apple Documentation

Understanding async/await in Swift - Andy ibanez

Converting closure-based code into async/await in Swift  - Andy ibanez

복사했습니다!