[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/Concurrency & GCD

[Swift][문서의역] Concurrency (async & await)

inu 2022. 3. 8. 16:26
반응형

참고 및 출처

Concurrency

Swift는 구조적 방식으로 비동기(asynchronous) 및 병렬(parallel) 코드 작성을 지원했습니다. 비동기 코드는 한번에 프로그램의 한 부분만 처리되지만 정지 및 재실행이 가능합니다. 네트워크에서 데이터를 가져오거나 파일을 읽어오는 등의 긴 기간의 연산을 하는 동안 UI를 갱신하는 등의 비교적 단기간의 연산을 수행할 수 있습니다. 병렬 코드는 여러개의 코드들을 동시에 실행할 수 있습니다. 예를 들어 4개의 코어를 가진 프로세서는 각 코어마다 하나의 코드를 배치하여 동시에 4개의 코드를 실행할 수 있는 것입니다.

 

병렬 및 비동기 코드는 유연하게 추가적인 스케줄링을 관리할 수 있도록 도와주지만, 그만큼 코드의 복잡도가 증가한다는 단점이 존재합니다. Swift는 컴파일타임에 우리의 의도가 명확하게 정리되었는지 확인합니다. (예를들어 Actor는 data race를 방지하고 여러 스레드에서 mutable state에 안전하게 접근하도록 돕습니다.) 하지만 이를 사용한다고해서 무조건 코드의 실행이 빨라짐을 보증하는 것은 아닙니다. 오히려 코드 디버깅이 어려워지는 문제를 낳을 수도 있습니다. 하지만 분명한 것은 Swift가 지원하는 동시성(Concurrency, 비동기 + 병렬) 기능을 사용하면 컴파일타임에 우리 코드의 문제점을 발견할 수 있다는 것입니다.

 

Swift의 동시성 기능을 사용하지 않고도 동시성 코드를 작성할 수는 있습니다. 하지만 이는 조금 읽기 어려워보입니다. 아래는 사진의 이름목록 중 첫번째 사진을 다운로드하고 사용자에게 표시하는 코드입니다.

listPhotos(inGallery: "Summer Vacation") { photoNames in
    let sortedNames = photoNames.sorted()
    let name = sortedNames[0]
    downloadPhoto(named: name) { photo in
        show(photo)
    }
}

이런 간단한 케이스에서도 연속된 complete handler를 통해 코드를 작성하면서, 콜백지옥(코드 중첩이 매우 깊어짐)이 발생할 수 있습니다.

 Defining and Calling Asynchronous Functions (비동기함수의 정의 및 호출)

비동기 함수(asynchronous function), 비동기 메서드(asynchronous method)는 실행 도중 중단이 가능한 특수한 함수(메서드)입니다. 이는 완료 전까지 실행되거나, 에러를 던지거나, 계속해서 무한히 실행되는 일반적인 함수(메서드)와 대조됩니다. 비동기 함수(메서드)도 일반적인 함수처럼 완료 전까지 실행되거나, 에러를 던지거나, 계속해서 무한히 실행되지만, 어떤 행위를 기다리는 도중에 일시 정지할수 있다는 점이 다릅니다.

 

함수가 비동기임을 나타내기 위해서는 에러를 반환하는 함수를 throw 키워드로 표시했을 때와 같이 async 키워드를 매개변수 뒤에 작성합니다. 값을 반환하는 함수일 경우 async 키워드를 반환 화살표 (->) 앞에 작성합니다. 다음은 갤러리에서 사용할 사진의 이름을 가져올 때 쓸 수 있는 방법입니다.

func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... some asynchronous networking code ...
    return result
}

보여지는 것처럼 비동기이면서 에러까지 반환할 수 있는 함수라면, throws 앞에 async를 작성합니다.

 

비동기 메서드를 호출할 때는 그 메서드가 반환될 때까지 실행이 일시 중단됩니다. 일시 중단 가능성이 있는 지점에 await 키워드를 표시합니다. 이는 에러 발생가능성이 있는 함수 호출 앞에 try 키워드를 붙이는 것과 비슷한 행위입니다. 비동기 메서드 내부에서는 또 다른 비동기 메서드를 호출할 때만 실행흐름이 멈춥니다. 일시중단이 될 수 있는 모든 지점에는 await 키워드가 붙여집니다.

 

아래 코드는 갤러리의 모든 사진 이름을 가져오고, 이 중 첫번째 사진을 표시합니다.

let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)
  • 비동기함수인 listPhotos 함수를 호출하면서 원래 코드는 listPhotos 함수가 반환되기를 기다립니다.
  • listPhotos 함수가 반환되어 photoNames를 받아오면, 나머지 코드가 동기적으로 처리됩니다.
  • 다음 비동기함수인 downloadPhoto 함수 역시 호출되면 원래 코드는 해당 함수가 반환되기를 기다립니다.
  • downloadPhoto 함수가 반환되어 photo를 받아오면, 나머지 코드가 동기적으로 처리됩니다.
  • 이 때 주의할 점은 await로 인해 원래 코드가 정지되어도, 해당 스레드가 아무 작업처리를 하지 않는 것은 아니라는 것입니다. 다른 곳에서 처리할 작업이 발견되면 해당 작업을 처리하면서 기다리게 됩니다.

await 키워드로 표시된 일시 정지 가능한 시점들은 비동기 함수의 반환을 기다리는 동안 현재 코드 부분이 일시 정지될 수 있음을 나타냅니다. 이것을 스레드 양보(yielding the thread)라고도 하는데, Swift가 현재 스레드에서 코드를 실행 중단하는 대신 해당 스레드에서 다른 코드를 실행할 수 있기 때문입니다. await가 있는 코드는 일시 중단될 수 있어야하므로, 우리의 프로그램 중 확실한 위치에서만 호출할 수 있습니다.

 

