Swift Programming Language - Concurrency

업데이트:

Swift Programming Language - Concurrency 를 번역한 글입니다.

영어 실력이 많이 부족합니다ㅠ 수정 제안 환영합니다 🙌

Concurrency

Swift는 비동기와 병렬 코드를 작성하기 위해 구조화된 빌트인 방식을 제공합니다. 비동기코드는 한 번에 하나의 프로그램만 실행되지만 일시중지한 후 나중에 다시 시작할 수 있습니다. 프로그램에서 코드를 일시중지하고 다시 시작하면 UI 업데이트와 같은 short-term 작업을 계속 진행하면서 네트워크를 통해 데이터를 가져오거나 파일을 파싱하는 것과 같은 long-running 작업을 계속 진행할 수 있습니다. 병렬코드는 여러 코드 조각이 동시에 실행됨을 의미합니다. 예를 들어, 쿼드코어 프로세서가 장착된 컴퓨터는 각 코어가 작업 중 하나를 수행하면서, 동시에 4개의 코드 조각을 실행할 수 있습니다. 병렬 및 비동기 코드를 사용하는 프로그램은 한 번에 여러 작업을 수행합니다. 이는 외부 시스템을 기다리는 작업을 일시중지하고, memory-safe한 코드를 쉽게 작성할 수 있게 합니다.

병렬 또는 비동기 코드의 추가적인 스케쥴링 유연성은 복잡성을 증가시킵니다. Swift는 컴파일 타임에 확인 가능하게 하는 방식으로 의도한 코드를 표현하도록 해줍니다. 예를 들어, 가변적인 상태(mutable state)에 안전하게 접근하기 위해 actors를 사용할 수 있습니다. 하지만, 느리거나 버그가 있는 코드에 동시성을 추가하는 것이 빠르거나 정확해질 것이라는 보장은 아닙니다. 사실 동시성을 추가하면 코드를 디버깅하기 더 어려워질 수도 있습니다. 하지만, 동시성이 필요한 코드에서 Swift 언어 수준에서 제공하는 동시성을 사용하면 Swift가 컴파일 타임에 문제를 잡는데 도움을 줄 수 있습니다.

이 문서에서는 동시성이라는 용어를 사용해 흔히 이야기하는 비동기 및 병렬 코드의 조합을 표현합니다.

NOTE

이전에 동시성 코드를 작성한 적이 있다면, 스레드로 작업하는데 익숙할 수 있습니다. Swift의 동시성 모델은 스레드 위에서 구축되었지만, 직접 상호작용하지 않습니다. Swift의 비동기 함수는 실행 중인 스레드를 포기하여 첫 번째 함수가 차단된 동안 해당 스레드에서 다른 비동기 함수를 실행할 수 있습니다.

Swift 언어 수준에서 제공하는 동시성을 사용하지 않고 동시성 코드를 작성할 수 있지만, 그 코드는 읽기가 더 어렵습니다. 예를 들어, 다음 코드는 사진 이름 목록을 다운로드하고 해당 목록의 첫 번째 사진을 다운로드한 다음, 해당 사진을 사용자에게 보여줍니다:

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

이러한 간단한 경우에도 코드는 일련의 completion handler로 작성되어야하므로 중첩된 클로져를 작성하게 됩니다. 이 스타일에서, 깊은 중첩이 있는 더 복찹한 코드는 빠르게 다루기 힘들 수 있습니다.

Defining and Calling Asynchronous Functions

비동기 함수 또는 비동기 메서드는 실행을 통해 부분적으로 일시중지될 수 있는 특별한 종류의 함수 또는 메서드입니다. 이는 완료될 때까지 실행되거나, 오류가 발생하거나, 반환되지 않는 일반적인 동기 함수 및 메서드와 대조됩니다. 비동기 함수 또는 메서드 역시 이러한 세가지 작업 중 하나를 수행하지만, 무언가를 대기하는 동안 중간에 일시정지할 수 있습니다. 비동기 함수 또는 메서드의 본문 내부에 실행이 일시중지될 수 있는 위치를 표시합니다.

함수 또는 메서드가 비동기임을 나타내려면, throws 를 사용해 throwing function을 표시하는 것과 비슷하게 해당 선언에서 매개변수 뒤에 async 키워드를 작성합니다. 함수 또는 메서드가 값을 반환하는 경우에는, 반환 화살표(->) 앞에 async 를 작성합니다. 예를 들어, 갤러리에서 사진 이름을 가져오는 방법은 다음과 같습니다:

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

두 키워드가 모두 필요한 함수 또는 메서드의 경우, throws 전에 aync 를 작성합니다.

