[Conference] A crash course of async await

Junyoung Park·2022년 12월 31일
0

Conference

목록 보기
4/5
post-thumbnail

A crash course of async await (Swift Concurrency) - Shai Mishali - Swift Heroes 2022

A crash course of async await

Concurrency

  • 시리얼 큐에 할당도힌 작업보다 더 적은 시간을 소모한 채 동일한 결과물을 가져올 수 있다는 장점 → 이슈는 타이밍

History

  • GCD: 디스패치 그룹, 세마포어, 워크 아이템 등을 통해 특정 비동기 작업의 실행 순서를 지정
  • completion handler: 이스케이핑 클로저를 통해 비동기적으로 갱신되는 데이터를 할당하는 구조, 하지만 클로저의 개수가 많아지고 예외 케이스 등 처리가 복잡해질 때 코드 복잡성이 많아지는 문제점 발생
  • Combine

Sync vs Async

  • 동기: (1). 코드 가독성이 좋고 순서대로 실행됨을 보장. 예측 가능. 간단한 에러 핸들링 (2). 시리얼적이고, 블로킹이 발생. 싱글 스레드
  • 비동기: (1). 컨커런시를 구현 가능. 멀티 스레드 환경에 적합. 리소스 최적화에 있어 뛰어남, 블로킹이 일어나지 않음. (2). 따라가기 어렵고 예측 불가능할 때가 있음. 에러 핸들링이 필수적

Async Await

  • 기존의 컴플리션 핸들러 스타일의 비동기 리턴 방식을 동기적 리턴 방식으로 감싸기
private func loadImage(completion: @escaping (Result<UIImage, Error>) -> ()) {
        // ... return result using completion
    }
  • 컴플리션 핸들러 내부에 리턴할 값을 감싸야 함