'확실한 위치'란 아래와 같은 위치들 뜻합니다.

  • 비동기함수 내부
  • @main 키워드로 표시된 구조체 혹은 클래스, 열거형 내부
  • static main() 메서드 내부
  • 구조화되지 않은 Child Task 내부 (참고)

cf. Task.sleep(_:) 메서드는 동시성이 작동하는 방식을 익히기 위해 간단한 코드를 작성할 때 유용합니다. 이 메서드는 아무 작업도 하지 않지만 return되기 전에 주어진 시간을 기다립니다. 다음은 네트워크 작업 대기를 시뮬레이션하기 위해 사용하기 좋은 코드의 예시입니다.

func listPhotos(inGallery name: String) async throws -> [String] {
    try await Task.sleep(nanoseconds: 2 * 1_000_000_000)  // Two seconds
    return ["IMG001", "IMG99", "IMG0404"]
}

Asynchronous Sequences (비동기 시퀀스)

앞서 보여진 listPhotos(inGallery:) 메서드처럼 모든 요소가 준비된 후 비동기적으로 전체 배열을 받아오는 방법도 있지만, 비동기 시퀀스를 사용해 한번에 컬렉션의 한 요소를 받아오는 방법도 있습니다. 비동시 시퀀스의 반복 방법은 다음과 같습니다.

import Foundation

let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
    print(line)
}

일반적으로 for-in 반복문을 작성하면서 for 뒤에 await 키워드를 붙입니다. 에러 발생가능성이 있을 경우 그 앞에 try도 함께 붙입니다. await는 기존과 마찬가지로 '일시정지 가능성'을 내포합니다. 따라서 for-await-in 반복문을 각 원소의 사용을 기다려면서 다음 반복문의 시작을 일시중지합니다. for-in 반복문의 사용대상이 Sequence 프로토콜을 준수했던 것과 같이, for-await-in 반복문의 사용대상은 AsyncSequence 프로토콜을 준수해야 합니다.

Calling Asynchronous Functions in Parallel (비동기 함수 병렬 호출)

다음과 같이 3번 비동기 함수 downloadPhoto(named:)를 실행하려는 경우가 있다고 해봅시다.

let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

이러한 접근 방식은 모든 다운로드가 비동기로 처리되어 각 작업동안 다른 작업을 수행할 수는 있지만, 한 번에 하나의 downloadPhoto(named:)처리만 가능하다는 단점이 있습니다. 다음 사진을 다운로드하기 전에 그 전 사진의 완전한 다운로드를 필요로 하는 것입니다. 하지만 비동기 함수를 병렬적으로 호출하는 방법이 있습니다. 상수 정의부 let 앞에 async 키워드를 작성하는 것입니다. 그리고 그 상수를 사용하려할 때 await 키워드를 작성합니다.

async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])

let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

이 코드에서 각 downloadPhoto(named:) 호출은 이전 호출 완료를 기다리지 않고 실행됩니다. (단, 시스템 리소스가 충분할 경우) 해당 함수의 호출을 await로 표시하지 않은 것은 해당 함수의 결과를 기다리기 위해 코드를 멈추지 않는다는 의미입니다. 대신 photos를 정의하는 시점에서는 각 비동기 함수가 return될 때까지 대기가 필요합니다. 따라서 await 키워드를 붙입니다.

  • 즉각적으로 비동기함수의 결과를 필요로 할 때는 await 키워드로 함수를 호출합니다. 이는 각 작업을 순차적으로 처리합니다.
  • 비동기함수의 결과를 나중에 필요로 할 경우 async let 을 사용해 함수를 호출합니다. 이는 각 작업을 병렬적으로 처리합니다.
  • 두 케이스 모두 일시 정지가 발생할 수 있는 위치에 await를 표시해주어야 합니다.

Tasks and Task Groups 

Task는 프로그램에서 비동기적으로 실행할 수 있는 작업의 단위입니다. 모든 비동기 코드는 특정 Task의 일부로 실행됩니다. async let 구문은 일종의 'Child Task'를 생성합니다. Task Group을 생성하고 해당 Group에 Child Task를 추가할 수도 있습니다. 이를 통해 우선 순위 조정 및 취소를 좀 더 잘 제어할 수 있고, 동적으로 Task를 생성할수도 있습니다.

 

Task는 계층 구조로 배치됩니다. Task Group의 각 작업에는 동일한 상위 Task가 있으며, 각 Task에는 또 다른 Child Task가 있을 수 있습니다. Task와 Task Group 간의 명시적 관계 때문에 해당 접근 방식을 구조적 동시성(structured concurrency)이라고 표현합니다. 정확성에 대한 책임 중 일부는 사용자가 지겠지만, Task들간의 명시적인 parent-child 관계 덕분에 Swift는 취소 전파(propagating cancellation)와 같은 동작을 처리할 수 있고, Swift 컴파일타임에 일부 오류를 감지할 수 있습니다.

await withTaskGroup(of: Data.self) { taskGroup in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        taskGroup.addTask { await downloadPhoto(named: name) }
    }
}
반응형

'🍎 Apple > Concurrency & GCD' 카테고리의 다른 글

[Concurrency] async / await  (2) 2023.03.31
[Swift] GCD 정리하기  (2) 2022.05.30
[Swift] Operation Queue  (0) 2022.05.27
[Swift] Actor  (4) 2022.03.08
[Swift][문서의역] Task  (0) 2022.03.08