비동기 메서드를 호출하면, 메서드가 반환될 때까지 실행이 일시중지됩니다. 가능한 일시중지 지점을 표시하기 위해 해당 함수 또는 메서드 호출 지점 앞에 await를 작성합니다. 이는 throwing function을 호출할 때, 오류 발생 시 프로그램 흐름 변경의 가능성이 있는 지점을 표시하기 위해 try 를 작성하는 것과 같습니다. 비동기 메서드 내부에서 실행 흐름은 오직 다른 비동기 메서드를 호출할 때만 일시중지되는데, 일시중지는 암시적이거나 우선적이지 않기 때문에, 가능한 모든 일시중지 지점은 await 로 표시됩니다.

예를 들어, 아래 코드는 갤러리의 모든 그림의 이름을 가져온 후 첫 번째 그림을 보여줍니다:

let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[1]
let photo = await downloadPhoto(named: name)
show(photo)

listPhotos(inGallery:)downloadPhoto(named:) 함수 모두 네트워크 요청을 해야하기 때문에, 완료되기까지 상대적으로 오랜 시간이 걸릴 수 있습니다. 반환 화살표(->) 전에 aync를 적어 비동기적으로 만들면 이 코드가 사진의 준비를 대기하는 동안 앱의 나머지 코드는 계속 실행될 수 있습니다.

위 예제의 동시성을 이해하기 위해, 다음과 같은 실행 순서가 있습니다:

  1. 코드는 첫 번째 줄에서 실행되기 시작하여, 첫 번째 await 까지 실행됩니다. listPhotos(inGallery:) 함수를 호출하고 해당 함수의 반환을 기다리는 동안 실행을 일시중지합니다.
  2. 코드의 실행이 일시중지되는 동안, 같은 프로그램의 다른 동시성 코드가 실행됩니다. 예를 들어, long-running 백그라운드 작업은 새로운 사진 갤러리 목록을 계속 업데이트할 수 있습니다. 그 코드 또한 await로 표시된 다음 일시중지 지점 또는 완료될 때까지 실행됩니다.
  3. listPhotos(inGallery:)가 반환된 후, 이 코드는 해당 시점부터 실행을 계속합니다. 반환된 값을 photoNames 에 할당합니다.
  4. sortedNamesnames를 정의하는 줄은 규칙적이고 동기적인 코드입니다. 이 줄에는 await로 표시된 부분이 없기 때문에, 여기에는 가능한 일시중지 지점이 없습니다.
  5. 다음 awaitdownloadPhoto(named:) 함수 호출을 표시합니다. 이 코드는 해당 함수가 반환될 때까지 다시 일시중지하여 다른 동시성 코드에 실행 기회를 제공합니다.
  6. downloadPhoto(named:) 반환 후, photo에 값이 할당되고 show(: )를 호출할 때 인수로 전달됩니다.

await로 표시된 가능한 일시중지 지점은 현재 코드 조각이 비동기 함수 또는 메서드의 반환을 기다리는 동안 실행을 일시중지할 수 있음을 나타냅니다. 이것은 스레드 양보 라고도 하는데, 이면에서, Swift가 현재 스레드에서 코드 실행을 일시중지하고 해당 스레드에서 다른 코드를 대신 실행하기 때문입니다. await 키워드가 사용된 코드는 실행을 일시중지할 수 있어야하기 때문에, 프로그램의 특정 장소에서만 비동기 함수 또는 메서드를 호출할 수 있습니다:

  • 비동기 함수, 메서드 또는 프로퍼티의 본문에 있는 코드
  • @main으로 표시된 구조체, 클래스 또는 열거형의 static main() 메서드에 있는 코드
  • Unstructured Concurrency 에서 볼 수 있듯이 detached child task에 있는 코드

NOTE Task.sleep(_:) 메서드는 동시성이 어떻게 작동하는지 배우기 위해 간단한 코드를 작성할 때 유용합니다. 이 메서드는 아무것도 하지 않지만, 반환되기 전에 최소한 주어진 나노초의 수를 기다립니다. 다음은 sleep()을 사용해 네트워크 작업 대기를 시뮬레이션하는 listPhotos(inGallery:) 함수의 버전입니다.