private func loadImage() async throws -> UIImage {
      // ... return image
  • async를 따른다고 선언한 뒤 해당 파라미터를 리턴
  • throws는 에러를 스로우할 수 있음을 보여줌
private func testWithCompletion() {
        loadImage { result in
            switch result {
            case .success(let image):
                // handle image
                break
            case .failure(let error):
                // handle error
                break
            }
        }
    }
  • 컴플리션 핸들러를 통해 리턴받은 값을 클로저 내부에서 관리해야 하는 이전 방식
private func testWithAsync() async {
        do {
            let image = try await loadImage()
        } catch {
            // error handling
        }
    }
  • async를 따른다고 선언해야 async, 비동기적으로 리턴하는 위의 함수를 사용 가능
  • throws를 통해 에러를 내보낼 수 있기 때문에 do catch를 통해 에러 핸들링
  • await를 통해 비동기적 함수의 리턴 값이 나올 때까지 기다린 뒤 원하는 태스크를 실행 가능
 private func testWithAsyncAndTask() {
        Task {
            do {
                let image = try await loadImage()
            } catch {
                // error handling
            }
        }
    }
  • async를 통해 리턴하는 비동기 구문을 사용할 수 있는 또 다른 방법은 Task 블럭 내부에서 사용하는 것
private func testWithAsyncAsSync() async {
        do {
            let firstImage = try await loadImage()
            let secondImage = try await loadImage()
            let thridImage = try await loadImage()
        } catch {
            // error handling
        }
    }
  • 비동기 태스크를 연속적으로 실행하는 (것처럼 보이는) 구문
  • 실제로 비동기 함수를 위의 순차적 태스크를 하도록 하는 블럭 내부에서는 각 구문에서 서스펜션이 걸린 뒤, await를 통해 리턴을 받을 때까지 기다림. 이후 resuming이 가능한 구조

Task

  • 태스크는 비동기적 작업의 유닛
  • 다른 태스크의 자식이 될 수 있음
  • 새로운 비동기 컨텍스트를 생성하도록 하는 일련의 이니셜라이저임
  • 태스크 실행 시 실행되는 컨텍스트의 스레드를 상속할 수도, 벗어날 수도, 독립적으로 작동하는 것도 가능
  • SuccessFailure를 모두 제네릭하게 설정 가능

Cooperative Thread Pool

  • 스레드 풀: 비동기 함수를 실행할 수 있는 스레드 모음
  • 태스크는 언제나 앞으로 진행되는 프로그레스를 따르거나 서스펜션에 걸려 멈춰 있어야 함
  • GCD와 달리 CPU 코어 하나 당 한 개 이상이면 안 됨
  • 스레드 과포화 상태인 thread exploision을 방지
  • 스레드 스위칭으로 인한 오버헤드 및 퍼포먼스 패널티를 방지
  • 여러 개의 태스크를 정지할 수 있고, 멈추 뒤 리줌을 해당 태스크 우선순위에 따라 실행 가능
  • Continuation: 스레드 스위칭 없이 특정 작업을 정지, 재개하는 일이 경량화됨
private func getAmazingItem() async -> UIImage? {
        // ... return data
    }
    
private func getFoo() async {
        let tiem = await getAmazingItem()
    }
  • getFoo() 함수는 getAmazingItem() 비동기 함수가 데이터를 리턴할 때까지 현재 스레드 상태를 정지한 뒤, 데이터가 리턴되면 그때 태스크를 재개
  • 즉 현재 스레드를 '포기'한다는 것을 알려주는 것 → 스레드를 멈추거나 스레드 가용 상황과 우선순위에 따라 즉각적으로 실행할 수 있도록 설정
  • 스레드가 멈춘 시점에 해당 리소스를 다른 태스크가 점유할 수 있기 때문에 최적화
  • getAmazingItem() 비동기 함수가 종료된다면, getFoo() 함수는 다시 작업을 재개하는 데, 이 싲머에서는 어느 종류의 스레드에서라도 리줌될 수 있음
  • 스레드 과포화 상태를 방지하고 리소스 최적화를 손쉽게 달성 가능

Error Handling

  • async throws를 통해 발생 가능한 에러를 스로우하는 구문을 다른 태스크 / 비동기 구문에서 사용할 때 try catch를 통해 손쉽게 에러 핸들링 가능

Complier Check

	private func testWithCompletionCorrect(urlString: String, completion: @escaping ((Result<UIImage, Error>) -> Void)) {
        guard let url = URL(string: urlString) else {
            completion(.failure(URLError(.badURL)))
            return
        }
        ...
    }
    
    private func testWithCompletionWrong(urlString: String, completion: @escaping ((Result<UIImage, Error>) -> Void)) {
        guard let url = URL(string: urlString) else {
            return
        }
        ...
    }
  • 컴플리션 핸들러를 통한 에러 핸들링의 경우 return 문과 별도로 컴플리션 클로저 내부에 실패 상황을 작성하지 않아도 컴파일러가 통과시킴
	private func testWithAsyncThrows(urlString: String) async throws -> UIImage? {
        guard let url = URL(string: urlString) else {
            throw URLError(.badURL)
        }
        ...
    }
  • async 구문을 통해 특정 데이터를 리턴하는 경우에는 throw 또는 리턴 데이터에 맞춰 값을 리턴해야 하기 때문에 코드를 잘못 작성하는 케이스가 감소

Async Properties

  • Async getters: 함수 이외의 프로퍼티에도 적용 가능.
struct Person {
    let url: URL
    
    var image: UIImage? {
        get async throws {
            let (data, _) = try await URLSession.shared.data(from: url)
            return UIImage(data: data)
        }
    }
}
  • 연산 프로퍼티의 getter를 비동기적으로 작성한 구문
  • 저장 프로퍼티의 URL을 인자로 받아 URLSessionasync를 따르는 data를 비동기적으로 리턴
private func testAsyncGetter() {
    Task {
        let person1 = Person(url: URL(string: "")!)
        let imge = try await person1.image
    }
}
  • 해당 값을 사용하기 위해서는 동일한 방법으로 Task 또는 async를 따르는 구문 아래에서 await 작성

Asnyc / Await Scenarios

  • 토큰 만료 이전 갱신 필요 체크: (1). 필요하다면 토큰 갱신 시도 (2). 필요없다면 그대로 놔두기 → 데이터 패치 및 리턴하기
  • 위 상황에서 발생 가능한 에러는 거의 모든 상황에 존재(토큰 갱신 체크, 토큰을 갱신하는 과정 등)
  • 컴플리션 핸들러를 통한 비동기 사용 → 재귀 호출이 필수 (리프레시 체크 → 그렇지 않다면 새롭게 리프레시 → 인증 토큰 발급 등이 순차적으로 이루어져야 하는데, 클로저 내부에서 또 다른 클로저를 사용해야 하기 때문에 재귀 호출을 해야 함)
  • async await를 통한 비동기 사용 → 재귀 호출을 사용하지 않음 (만료가 되었다면 갱신을 하는 과정이 비동기적으로 이루어지는 데, 해당 과정이 await를 통해 해당 태스크 전반을 대기시키기 때문에 그 과정이 보장이 된 시점에 해당 토큰을 곧바로 사용할 수 있기 때문)

Async let Bindings

  • async하게 리턴받는 과정 여러 개의 그룹을 순차적으로 실행하는 게 아니라, 병렬적으로 처리하고 싶을 때 사용 가능한 방법
  • 기존 디스패치 그룹을 통한 enter, leave에서 벗어나 코드 가독성 및 단축
private func testWithDispatchGroup() {
    let group = DispatchGroup()
    let person1 = Person(url: URL(string: "")!)
    let person2 = Person(url: URL(string: "")!)
    let person3 = Person(url: URL(string: "")!)
    var personImages: [UIImage] = []
    
    group.enter()
    person1.loadImage { result in
        defer { group.leave() }
        switch result {
        case .success(let image): personImages.append(image)
        case .failure(let error): break
        }
    }
    group.enter()
    person2.loadImage { result in
        defer { group.leave() }
        switch result {
        case .success(let image): personImages.append(image)
        case .failure(let error): break
        }
    }
    group.enter()
    person3.loadImage { result in
        switch result {
        case .success(let image): personImages.append(image)
        case .failure(let error): break
        }
    }
    group.notify(queue: DispatchQueue.main) {
        handleImages(images: personImages)
    }
}
  • 컴플리션 핸들러를 통해 비동기 데이터를 사용한다면 디스패치 그룹을 사용할 수 있음
  • notifiy를 통해 특정 작업이 완료되었음을 감지 가능 → 해당 블럭에서의 images 배열은 각 비동기 블럭이 완료된 이후임을 보장할 수 있음
  • 에러 핸들링이 어렵다는 단점
func loadImage(completion: @escaping((Result<UIImage, Error>) -> Void)) {
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let error = error {
                completion(.failure(error))
            } else if
                let data = data,
                let image = UIImage(data: data) {
                completion(.success(image))
            }
        }
    }
  • 이스케이핑 클로저를 통해 결과를 리턴하는 전형적인 비동기 구문
