Swift Language Guide: Concurrency

J.Noma·2021년 12월 18일
0

Swift : 문법

목록 보기
4/11

Swift Language Guide: Concurrency


🐶서문

Swift는 자체적으로 비동기&병렬 코드 사용을 지원합니다

🔘 비동기 코드
비동기 코드는 한번에 한 부분만 실행하더라도 suspend되고 이후 resume될 수 있습니다. 그리고, 프로그램에서 Suspend/Resume 코드는 long-term 동작 중에 short-term 동작을 계속할 수 있도록 해줍니다 (ex. 네트워크로부터 Data fetching 중에 UI update하기)

🔘 병렬 코드
병렬 코드는 동시에 여러 코드 부분을 실행하는 것을 말합니다. (예로, 4코어 프로세서를 가진 컴퓨터가 동시에 4 pieces 코드를 돌릴 수 있는 것). 병렬 코드는 외부 시스템을 기다리는 동작을 suspend하고 Memory-safe 방식으로 코드를 작성하기 용이하도록 해줍니다

🔘 비동기&병렬은 복잡도를 증가시킨다. 다만, Swift가 나름 노력한다
또한, 비동기&병렬 코드로부터 뽑아내는 스케줄링 유연성은 복잡도 증가에 따른 비용을 유발합니다. Swift는 당신의 의도를 compile-time에 확인할 수 있는 방식으로 표현하도록 합니다. (예로, mutable state에 안전하게 접근하기 위해 Actor를 사용하는 것)

🔘 비동기&병렬이 정답이 아니다
하지만, 느리고 버그성 코드에 동시성을 부여하는 것은 항상 빠르고 정확해짐을 보장하지 않습니다. 사실 동시성 부여는 오히려 코드를 디버깅하기 어렵게 만듭니다

하지만 동시성에 대한 Swift의 언어 level support를 사용하면 compile-time에 문제점들을 잡아내는데 도움을 줄 것입니다

🔘 Concurrency in Swift
이 포스팅에서 '동시성'이라는 표현은 '비동기+병렬'의 일반적인 조합을 지칭합니다

❗️NOTE❗️
만약 동시성 코드를 이전에 써봤다면, 쓰레드 작업에 익숙할 것입니다. Swift의 동시성 모델은 쓰레드의 top에서 만들어졌지만 쓰레드와 직접적으로 상호작용하지는 않습니다. Swift의 async 함수는 실행 중인 쓰레드를 포기하고(block) 해당 쓰레드가 다른 async 함수를 실행하도록 만들 수 있습니다

Swift의 언어 suppport를 사용하지 않고도 동시성 코드를 작성하는게 가능하긴 하지만, 읽기가 어려워지는 경향이 있습니다. 예로, 아래 코드는 사진 photo name list와 첫 번째 photo를 불러와 유저에게 보여주는 코드입니다

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

간단한 case임에도 연쇄적인 completion handler 형태로 작성되어야 하므로 결국 Nested 클로저로 작성해야 합니다. Nesting이 더 깊은 코드는 더 복잡하고 다루기 힘들어집니다



🐱 Async 함수 정의/호출

✔️ 정의

async 함수는 실행 중 어느시점에 suspend될 수 있는 특별한 종류의 함수입니다. 이는 (완료될 때까지 실행되거나, 에러를 던지거나, 절대 return하지 않는) 평범한 sync 함수와는 대조적입니다

async 함수도 이 3가지를 하지만, 무언가를 기다리기 위해 중간에 멈출 수 있습니다. async 함수 내부에서 실행을 suspend할 부분에 표시를 하면 됩니다. 함수가 async임을 알리기 위해, async 키워드를 선언부의 parameter와 return 사이에 적어줍니다

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

✔️ 호출

async 메서드를 호출하면, 해당 메서드가 return할 때까지 기존 코드 흐름이 suspend됩니다. 우리는 suspension 가능한 포인트에 await 키워드를 붙혀 줍니다

async 메서드 내에서 실행 flow가 suspend되는 경우는 그 안에서 또 다른 async 메서드를 호출할 때 뿐입니다(참고로, suspension은 절대 암시적이거나 선제적이지 않습니다). 즉, suspension이 가능한 포인트에는 항상 await로 표기됩니다

✔️ 호출: Detail

예제 코드를 봅시다

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

