본문 바로가기

Swift/Concurrency

Swift Concurrency - Perform asynchronous operation

위 글은 Swift Concurrency 공식문서를 참고했습니다.

 

Concurrency에 대해서 알아보기에 앞서 우린 동시성을 가진 코드를 작성하고 있었다.

예를 들면 사진 이름 리스트를 다운로드하고, 첫 번째 사진을 다운로드하고 유저에게 보여주는 코드라면

 

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

 

completion handler를 통해서 위와 같은 코드를 작성했을 것이다. 

그렇다면 새로운 방식의 Asynchronous 함수를 호출하는 방법에 대해서 알아보자.

 

Defining and Calling Asynchronous Functions

비동기 함수 또는 비동기 메서드는 실행 도중 일시중지할 수 있는 특수한 종류의 함수 또는 메서드이다.

이는 완료될 때까지 실행(run to completion)되거나 오류(throw an error)가 발생되거나 반환(return)되지 않는 일반적인 동기 메서드와는 대조적이다.

비동기 메서드 또한 일반 동기함수는 방금 말한 세 가지중 하나를 수행한다. 하지만, 무언가를 기다리는 동안 중간에 일시 중지할 수도 있다. 비동기 메서드에서는 일시 중단할 수 있는 위치를 표시한다.

 

메서드가 비동기라는것을 알리기 위해서, async 키워드를 파라미터 다음에 명시한다. 이것은 throws를 표기할 때와 비슷하다. 또한 리턴값이 있는 경우, 화살표(→) 표기 전에 async 키워드를 작성한다.

만약 메서드가 async 와 throws 키워드를 모두 사용한다면, throws 전에 async를 표기한다.

 

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

 

비동기 메서드를 호출하면 해당 메서드가 return 될 때까지 실행이 일시 중단된다. 그리고 가능한 일시 중단 지점을 표시하기 위해 호출 앞에 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(inGallery:) 와  downloadPhoto(named:)  메서드 둘 다 네트워크 요청이 필요하기 때문에, 완료되기까지 긴 시간이 요구된다. 이때, 이 메서드들에 비동기로 호출됨을 표시하기 위해서 async 를 표기하여, 코드가 사진들이 준비될 때까지, 마저 다른 일을 할 수 있게 하는 것이다.

코드의 흐름을 살펴 보자면

  1. listPhotos(inGallery:) 메서드가 호출되고 await 키워드가 있으므로, 해당 메서드가 return 될 때까지 대기한다.
  2. 대기하는 동안, 같은 프로그램 내 다른 비동기 코드는 실행된다. 예를 들어, 시간이 많이 걸리는 background task 가 새로운 사진 갤러리 목록을 업데이트할 수 있다. 해당 코드는 await 키워드로 표시된 다음 일시 중단 지점까지 또는 완료될 때까지 실행된다.
  3. listPhotos(inGallery:) 가 return 되고, 코드가 다시 그 지점부터 시작된다. 즉 photoNames에 반환된 값을 할당한다.
  4. sortedNames 은 일반적인 동기 코드이기 때문에, 일시정지되는 것 없이 멈추지 않는다.
  5. 다음 await 키워드가 downloadPhoto(named:) 에 표기되어 있다. 다시 return 될 때까지 일시중지되고, 다른 비동기 코드에 실행할 수 있는 기회를 넘긴다.
  6. downloadPhoto(named:) 가 return 되고, 사진이 return 된다. 이후 show(_:) 메서드에 파라미터로 전달된다.

코드에서 await 키워드로 표시된 부분은 비동기 메서드가 return 될 때까지 일시 중지할 수 있음을 나타낸다.

Swift는 백그라운드에서 현재 스레드에서 코드 실행을 일시 중단하고 대신에 해당 스레드에서 다른 코드를 실행하기 때문에 yielding the thread(스레드 양보)라고도 한다.

 