private func testWithAsyncLetBinding() async {
    let person1 = Person(url: URL(string: "")!)
    let person2 = Person(url: URL(string: "")!)
    let person3 = Person(url: URL(string: "")!)
    
    async let image1 = person1.image
    async let image2 = person2.image
    async let image3 = person3.image
    
    do {
        try await handleImages(images: [image1, image2, image3])
    } catch {
        // error handling
    }
    
}
  • 각 비동기 구문을 병렬적으로 처리하되, 오로지 모든 태스크가 완료된 이후에 handleImages 함수가 실행되는 게 보장 (await를 통해 해당 handleImages로 들어오는 image1... 들이 들어올 때까지 기다림)

Cancellation

  • 비동기적으로 시작된 특정 태스크를 중도 취소할 수 있음
  • Cooperative Cancellation 메커니즘을 사용
  • 특정 비동기 요청 중간에 해당 태스크를 취소할 수 있어야 함 → 리소스를 절약하고 불필요한 네트워킹을 방지
try Task.checkCancellation()
  • 해당 구문을 통해 해당 태스크가 중도 취소되었다면 에러를 스로우
if Task.isCancelled {
                // .. default value returned
            }
  • 디폴트 값을 리턴하도록 캔슬 여부를 체크할 수도 있음
  • 캔슬 상황에서 특정 구문을 실행하도록 핸들러를 제공
