[Swift Concurrency] async/await

정한별·2024년 11월 20일

Swift Concurrency

목록 보기
2/5

async/await란?

기존에 비동기 호출 코드를 작성할땐 아래와 같이 작성해야 했다.

func getData(completion: @escaping (Data) -> Void) {
    DispatchQueue.global().async {
        sleep(1)
        completion(Data())
    }
}

getData { _ in
    getData { _ in
        getData {
            print("completion \($0)")
        }
    }
}

이런 코드는 중첩된 비동기 상황에서 콜백지옥을 만들고 가독성의 저하를 일으킨다.

Concurrency를 이용하면 동기코드를 작성할때와 같은 형식의 코드를 작성할수 있기 때문에 가독성이 좋아진다.

func getData() async -> Data {
    try! await Task.sleep(for: .seconds(1))
    return Data()
}
Task {
    let data = await getData()
    let data2 = await getData()
    let data3 = await getData()
    print("await \(data3)")
}

이때 사용하는 두가지 키워드에 대해서 알아보자.

  • async

    async 키워드는 Swift에서 비동기 함수를 정의할 때 사용한다. async 키워드를 쓴 함수는 해당 함수가 비동기 작업을 수행할 것임을 Swift 컴파일러에 알려준다.

  • await

    await 키워드는 비동기 함수 안에서 특정 작업이 완료될 때까지 "기다려야 하는" 부분을 표시하는 역할을 한다. 비동기 작업의 중간에 사용하여 해당 작업이 끝날 때까지 다른 작업을 수행할 수 있도록 중단을 허용한다.

    await 키워드가 있는곳에서 코드의 실행을 잠시 중단하고 해당 비동기 작업이 완료되면 이어서 다음 코드를 실행 한다. 중단되는 동안 다른 비동기 작업이 진행될 수 있다.

여기서 주의해야할 점은 비동기 함수를 실행시킬때는 항상 비동기 컨텍스트 안에서 실행 해야 한다는것이다. 일반적인 컨텍스트에서는 비동기 함수의 호출 자체가 되지 않는다. 그래서 Task와 같은 비동기 컨텍스트 안에서 실행해야한다.

동작 매커니즘

async/await의 동작 메커니즘을 한번 알아보자.

await 키워드는 호출할 함수의 앞에 붙여 중단지점(suspension point) 임을 나타낸다. 호출된 함수가 결과값 혹은 오류를 던진 이후, 중단지점에서 실행을 재개 할 수 있다.

함수가 실행되는 과정에서 await 키워드를 만나면 해당 지점을 중단지점으로 표시하게 된다.

작업이 일시중지되면, task는 작업을 진행 중이던 스레드의 제어권을 포기하게되고, 이때 해당 스레드 제어권은 시스템이 가지게 된다. 시스템은 이 스레드에서 다른 작업을 수행하다가 task의 수행이 필요하다고 느껴지는 적절한 시점에 스레드 제어권을 반환한다.

스레드 제어권은 다시 중단지점이 발생한 지점으로 돌아가 작업의 실행이 재개된다.

func fetchData() async -> String {
    print("네트워크 호출 \(Thread.current)")
    let data: (Data, URLResponse)? = try? await URLSession.shared.data(
        from: URL(string: "https://example.com")!
    )
    print("네트워크 호출 완료 \(Thread.current)")
    return "네트워크 호출 완료"
}

Task {
    print("Task 시작 \(Thread.current)")
    let data = await fetchData()
    print("Task 완료 \(Thread.current)")
}

기존 GCD를 사용할때는 처리해야 할 비동기 작업이 많아지면 생성되는 스레드도 증가하게 되고 이는 thread explosion을 발생시켰다. CPU 코어수보다 스레드가 많아지게되면 overcommit 또한 되어버린다. 또 스레드가 과도하게 생성될 경우 빈번한 context switching으로 인해 오버헤드가 발생한다.

  • thread explosion 프로그램에서 스레드가 과도하게 많이 생성되는 현상
  • overcommit 실제 물리적 CPU 코어 수보다 스레드의 숫자가 초과하는 상황

GCD를 사용할 때는 이러한 현상을 피하기 위해 DispatchSemaphore 이용해 동시 실행되는 작업의 수를 제한했었다. 하지만 개발자가 직접 스레드를 관리하는 것은 복잡하고 오류를 일으킬 가능성이 많기 때문에 애플은 개발자가 직접 스레드를 관리하는 대신, 시스템이 이를 처리하도록 했다.

await 즉 비동기 작업이 일시중지되면 스레드의 권한을 시스템이 가져가고 이를 관리하게 된다. 그리고 나중에 해당 비동기 작업이 다시 실행될 때, 시스템은 이 비동기 작업을 실행할 적절한 스레드를 찾아서 재개하는 것이다.

아주 쉽게 이해하자면 비동기 작업이 많아지면 스레드를 계속 생성 하는게 아니라 일시중지라는 개념을 도입해서 일시중지 될때마다 적절한 스레드를 재할당 해가면서 최대한 가지고 있는 스레드를 이용해 효율적으로 비동기 작업을 진행하는것이다.

그렇기때문에 위에 코드 print 결과를 일시중지 될때 스레드와 다시 실행을 재개할때 스레드가 같을때도 있고 다를때도 있는것이다. 이 모든것들을 시스템 안에서 알아서 해주는것이 async/await이다.