await이 있는 코드는 실행을 일시 중단할 수 있어야 하므로 프로그램의 특정 위치에서만 해당 비동기 메서드를 호출할 수 있다:

  • 비동기 메서드, 함수, 프로퍼티의 body 부분
  • @main 속성이 지정된 클래스, 구조체 또는 열거형의 static main() 메서드
  • 조직되지 않은 자식 task 내부

가능한 정지 지점 사이의 코드는 다른 동시적인 코드의 중단 가능성 없이 순차적으로 실행된다. 예를 들어, 아래 코드는 한 갤러리에서 다른 갤러리로 사진을 이동한다.

 

let firstPhoto = await listPhotos(inGallery: "Summer Vacation")[0]
add(firstPhoto, toGallery: "Road Trip")
// At this point, firstPhoto is temporarily in both galleries.
remove(firstPhoto, fromGallery: "Summer Vacation")

 

위 코드에서 add(_:toGallery:)remove(_:fromGallery:) 사이에 다른 코드를 실행할 수 있는 방법이 없다. 그동안, 첫 번째 사진이 양쪽 갤러리에 나타나고, 이는 앱의 불변성중 하나를 깨뜨리게 된다. 따라서 이 코드에 await이 추가되어서는 안 된다는 점을 명확하게 하기 위해 해당 코드를 동기 함수로 리팩터링 할 수 있다.

 

func move(_ photoName: String, from source: String, to destination: String) {
    add(photoName, toGallery: destination)
    remove(photoName, fromGallery: source)
}
// ...
let firstPhoto = await listPhotos(inGallery: "Summer Vacation")[0]
move(firstPhoto, from: "Summer Vacation", to: "Road Trip")

 

위 예에서 move(_:from:to:) 함수는 동기식이므로 가능한 일시 중단 지점을 포함할 수 없음을 보장한다.

앞으로 이 함수에 동시성 코드를 추가하려고 하면 일시 중지 지점이 생길 수 있으므로 버그를 도입하는 대신 컴파일 타임 오류가 발생한다.

 

Asynchronous Sequences

listPhotos(inGallery: ) 메서드는 전체 배열을 준비완료되면 한 번에 return 했다. 다른 접근은 비동기 시퀀스를 사용해 컬렉션의 하나의 원소를 기다리는 것이다. 다음은 비동기 시퀀스를 반복하는 모습이다.

 

import Foundation

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

 

보통의 for-in 루프를 사용하는 것 대신에, for 다음에 await 키워드를 추가한다. 비동기 메서드를 호출할 때, await 키워드를 작성하는 것은 가능한 일시중지 지점을 가리키는 것이다. for-await-in 루프는 다음 원소를 사용할 수 있을 때까지 기다리는 각 반복의 시작 부분에서 잠재적으로 실행을 일시 중단한다.

특정 타입에 AsyncSequence 프로토콜을 따르게 하여 같은 방식의 for-await-in 루프를 사용할 수 있다.

 

Calling Asynchronous Functions in Parallel

await을 사용하여 비동기 함수를 호출하면 한 번에 하나의 코드만 실행된다. 비동기 코드가 실행되는 동안 호출자는 다음 코드 줄을 실행하기 위해 이동하기 전에 해당 코드가 완료될 때까지 기다린다. 예를 들어 갤러리에서 처음 세 장의 사진을 가져오려면 다음과 같이 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 키워드를 붙여준다.

 

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:) 호출은 이전 작업이 완료되기를 기다리지 않고 시작한다. 시스템 자원이 충분하다면, 이들은 같은 시간에 실행될 것이다. 이는 메서드의 return 값을 기다리지 않기 때문에 메서드 중 어느 것도 await으로 표시되지 않는다.

대신에 실행은 실행은 photos를 정의한 곳까지 진행된다. 그리고 프로그램은 비동기 호출들의 결과가 필요하기 때문에 세 사진들이 다운로드가 완료될 때까지 일시중지하도록 await 키워드를 작성해야 한다.

 