var image: UIImage? {
        get async throws {
            try await withTaskCancellationHandler(operation: {
                let (data, _) = try await URLSession.shared.data(from: url)
                return UIImage(data: data)
            }, onCancel: {
                // ... handle someithing in this return Void clousre
            })
        }
    }

Task Groups

  • 여러 개의 async 리퀘스트를 보낸 뒤 리턴받은 모든 데이터를 한 번에 사용해야 하는 경우
private func testWithForLoop(people: [Person]) async -> [UIImage?] {
    var result = [UIImage?]()
    for person in people {
        do {
            try await result.append(person.image)
        } catch {
            // error handling
        }
    }
    return result
}
  • For-Loop를 통해 파라미터로 들어온 각 변수 별로 await를 통해 데이터가 리턴받을 때까지 서스펜션이 걸리기 때문에 병렬 처리가 아님
  • 컨커런트한 태스크 수행이 필요하다면 앞서 언급된 async let binding이 필요
private func testWithAsyncLetBinding(people: [Person]) async -> [UIImage?] {
    var result = [UIImage?]()
    for person in people {
        async let image = person.image
        // ... cannot handle this image
    }
    return result
}
  • async ley으로 바인딩되는 변수의 개수가 동적으로 변할 경우 해당 인자에 대한 직접적인 접근이 어려워진다는 한계
private func testWithTaskGroups(people: [Person]) async -> [UIImage?] {
    await withTaskGroup(of: UIImage?.self, body: { group in
        for person in people {
            group.addTask {
                do {
                    let image = try await person.image
                    return image
                } catch {
                    // error handling
                    return nil
                }
            }
        }
        var result = [UIImage?]()
        for await image in group {
            result.append(image)
        }
        return result
    })
}
  • 태스크 자체를 그룹화하여 컨커런트하게 실행한다는 아이디어
  • 자식 태스크를 가질 수 있는 여러 개의 태스크를 묶어서 실행
  • withTaskGroup 내부에서 어떤 종류의 데이터 타입을 취급하는 넣어준 뒤, 바디에서 그룹으로 실행할 같은 종류의 태스크를 태스크 그룹에 넣기
  • 태스크로 들어갈 때 클로저로 리턴하는 값이 of 뒤에 선언한 해당 데이터와 일치해야 함. 에러 핸들링 또한 do catch문을 동일하게 적용
  • for await를 통해 해당 그룹 태스크가 컨커런트하게 리턴하는 image를 그때마다 받아와서 결과 result를 리턴 가능
private func testWithTaskGroups(people: [Person]) async -> [UIImage?] {
    await withTaskGroup(of: UIImage?.self, body: { group in
        for person in people {
            group.addTask(priority: person.isPrior ? .high : nil) {
                do {
                    let image = try await person.image
                    return image
                } catch {
                    // error handling
                    return nil
                }
            }
        }
        var result = [UIImage?]()
        for await image in group {
            result.append(image)
        }
        return result
    })
}
  • 태스크 그룹에 태스크를 추가할 때 특정 변수에 따라 우선순위 또한 설정 가능
  • 여러 개의 태스크를 동시적으로 수행할 때 우선순위가 높은 태스크를 먼저 실행