listPhotos/downloadPhoto는 상대적으로 오래 걸리는 작업들이므로 await를 부여하였습니다. 그리고 두 함수는 선언부에 async 키워드를 동반하게 되고 이는 사진이 완전히 준비되길 기다리지 않고 코드를 계속 진행할 수 있게 해줍니다

위 코드를 순차적으로 해석해봅시다

  1. 첫 line에서부터 코드가 시작되고 첫 await를 만납니다
    이는 listPhotos 함수를 호출하고, return될 때까지 실행을 suspend합니다
  1. 코드 실행이 suspend되어 있는 동안, 같은 프로그램 내에서 다른 '동시코드'가 실행됩니다
    이 동시코드는 오래 걸리는 background task가 photo를 계속 업데이트합니다
    그리고, 도중에 다음 suspension 포인트를 만난거나 완료될 때까지 자신의 코드를 실행합니다
    (다음 suspension 포인트란, await 함수 내에서 또 다른 await 함수를 만나는 경우를 말합니다)
  1. listPhoto가 return되면 suspend 되어 있던 메인 코드의 실행이 재개됩니다
    listPhotos함수의 결과가 photoName에 할당됩니다
  1. 다음 2 line은 sync 메서드로 우리가 익히 알고 있듯 순차적으로 평범하게 실행됩니다
    await 키워드가 없으므로 suspension 가능한 포인트가 아닙니다
  1. downloadPhoto에서 다음 await을 만나게 되고 역시나 또 다른 '동시코드'가 실행됩니다
    메인 코드는 동시코드가 return될 때까지 다시 suspend됩니다
  1. downloadPhoto에서가 return되면 photo에 그 값이 할당되고 show 함수가 실행됩니다

✔️ 쓰레드 양보

await 키워드를 동반하는 suspension 가능 포인트는 현재 코드 조각이 실행을 잠시 멈추고 async 함수의 return을 기다리게 되는 것을 의미합니다
이를 '쓰레드 양보'라고도 표현합니다. 왜냐하면 우리가 보이지 않는 부분에서 Swift가 현재 쓰레드에 있는 코드 실행을 suspend하고 대신 그 쓰레드에 다른 코드를 올려서 실행시키기 때문입니다
(하나의 쓰레드를 두고 코드를 바꿔치기하는 개념)

✔️ async 함수는 특정 조건에서만 호출 가능하다

await이 붙은 코드는 실행을 suspend하는게 가능하도록 해야 하기 때문에, 프로그램에서 특정 부분에서만 async 함수를 호출할 수 있습니다

  • async 함수/메서드/연산프로퍼티의 내부 body
  • @main이 붙은 struct/class/enum의 main()메서드
  • 분리된 child task

분리된 child task란?
아래 코드와 같은 Unstructred Concurrency를 말합니다

let handle = Task {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}

❗️NOTE❗️
Task.sleep() 메서드는 동시성이 어떻게 동작하는지 배우기 위해 간단한 코드를 작성할 때 유용합니다. 이 메서드는 아무 일도 하지 않지만 argument로 넣어주는 숫자만큼 기다립니다. 아래는 학습을 위해 네트워크 동작을 모방한 예시입니다

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


🐭 Async Sequence

이전 섹션의 listPhotos 함수는 비동기적으로 '한 번에' 전체 배열을 return합니다. 이에 대한 또 다른 접근법은 전체 배열이 아닌 Collection의 Element 하나하나를 기다리게 하는 것입니다

import Foundation

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

위와 같이 for-await-in 루프를 사용하면 각 iteration의 시작부분에 suspension 가능 포인트가 설정되고 다음 Element가 준비될 때까지 기다립니다

for-await-in을 사용하려면, AsyncSequence 프로토콜을 채택하는 것이 필요합니다



🐹 병렬로 Async 함수 호출하기

await 키워드로 async 함수를 호출하는 것은 한 번에 하나의 코드부분만 실행시키게 됩니다. 즉, async 코드가 실행 중인 동안 caller는 next line을 실행하지 않고 기다립니다

✔️ 기존방식 (await)

아래 예제를 살펴봅시다

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)

매번 await를 사용하는 접근법에는 중대한 결점이 있습니다. download가 async로 동작하고 그 동안 다른 작업이 수행될 수 있지만, 한 번에 하나의 downloadPhoto만 호출하게 됩니다. 즉, 각 download는 다음 download가 시작되기 전에 완료됩니다

하지만 우리는 이렇게 각각을 기다려줄 필요가 없습니다. 각 download는 독립적으로 수행될 수 있고 심지어는 동시에 수행되어도 됩니다