func listPhotos(inGallery name: String) async -> [String] {
    await Task.sleep(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을 작성합니다. 비동기 함수나 메서드를 호출할 때처럼, await를 작성하는 것은 가능한 일시중지 지점을 나타냅니다. for-await-in 루프는 다음 요소가 사용가능하기를 기다리고 있을 때, 각 반복의 시작에서 실행을 잠재적으로 일시중지합니다.

Sequence 프로토콜을 준수해 for-in 루프에서 커스텀 타입을 사용하는 것과 같이, AsyncSequence 프로토콜을 준수하여 for-await-in 루프에서 커스텀 타입을 사용할 수 있습니다.

Calling Asynchronous Functions in Parallel

await와 함께 비동기 함수를 호출하면 한 번에 코드 한 조각만 실행됩니다. 비동기 코드가 실행되는 동안, 호출자는 다음 코드 줄을 실행하기 위해, 이동하기 전 해당 코드가 끝나기를 기다립니다. 예를 들어, 갤러리에서 처음 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가 정의된 줄까지 실행이 계속됩니다. 이 시점에서 프로그램은 비동기 호출의 결과를 필요로하므로 await를 작성해 세 사진의 다운로드가 완료될 때까지 실행을 일시중지해야합니다.

두 접근법의 차이점에 대해 고민하고, 적절하게 사용할 수 있는 방법은 다음과 같습니다:

  • 다음 줄의 코드가 해당 함수의 결과에 따라 달라지는 경우, await를 사용해 비동기 함수를 호출하세요. 이는 순차적으로 수행되는 작업을 생성합니다.
  • 나중 코드에서 결과가 필요하지 않을 때, async-let을 사용해 비동기 함수를 호출하세요. 이는 병렬로 수행되는 작업을 생성합니다.
  • await과 async-let 모두 일시중지된 동안 다른 코드를 실행할 수 있도록 합니다.
  • 두 경우 await을 사용해 모두 가능한 일시중지 지점을 표시하여 필요한 경우 비동기 함수가 반환될 때까지 실행이 일시중지됨을 나타냅니다.

또한 같은 코드에 두 가지 접근법을 혼합할 수 있습니다.

Task and Task Groups

task 는 프로그램의 일부로 비동기로 실행될 수 있는 작업의 단위입니다. 모든 비동기 코드는 여러 task의 일부로 실행됩니다. 이전 섹션에서 설명한 async-let 구문은 child task를 생성합니다. 또한 task group을 생성하고 해당 그룹에 child tasks를 추가하여 우선순위 및 해제를 보다 세부적으로 제어할 수 있으며, 동적으로 여러 태스크를 생성할 수 있습니다.

task는 게층 구조로 정렬됩니다. task group의 각 task에는 동일한 parent task가 있으며, 각 task는 chile taks를 가질 수 있습니다. tasks와 task group 간의 명시적인 관계때문에, 이 접근법은 구조화된 동시성(structured concurrency) 이라고 합니다. 정확성에 대한 일부 책임은 개발자에게 있지만, parent-child 관계 덕분에 Swift는 해제 전파(propagating cancellation)와 같은 일부 동작을 처리하고, 컴파일 타임에 일부 오류를 감지할 수 있습니다.

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

task groups에 대한 자세한 내용은 TaskGroup 를 확인하세요.

Unstructured Concurrency

이전 섹션에서 설명한 동시성에 대한 구조화된 접근법 외에도 Swift는 구조화되지 않은 동시성을 지원합니다. task group의 일부인 tasks와 달리 구조화되지 않은 task(unstructured task) 에는 parent task가 없습니다. 프로그램이 필요로하는 방식으로 구조화되지 않은 task를 관리할 수 있는 완전한 유연성을 갖추고 있지만 정확성에 대한 책임은 전적으로 개발자에게 있습니다. 현재 actor에서 실행되는 구조화되지 않은 task를 생성하려면 async(priority:operation:) 함수를 호출하세요. 현재 actor의 일부가 아닌 구조화되지 않은 task(더 구체적으로는 detached task로 알려진)를 생성하기 위해서는, asyncDetached(priority:operation:) 메서드를 호출하세요. 이 두 함수는 결과를 기다리거나 취소하는 것과 같은 task와 상호작용할 수 있는 task handle을 반환합니다.

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

detached tasks 관리에 대한 자세한 내용은 Task.Handle 을 확인하세요.

Task Cancellation

Swift 동시성은 협력 해제 모델(cooperative cancellation model)을 사용합니다. 각 task는 실행 중 적절한 지점에서 해제 여부를 확인하고, 적절한 방법으로 해제에 대응합니다. 수행중인 작업에 따라, 일반적으로 다음 중 하나를 의미합니다.

  • CancellationError 와 같은 오류 발생
  • nil 또는 빈 컬렉션 반환
  • 부분적으로 완료된 작업 반환

해제를 확인하기 위해서는, task가 해제된 경우 CancellationError 를 발생시키는 Task.checkCancellation() 를 호출하거나 Task.isCancelled 값을 확인하고 코드에서 직접 해제를 처리하세요. 예를 들어, 갤러리에서 사진을 다운로드하는 task는 부분 다운로드를 삭제하고 네트워크 연결을 해제해야 할 수 있습니다.

해제를 수동으로 전파하려면 Task.Handle.cancel() 을 호출합니다.

Actors

클래스와 마찬가지고 actors는 참조 타입이기 때문에, Classes Are Reference Types 에서의 값 타입과 참조 타입의 비교는 클래스뿐만 아니라 actors에게도 적용됩니다. 클래스와 달리 actors는 한 번에 하나의 task만 가변적인 상태에 접근할 수 있도록 허용하므로, 여러 tasks의 코드가 동일한 인스턴스와 상호작용하는 것을 안전하게 만들어줍니다. 예를 들어, 다음은 온도를 기록하는 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
    }
}