위 두 접근에 대해 생각해 볼 차이점이다.

  • 만약 다음줄의 코드가 앞선 비동기 메서드의 결괏값을 의존한다면, await 키워드와 함께 비동기 메서드를 호출함으로써 작업의 순차적인 실행을 생성할 수 있다.
  • 만약 결괏값이 코드에서 당장 필요한 것이 아니라 나중에 필요하다면, async-let 키워드를 사용한다. 이것은 작업의 병렬 실행을 생성할 수 있다.
  • await과 async-let 모두 일시 중지될 동안 다른 코드의 실행을 수행할 수 있다.
  • 두 가지 상황 모두 가능한 일시 중지 지점을 await으로 표시하여 필요한 경우 비동기 메서드가 return 될 때까지 실행이 일시 중지됨을 나타낸다.

두 접근을 같은 코드에서 섞어서 사용할 수도 있다.

 

Tasks and Task Groups

Task는 프로그램의 일부로 비동기적으로 실행할 수 있는 작업의 단위이다. 이전 섹션에서 설명한 async-let 구문은 child task를 생성한다. task group을 생성하고, child task를 그룹에 추가할 수도 있다. 이렇게 하면 우선순위 및 작업의 취소를 더 잘 제어할 수 있고, 여러 task들을 생성할 수 있다.

 

Task는 계층적으로 정렬된다. task group 내 각 task는 같은 부모 task를 갖고 각 task는 child task를 가질 수 있다. task 와 task group 간 명확한 관계 덕분에, 이 접근은 structured concurrency(구조화된 동시성) 이라부른다. 정확성에 대한 책임은 있지만, task 간 parent-child 관계를 통해 Swift는 cancellation을 전파하는 것을 핸들링할 수 있고, 컴파일 타임에 에러를 탐지할 수 있다.

 

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

 

Unstructured Concurrency

이전 섹션에서 소개한 구조화된 동시성 접근 방식 이외에도 Swift는 구조화되지 않은 동시성 또한 지원한다.

작업 그룹의 일부인 task와 달리 unstructured task(구조화되지 않은 task)는 parent task 가 없다.

구조화되지 않은 task을 관리함으로써 프로그램에 필요한 완전한 유연함을 가질 수 있으나, 정확성에 대한 책임이 따른다. 현재 actor에서 실행되는 구조화되지 않은 task를 생성하기 위해선, Task.init(priority:operation:) 이니셜라이저를 호출한다. 현재 actor에 일부가 아닌 구조화되지 않은 작업을 생성하기 위해선, Task.detached(priority:operation:) 클래스 메서드를 호출한다. 이 두 작업 모두 결과를 기다리거나 취소하는 등 상호 작용을 할 수 있는 task를 반환한다.

 