✔️ 병렬호출 (async-let)

async 함수들이 병렬적으로 실행되도록 하기 위해, 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)

위 예제에서, 3개의 downloadPhoto는 동시에 시작됩니다. 그리고 만약 충분한 시스템 자원이 가용하다면, 동시에 수행됩니다. await-let 시점에서는 코드가 함수의 결과를 기다리기 위해 suspend되지 않기에 함수 호출부에 await 키워드가 붙지 않습니다. 그리고 photos가 정의되는 line까지 실행이 계속됩니다. 마지막 line 지점에서야 프로그램이 async 함수의 결과가 필요해지면서 suspend되므로 await이 붙습니다

✔️ 기존방식 vs 병렬호출

기존방식(await)
async 함수의 결과가 당장 다음 line 수행에 필요한 경우 await과 함께 호출합니다. 이 경우, 작업들이 순차적으로 수행됩니다

병렬호출(async-let)
async 함수의 결과가 다음 line이 아닌 나중에야 필요한 경우 await-let으로 호출합니다. 이 경우, 작업들이 병렬적으로 수행됩니다

await과 async-let 모두
suspend 중 다른 코드가 실행되는 것을 허용합니다
그리고, suspension 가능 포인트에 await을 찍습니다

두 접근법은 혼용될 수 있습니다



🐰 Task / Task Group

Task는 비동기적으로 실행될 수 있는 작업의 '단위'를 말합니다. 모든 async 코드는 Task의 일부로써 실행됩니다. 이전 section에서 언급했던 async-let는 child Task를 생성하는 구문입니다

또한, 우리는 Task Group을 만들고 group에 child Task를 추가할 수도 있습니다. Task Group은 우선순위/실행취소에 있어 더 많은 제어권을 주고, Task의 개수를 동적으로 만들 수 있게 해줍니다

✔️ Structured Concurrency

Task는 계층구조로 정렬됩니다. 어떤 Task Group 내 각 Task들은 동일한 parent Task를 가지며 child Task 또한 자신만의 child Task를 가질 수도 있습니다. 이런 접근법은 Task와 Task Group 간 관계를 명시해야 하므로 Structured Concurrency라 불립니다

🔘 장점
비록 정확성에 대한 책임이 일부 주어지지만, parent-child 관계를 명시하는 것은 Swift가 실행취소를 전파한다거나 하는 동작들을 처리할 수 있게 해줍니다
그리고, Swift가 compile-time에 일부 에러들을 검출해낼 수 있게 해줍니다

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는 Unstructured 접근법도 지원합니다. 다만 Task가 Task Group의 일부인 Structured Concurrency와 달리, Unstructured Concurrency는 parent Task를 가지지 않습니다

🔘 장점
이를 통해, unstructured Task들을 관리하는데 있어 프로그램이 필요로 하는 어떤 방식으로든 가능하다는 유연성을 가집니다. 하지만 Unstructured Concurrency도 정확성에 대한 책임은 있습니다

🔘 current Actor에서 동작하는
unstructured Task를 생성하기 위해서는 Task.init(priority:operation:) 생성자를 호출합니다

🔘 반면, current Actor의 일부가 아닌
unstructured Task를 생성하기 위해서는 Task.detached(priority:operation:) 메서드를 호출합니다. (detached Task라고 불립니다)

두 operation 모두 당신이 Task와 상호작용할 수 있도록 하는 'Task Handle'을 return합니다. 예로, Task의 결과를 기다리거나 취소하는게 있습니다

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

✔️ Task 실행취소

Swift의 concurrency는 Task 실행취소에 있어 협력적인 모델을 사용합니다

각 Task는 실행 구문 내 특정 지점에서 자신이 취소되었는지를 확인합니다. 그리고 적절한 어떤 방식으로든 취소에 대응합니다

🔘 Task 취소방식
취소 방식은 어떤 작업을 하고 있느냐에 따라 아래 중 한 가지 방식을 사용합니다

  • CancellationError 같은 Error throwing
  • nil 혹은 Empty Collection을 return
  • 부분적으로 완료된 작업을 그대로 return

🔘 Task 취소 '확인' 방식
실행취소를 확인하기 위해서는 취소방식에 따라 크게 2가지 방법이 있습니다. Error throwing은 Task.checkCancellation()를 호출하고, 나머지는 Task.isCancelled로 값을 체크합니다