private func testWithTaskGropus(people: [Person]) async -> [String: UIImage?] {
    await withTaskGroup(of: (String, UIImage?).self, body: { group in
        for person in people {
            group.addTask {
                do {
                    return try await (person.id, person.image)
                } catch {
                    return (person.id, nil)
                }
            }
        }
        return await group.reduce(into: [String: UIImage?]()) { $0[$1.0] = $1.1 }
    })
}
  • 태스크의 결과가 동시적으로 리턴되기 때문에 결과 값을 최종 리턴하기 이전 정렬을 하는 방법이 유용함
  • 키가 되는 값을 리턴 데이터 타입과 함께 of로 넘겨버리는 방법에 주목

Hierarchy

  • 태스크 그룹이 존재하고, 해당 그룹에 추가된 자식 태스크가 동시적으로 실행
  • 부모/자식 태스크가 없이 분리된 별도의 태스크를 생성 가능 → Task.detached 등을 통해 사용, 별도로 관리

Async Sequences

  • 여러 개의 값을 리턴하는 비동기 작업을 실행해야 할 때 사용
  • 일반적인 스위프트의 시퀀스 타입과 동일 → 시퀀스 내 값이 모두 awaited asynchronously하다는 것만 제외한다면
  • 태스크 그룹을 사용할 때 for await in ...를 적용한 것이 그 예시
  • 데이터 용량이 큰 csv 파일을 구조화해 사용해야 할 때
private func fetchLineSerailly(with largeCSV: URL) async {
    Task {
        let (data, _) = try await URLSession.shared.data(for: URLRequest(url: largeCSV))
        let lines = String(data: data, encoding: .utf8)?.components(separatedBy: "\n") ?? []
        for line in lines {
            // handle line...
        }
    }
}
  • 전체 파일을 인코딩한 뒤 결과를 for loop를 통해 핸들링
private func fetchLineAsyncSequences(with largeCSV: URL) async {
    Task {
        let (bytes, _) = try await URLSession.shared.bytes(for: URLRequest(url: largeCSV))
        for try await line in bytes.lines {
            // handle line...
        }
    }
}
  • bytes라는 별도의 API를 통해 컨커런트하게 접근 가능
private func handleLocalFile(with localFile: URL) async {
    Task {
        let handle = try FileHandle(forReadingFrom: localFile)
        for try await line in handle.bytes.lines {
            // handle line by line
        }
    }
}
  • 로컬 파일을 다룰 때에도 유용하게 사용할 수 있는 방법

Actor

  • 레이스 문제를 해결할 때 가장 간편한 해결 방법 중 한 가지
  • 멀티 스레드 환경의 컨커런트한 데이터 접근 방법과 엮어 있음
  • 해당 상태에 대한 자동화된 동기화 및 고립을 지원
actor DataSource {
    let items = ["A", "B", "C", "D", "E"]
    private var index = 0
    
    func next() {
        print(items[index])
        if items.indices.contains(index + 1) {
            index += 1
        } else {
            index = 0
        }
    }
}

private func testActor() {
    let dataSource = DataSource()
    Task.detached { await dataSource.next() }
    Task.detached { await dataSource.next() }
    Task.detached { await dataSource.next() }
}
  • 컨커런트하게 next() 함수에 접근, 호출해야 할 경우 순차적 접근이 가능하도록 actor 클래스에서 내장 지원
@MainActor class DataSource {
    let items = ["A", "B", "C", "D", "E"]
    private var index = 0
    
    func next() {
        print(items[index])
        if items.indices.contains(index + 1) {
            index += 1
        } else {
            index = 0
        }
    }
}

@MainActor private func testActor() {
    let dataSource = DataSource()
    Task.detached { await dataSource.next() }
    Task.detached { await dataSource.next() }
    Task.detached { await dataSource.next() }
}
  • 액터의 실행 스레드가 메인 스레드에서 이루어진다는 게 필요할 때 @MainActor 프로토콜을 따름으로써 보장 가능