let newPhoto = // ... some photo data ...
let handle = Task {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value

 

💡 둘 간 명확한 차이점: Task.init(priority:operation:) 은 현재 실행 컨텍스트 내에서 실행된다.

Task.detached(priority:operation:) 는 현재 실행 컨텍스트와 다른 실행 컨텍스트를 가진다.

Task.init(priority:operation:) 를 호출하여 생성된 작업은 priority를 명시하지 않는 한 현재 스레드에서 실행되고 현재 QoS를 따르는 반면, Task.detached(priority:operation:) 를 호출하여 생성된 detached task는 현재 스레드와 완전히 분리되어 있으며, 다른 스레드에서 실행될 수 있다. 또한 QoS도 명시한 QoS를 따르거나 명시하지 않았을 시, .default이다.

 

Task Cancellation

Swift concurrency는 협력적인 취소 모델을 사용한다. 각 task는 실행 중 적절한 시점에 취소되었는지 여부를 확인하고 적절한 방식으로 취소에 응답한다. 수행 중인 작업에 따라 일반적으로 다음 중 하나를 의미한다.

  • CancellationError 같은 에러를 throwing
  • nil 또는 빈 collection을 return
  • 부분적으로 완료된 작업을 return

cancellation을 체크하기 위해선, 작업이 취소된 경우 CancellationError을 throw 하는 Task.checkCancellation() 를 호출하거나, Task.isCancelled 의 값을 체크하고 자체 코드에서 취소를 처리할 수 있다. 예를 들어 갤러리에서 사진을 다운로드하는 작업은 부분 다운로드를 삭제하고 네트워크 연결을 닫아야 할 수 있다.

취소를 수동으로 전파하려면 Task.cancel() 를 호출한다.

 

Actors

task를 사용해 프로그램을 격리된 동시적 부분으로 사용할 수 있다. Task는 각자 분리되어 동시에 실행할 때 안전하지만, 작업 간에 일부 정보를 공유해야 하는 경우가 있다. 이때, Actor을 사용하여 비동기 코드 간 정보를 안전하게 공유할 수 있다.

Actor는 클래스와 같이 참조 타입이다. 클래스와 달리 actor는 한 번에 하나의 작업만 변경 가능한 상태에 액세서할 수 있도록 허용하므로 여러 작업의 코드가 동일한 actor 인스턴스와 상호작용 하는 것이 안전하다.

예를 들어 아래 코드는 온도를 기록하는 actor이다.

 

actor TemperatureLogger {
    let label: String
    var measurements: [Int]
    private(set) var max: Int

    init(label: String, measurement: Int) {
        self.label = label
        self.measurements = [measurement]
        self.max = measurement
    }
}

 

TemperatureLogger actor는 actor외부의 다른 코드가 액세스 할 수 있는 프로퍼티들이 있으며, max 프로퍼티는 actor내부에서만 업데이트할 수 있도록 제한한다.

Actor인스턴스를 생성하는 것은 구조체나 클래스를 생성하는 것과 같다. actor에 프로퍼티나 메서드에 접근할 때, 잠재적인 일시 중단 지점에 await키워드를 사용한다.

 

let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"

 

이 예에서 logger.max 는 일시 중단 지점이다. actor는 변경 가능한 상태에 액세스 하기 위해 한번에 하나의 task만 허용하기 때문에, 다른 코드에서 task가 이미 logger와 상호작용하는 경우 이 코드는 프로퍼티에 액세스하기 위해 대기하는 동안 일시 중단된다.

반대로 actor의 일부인 코드는 await을 작성하지 않는다. 예를 들어, 온도 로거를 새 온도로 업데이트하는 방법이다.

 

extension TemperatureLogger {
    func update(with measurement: Int) {
        measurements.append(measurement)
        if measurement > max {
            max = measurement
        }
    }
}

 

update(with: ) 메서드는 actor에서 실행되기 때문에, max 프로퍼티에 접근할 때 await을 표기하지 않는다. 이 방법은 actor가 변경 가능한 상태와 상호 작용하기 위해 한 번에 하나의 작업만 허용하는 이유 중 하나를 보여준다. actor의 상태에 대한 일부 업데이트는 일시적으로 불변성을 깨뜨린다.

 

TemperatureLogger actor는 온도 목록과 최대 온도를 추적하고 새 측정값을 기록할 때 최대 온도를 업데이트한다. 업데이트 중간에 새 측정값을 measurements에 추가한 후 최댓값을 업데이트하기 전에 TemperatureLogger는 일시적으로 불변성이 깨진 상태이다. 여러 작업이 동일한 인스턴스와 동시에 상호작용하지 않도록 방지하면 다음과 같은 일련의 이벤트와 같은 문제가 방지된다.

  1. 코드가 update(with: ) 를 호출한다. 이는 measurements 배열을 처음 업데이트한다.
  2. max를 업데이트하기 전에, 코드 어느 곳에서 최댓값과, 온도 배열을 읽는다.
  3. 코드는 max를 변경하여 update(with: ) 를 완료한다.

이러한 경우 코드 어느 곳에서 update(with: ) 메서드가 완료되지 않은 중간 지점에서 잘못된 정보를 읽을 수 있기 때문에 데이터가 유효하지 않을 것이다. 이런 문제점을 Swift actor을 사용하여 방지할 수 있다.

왜냐하면 Actor는 한 번에 하나의 작업만 실행하고 해당 코드는 await가 일시 중단 지점을 표시하는 위치에서만 중단될 수 있기 때문이다.

update(with:)는 중단 지점을 포함하지 않기 때문에 다른 코드는 업데이트 중에 데이터에 액세스 할 수 없다.

클래스 인스턴스와 마찬가지로 actor 외부에서 해당 프로퍼티에 액세스 하려고 하면 컴파일 타임 오류가 발생한다.

 

print(logger.max)  // Error

 

await을 작성하지 않고 logger.max에 액세스 하는 것은 actor 의 속성이 해당 actor의 격리된 로컬 상태의 일부이기 때문에 실패한다. Swift는 actor 내부의 코드만 actor의 로컬 상태에 액세스 할 수 있음을 보장한다. 이 보장을 actor isolation이라고 한다.

 

Sendable Types

Task와 actor를 사용하면 프로그램을 안전하게 동시에 실행할 수 있는 조각으로 나눠준다. Task 또는 actor의 인스턴스 내부에서 변수 및 속성과 같은 변경 가능한 상태를 포함하는 프로그램의 부분을 concurrency domain이라 한다. 일부 데이터는 변경 가능한 상태를 포함하기 때문에 concurrency domain 간에 공유할 수 없지만 중복 접근으로부터 보호되지 않는다.

한 concurrency domain에서 다른 concurrency domain으로 공유할 수 있는 유형을 sendable type이라고 한다. 예를 들어, actor메서드를 호출할 때 인자로 전달되거나, task의 결과로 return 될 때이다. 그러나 일부 유형은 concurrency domain을 통과하는데 안전하지 않다. 예를 들어 변경 가능한 프로퍼티를 포함하고 해당 프로퍼티에 대한 접근을 직렬화하지 않은 클래스는 다른 작업 간에 해당 클래스의 인스턴스를 전달할 때 예측할 수 없고 잘못된 결과를 생성할 수 있다.

Sendable 프로토콜을 준수하여 sendable 유형을 선언할 수 있다. 이 프로토콜은 코드 요구사항이 없지만 Swfit가 강제하는 의미적 요구 사항이 있다. 일반적으로 sendable이 될 수 있는 세 가지 방법이 있다.

  • 값 타입이며, 그 타입의 가변 상태(mutable state)는 다른 sendable 데이터로 구성됐을 때. 예를 들어, sendable인 저장 프로퍼티를 가진 구조체 또는 sendable인 연관 값을 가진 열거형
  • 타입이 mutable state를 가지지 않고, immutable state가 다른 sendable 데이터로 이루어져 있는 경우. 예를 들어, 읽기 전용 속성만 있는 구조체나 클래스
  • 타입에 mutable state의 안전을 보장하는 코드가 포함되어 있는 경우. 예를 들어 @MainActor로 표시된 클래스 또는 특정 스레드나 큐에서 프로퍼티에 대한 접근을 직렬화하는 클래스

sendable프로퍼티만 가진 구조체나, sendable관련된 값을 가진 열거형은 항상 sendable 하다.

 

struct TemperatureReading: Sendable {
    var measurement: Int
}

extension TemperatureLogger {
    func addReading(from reading: TemperatureReading) {
        measurements.append(reading.measurement)
    }
}

let logger = TemperatureLogger(label: "Tea kettle", measurement: 85)
let reading = TemperatureReading(measurement: 45)
await logger.addReading(from: reading)

 

TemperatureReading 은 sendable 프로퍼티만 가진 구조체이고, public 또는 @usableFromInLine 으로 표시되지 않았기 때문에, 절대적으로 sendable 하다.

다음은 sendable 프로토콜에 대한 준수가 암시되는 구조의 버전이다.

 

struct TemperatureReading {
    var measurement: Int
}