예로, 사진을 다운로드하는 Task는 중간에 일부 다운로드를 삭제하고 네트워크 연결을 종료시키는게 필요할 수 있습니다

🔘 Task 취소 수동 전파
실행취소를 수동으로 전파하려면, Task.cancel()를 호출하면 됩니다



🦊 Actors

class처럼 Actor도 참조타입입니다. 그래서 기존의 값타입/참조타입 간 비교점들이 Actor에도 동일하게 적용됩니다

✔️ 한 번에 하나의 Task만 접근가능

하지만 class와는 달리, Actor는 mutable state에 접근하기 위해 '한 번에 하나의 Task만' 허용합니다
이는 동일한 Actor 인스턴스를 여러 개의 Task가 상호작용하는 경우에서 안전성을 확보하기 위함입니다

예로, 온도를 기록하는 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는 외부에서 접근할 수 있는 label/measurements 프로퍼티들을 가지고 있습니다
그리고 max 프로퍼티는 내부 메서드만 변경할 수 있도록 제한하였습니다

✔️ 프로퍼티/메서드에 접근할 땐 await 표시

아래 코드와 같이, 여느 struct/class와 동일한 구문으로 Actor 인스턴스를 생성할 수 있습니다
다만, Actor의 프로퍼티/메서드에 접근할 때는 await을 동반하여 suspension 가능 포인트임을 표시합니다

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

마지막 line에서 logger.max에 접근하는 것은 suspension 가능 포인트입니다
Actor는 자신의 mutable state에 접근함에 있어 한 번에 하나의 Task만 허용하므로 만약 다른 Task에서 이미 logger와 상호작용하고 있었다면 이 코드는 suspend됩니다

✔️ 내부 코드에서 자신의 프로퍼티에 접근할 땐 await 없음

반면, Actor의 메서드에서는 자신의 프로퍼티에 접근하기 위해 await를 쓰지 않습니다

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

위의 update() 메서드는 이미 Actor에서 실행 중이므로 max에 접근하기 위해 await을 표시하지 않습니다

✔️ 한 번에 하나의 Task만을 허용해야 하는 이유

또한, 이 메서드는 Actor가 mutable state와 상호작용함에 있어 한 번에 하나의 Task만을 허용해야 하는 이유들을 보여줍니다 (메서드 내에서 max를 변경하므로 mutable state의 불변성을 깨뜨리기 때문)

🔘 예시
TemperatureLogger Actor는 온도내역(measurements)과 최대온도(max)를 기록합니다. 그리고 새로운 온도내역이 기록될 때, 최대온도를 업데이트합니다

이 구조에서 새로운 온도내역은 기록했으나 아직 최대온도를 업데이트하지 못한 순간이 존재하게 되는데, 이 때 logger는 '일시적인 불일치 상태'에 놓이게 됩니다. 하지만 우리는 동일한 Actor 인스턴스에 여러 Task가 동시에 상호작용하지 못하도록 막았으므로 아래와 같은 문제 case를 예방하게 됩니다

🔘 만약 Multiple Task를 허용했다면

  1. update() 메서드 호출. measurements를 처음으로 업데이트 (append)
  2. max를 업데이트 하기 전에, 외부 코드에서 max와 measurements를 읽음
  3. 이후 max가 정상적으로 업데이트됨

이런 경우에서, 외부 코드는 '일시적인 불일치 상태'에 있는 잘못된 정보를 읽을지도 모릅니다.

🔘 Swift의 Actor는 예방가능하다
Swift의 Actor를 사용하면 한 번에 하나의 operation만 그들의 state에 접근할 수 있고, await을 별도로 만나지 않는 한 코드가 정지되지 않으므로 이런 문제를 예방할 수 있습니다. update()메서드에는 await 코드가 포함되어 있지 않으므로, 다른 어떤 코드도 update 중간에 data에 접근할 수 없습니다

✔️ Actor Isolation

만약 Actor 외부에서 일반적인 class 인스턴스에 접근하듯이 프로퍼티에 접근하면 complie-error가 유발됩니다

print(logger.max)  // Error

Actor의 프로퍼티들은 해당 Actor만의 고립된 local state이므로 await없이는 logger.max에 접근할 수 없습니다. Swift는 Actor 내부 코드에서만 자신의 local state에 접근할 수 있도록 보장하는데, 이를 Actor Isolation이라 합니다.

profile
노션으로 이사갑니다 https://tungsten-run-778.notion.site/Study-Archive-98e51c3793684d428070695d5722d1fe

0개의 댓글