actor DataSource {
    let items = ["A", "B", "C", "D", "E"]
    private var index = 0
    
    func next() {
        print(items[index])
        if items.indices.contains(index + 1) {
            index += 1
        } else {
            index = 0
        }
    }
    
    nonisolated
    func check() {
        print("i'm not isolated to this thread")
    }
}

private func testActor() {
    let dataSource = DataSource()
    Task.detached {await dataSource.next()}
    dataSource.check()
}
  • 특정 함수, 특정 변수가 액터가 보장하는 스레드에 고립되어 있지 않아도 된다면 nonisolated를 통해 await로 기다리지 않아도 곧바로 호출 가능하도록 설정

Plus

  • 개발 환경 (iOS 13 이상) 체크
  • 커스텀 Continuation 함수를 만들어 사용 가능
private func testContinuation() {
    withCheckedContinuation(<#T##body: (CheckedContinuation<T, Never>) -> Void##(CheckedContinuation<T, Never>) -> Void#>)
    withCheckedThrowingContinuation(<#T##body: (CheckedContinuation<T, Error>) -> Void##(CheckedContinuation<T, Error>) -> Void#>)
    withUnsafeContinuation(<#T##fn: (UnsafeContinuation<T, Never>) -> Void##(UnsafeContinuation<T, Never>) -> Void#>)
    withUnsafeThrowingContinuation(<#T##fn: (UnsafeContinuation<T, Error>) -> Void##(UnsafeContinuation<T, Error>) -> Void#>)
}
private func testAsyncWithCompletion(completion: @escaping((Result<UIImage, Error>) -> ())) { }

private func testAsyncContinuationWithCompletion() async throws -> UIImage {
    try await withCheckedThrowingContinuation({ continuation in
        testAsyncWithCompletion { result in
            continuation.resume(with: result)
        }
    })
}
  • 컴플리션 핸들러 리턴 방식을 async await를 따르도록 리팩터링 가능
struct Item {
    let id = UUID()
}

enum ItemChange {
    case change(Item)
    case finished
}

func checkItems(change: @escaping((ItemChange) -> ())) {
    // handle async task
}
  • 이넘을 통해 특정 데이터의 변화를 전달, 해당 전달의 비동기 구문은 이스케이핑 클로저를 쓰고 있을 때
func checkItems() -> AsyncStream<Item> {
    AsyncStream<Item> { continuation in
        checkItems { change in
            switch change {
            case .change(let item): continuation.yield(item)
            case .finished: continuation.finish()
            }
        }
    }
}

private func testCheckItems() async {
    Task {
        for await item in checkItems() {
            print("Current item: \(item)")
        }
    }
}
  • AsyncStream을 통해 컴플리션을 감싸서 사용 가능

SwiftUI + Async Await

  • .task 모디파이어를 통해 iOS 15 부터 사용 가능
  • Task를 통해 직접적으로 iOS 13부터 사용 가능했음

Combine & Async Await

  • 컴바인의 퍼블리셔가 가지고 있는 values 프로퍼티를 통해 AsyncSequence와 동일한 작업 수행 가능
private func testCombineStyle(with numbers: AnyPublisher<Int, Error>) {
    numbers
        .map({$0 * 2})
        .prefix(3)
        .dropFirst()
        .sink { _ in
            // handle completion
        } receiveValue: { number in
            // handle number
        }
}
  • 전형적인 컴바인 스타일
private func testCombineAndAsync(with numbers: AnyPublisher<Int, Error>) async {
    let publisher = numbers
        .map({$0 * 2})
        .prefix(3)
        .dropFirst()
    Task {
        for try await number in publisher.values {
            // handle number
        }
        // handle completion
    }
}
  • 퍼블리셔의 values가 정확히 AsyncSequences와 유사하기 때문에 sink 단에서 실제로 내려오는 데이터를 for (try) await로 다룰 수 있음
  • AsyncSequence를 위해 애플이 기본적으로 제공하는 오퍼레이터가 현재 컴바인 오퍼레이터를 대체할 수 있다는 게 유력함.
profile
JUST DO IT

0개의 댓글