async 함수 호출 메모리 구조

일단 기본지식으로 Stack 영역은 각 스레드마다 독립적으로 가지고 있고 Heap 영역은 다른 스레드끼리 공유될 수 있다는걸 알아야한다.

Swift에서 함수를 호출하면 매개변수, 지역변수, 함수 완료후 돌아갈 반환주소와 같은 상태정보들이 포함된 Stack Frame이 메모리의 Stack 영역에 올라가고, 함수가 종료되면 해당 Stack Frame이 Stack 영역에서 제거된다.

그렇다면 기존 GCD에서 비동기적으로 함수를 호출하면 어떻게될까?

func add(a: Int, b: Int) -> Int {
    let result = a + b
    return result
}

func asyncFunction() {
    DispatchQueue.global().async {
        let result = add(a: 1, b: 2)
    }
}

asyncFunction()

메인스레드에서 함수가 실행되고 DispatchQueue.global().async를 만나면 시스템의 스레드풀에서 메인스레드를 제외한 사용가능한 스레드를 할당한다. 그리고 새로운 스레드의 독립적인 Stack 영역에 add() 함수에 대한 Stack Frame이 올라간다.

Swift Concurrency에서의 비동기 함수 호출은 어떨까?

비동기 함수를 실행할때 사용되지 않는 지역변수들은 Stack 영역에 올라가지만, 실행에 필요한 함수의 상태정보와 함수가 중단지점의 정보와 실행재개 시 필요한 컨텍스트인 continuation이 Heap 영역에 올라간다. 이러한 continuation을 통해 일시정지된 함수의 상태를 추적해 어디서부터 실행을 재개할지 알수 있는것이다.

continuation은 Heap에 저장되기 때문에 스레드 간에 continuation를 공유할 수 있고, 그렇기 때문에 비동기함수가 어떤 스레드에서 일시중지 되었는지 관계 없이 이후 실행이 재개될때 가장 효율적으로 실행될 수 있는 스레드에서 Heap에 저장된 continuation을 가지고 실행을 재개할 수 있는것이다. 또한 continuation안에 함수 Stack Frame 정보를 이용해 새로운 Stack Frame을 생성하지 않아도 된다.

func fetchData() async -> String {
    print("네트워크 호출 \(Thread.current)")
    let data: (Data, URLResponse)? = try? await URLSession.shared.data(
        from: URL(string: "https://example.com")!
    )
    print("네트워크 호출 완료 \(Thread.current)")
    return "네트워크 호출 완료"
}

Task {
    print("Task 시작 \(Thread.current)")
    let data = await fetchData()
    print("Task 완료 \(Thread.current)")
}

print 결과
Task 시작 <NSThread: 0x600001709880>{number = 7, name = (null)}
네트워크 호출 <NSThread: 0x600001709880>{number = 7, name = (null)}
네트워크 호출 완료 <NSThread: 0x6000017094c0>{number = 4, name = (null)}
Task 완료 <NSThread: 0x6000017094c0>{number = 4, name = (null)}

또 다른 print 결과
Task 시작 <NSThread: 0x600001705640>{number = 5, name = (null)}
네트워크 호출 <NSThread: 0x600001705640>{number = 5, name = (null)}
네트워크 호출 완료 <NSThread: 0x600001705640>{number = 5, name = (null)}
Task 완료 <NSThread: 0x600001705640>{number = 5, name = (null)}

그래서 위의 코드를 보면 작업중지 전의 스레드와 실행재개 이후 스레드가 다른 결과가 나오기도 하는것이다.

(작업중지 전 스레드와 실행재개 이후 스레드가 같을수도 다를수도 있다 그것은 시스템의 판단)

에러처리

비동기 함수를 정의 할때 에러를 던지는 함수로 정의할 수 도 있다.

func getData() async throws -> Data {
    try await Task.sleep(for: .seconds(3))
    guard let data = UIImage(named: "")?.pngData() else {
        throw NSError(domain: "", code: 1)
    }
    return data
}

그리고 이런 함수를 호출하는 부분에서 여러 방법으로 에러를 받을수 있다.

do catch 구문을 이용해서 에러처리를 할수 도 있고.

Task {
    do {
        let data = try await getData()
        print("do \(data)")
    } catch {
        print("do \(error.localizedDescription)")
    }
}

guard let 구문으로 단순히 값만 받아올수도 있고

Task {
    guard let data = try? await getData() else { return }
    print("guard let \(data)")
}

task 자체를 변수로 가져와 실행과 동시에 Result 타입으로 받는것도 가능하다.

let task = Task {
    return try await getData()
}

Task {
    switch await task.result {
    case .success(let data):
        print("switch \(data)")
    case .failure(let error):
        print("switch \(error)")
    }
}

또한 Task를 중간에 취소하게되면 CancellationError()가 반환된다

func getData() async throws -> Data {
    try await Task.sleep(for: .seconds(1))
    guard let data = UIImage(systemName: "heart")?.pngData() else {
        throw NSError(domain: "", code: 1)
    }
    return data
}

let task = Task {
    do {
        try await getData()
        try await getData()
        try await getData()
        try await getData()
        try await getData()
    } catch {
        print("\(error)")
    }

}

sleep(3)
task.cancel()
profile
iOS Developer

0개의 댓글