actor 키워드와 함께 한 쌍의 중괄호로 정의된 actor를 소개합니다. TemperatureLogger actor에는 actor 외부의 다른 코드가 접근할 수 있는 프로퍼티가 있으며, actor 내부의 코드만 최대값을 업데이트할 수 있도록 max 프로퍼티를 제한합니다.

구조체 및 클래스와 동일한 이니셜라이저 구문을 사용해 actor의 인스턴스를 생성합니다. actor의 프로퍼티 또는 메서드에 접근할 때 await를 사용해 잠재적 일시중지 지점을 표시합니다. 예를 들면 다음과 같습니다:

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

이 예제에서, logger.max에 접근하는 것은 일시중지 시점이 될 수 있습니다. actor는 가변적인 상태에 접근하기 위해 한 번에 하나의 task만 허용하기 때문에, 다른 task의 코드가 이미 logger와 상호작용하고 있다면, 이 코드는 프로퍼티에 접근하기 위해 대기하는 동안 일시중지됩니다.

이와 반대로, actor의 일부인 코드는 actor의 프로퍼티에 접근할 때 await를 작성하지 않습니다. 예를 들어, 다음은 새 온도로 TemperatureLogger 을 업데이트하는 메서드입니다:

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

update(with:) 메서드는 이미 actor에서 실행 중이므로, max와 같은 프로퍼티에 대한 접근을 await로 표시하지 않습니다. 이 메서드는 또한 actor가 한 번에 하나의 task만 가변적인 상태와의 상호작용을 허용하는 이유 중 하나를 보여줍니다: actor의 상태에 대한 일부 업데이트는 일시적으로 불변을 깨뜨립니다. TemperatureLogger actor는 온도 리스트와 최대 온도를 추적하고 새 온도를 기록할 때 최대 온도를 업데이트합니다. 업데이트 중간에 새 측정 값을 추가한 후, max을 업데이트하기 전에 temperature logger는 일시적으로 불일치(inconsistent) 상태에 있습니다. 여러 tasks가 동일한 인스턴스와 동시에 상호작용하는 것을 방지하는 것은 다음과 같은 일련의 사건과 같은 문제를 예방합니다.

  1. 코드는 update(with:) 메서드를 호출합니다. 이는 measurements 배열을 먼저 업데이트합니다.
  2. 코드가 max를 업데이트하기 전에, 다른 코드는 최대 값과 온도 배열을 읽습니다.
  3. 코드는 max를 변경하여 업데이트를 완료합니다.

이 경우, 다른 곳에서 실행되는 코드는 actor에 대한 접근이 update(with:) 호출 중간에 데이터가 일시적으로 유효하지 않은 상태에서 끼어들기(interleaved) 때문에 잘못된 정보를 읽게 됩니다. Swift actors를 사용할 때는 상태에 대해 한 번에 하나의 작업만 허용하고, await가 표시하는 위치에서만 코드가 일시중지될 수 있기 때문에 이 문제를 예방할 수 있습니다. update(with:) 에는 어떠한 일시중지 지점도 포함하고 있지 않기 때문에 다른 코드는 업데이트 도중 데이터에 접근할 수 없습니다.

만약 클래스의 인스턴스를 사용하는 것처럼, actor 외부에서 이러한 속성에 접근하려고 시도한다면, 컴파일타임 에러가 발생할 것 입니다. 예를 들어:

print(logger.max)  // Error

actor의 프로퍼티가 해당 actor의 격리된(isolated) 로컬 상태의 일부이기 때문에, await를 작성하지 않고 logger.max에 접근하는 것은 실패합니다. Swift는 actor 내부의 코드만이 actor의 로컬 상태에 접근할 수 있도록 보장합니다. 이 보장은 actor isolation이라고 합니다.

댓글남기기