티스토리 뷰

문득 Task의 사용 방법을 자세히 모르고 있는 것 같아 Swift Concurrency 공식 문서를 정독해보게 되었습니다. 

 

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/

 

Documentation

 

docs.swift.org

이건 영문이고

https://bbiguduk.gitbook.io/swift/language-guide-1/concurrency

 

동시성 (Concurrency) - Swift

한 동시성 도메인에서 다른 동시성 도메인으로 공유될 수 있는 타입을 전송 가능 타입 (sendable type) 이라고 합니다. 예를 들어, 액터 메서드로 호출될 때 인수로 전달되거나 작업의 결과로 반환될

bbiguduk.gitbook.io

이건 한글 번역본입니다. 번역본이 완벽하진 않아서 원문을 참고하며 읽었습니다!

 

 

비동기와 병렬


비동기 코드(Asynchoronous code)

비동기 코드란, 일시적으로 중단되었다가 다시 실행될 수 코드를 말합니다.

실행을 멈추고, 재개하는 방식을 통해 프로그램은 UI 업데이트와 같은 짧은 작업과 네트워크 I/O, 파일 I/O, 파일 분석, 긴 데이터 처리 작업 등의 같이 시간이 오래걸리는 작업을 동시에 진행할 수 있습니다.

 

 

병렬 코드(Parallel code)

병렬 코드란, 동시에 코드의 여러 부분이 실행될 수 있음을 의미합니다.

예를 들어 4코어 프로세서 컴퓨터는 각 코어가 하나의 작업을 수행할 수 있으므로 코드의 4부분을 동시에 실행할 수 있습니다.

 

✅ 따라서 병렬 코드비동기 코드를 이용하는 프로그램은 한번에 여러 작업을 수행할 수 있게 됩니다.

 

스케쥴링

비동기 작업 수행하기 위해서는 스레드에 대한 스케줄링이 유연해야합니다. (실행을 재개할 스레드 스케줄링 등) 이는 코드의 복잡성을 증가시킬 우려가 있습니다. 동시성을 추가한다고 원래부터 느리거나 버그가 있는 코드가 잘 작동하게 되는 것이 아니며, 동시성은 디버깅을 어렵게 만듭니다.

 

이를 해결하기 위해서 Swift는 언어 레벨에서 컴파일 타임에 동시성 코드를 검사할 수 있는 방법들을 제공합니다. 예를 들면 actor를 이용해서 여러 스레드에서 안전하게 mutable state에 접근할 수 있습니다.

 

📢 앞으로 내용에서 동시성이란 단어는 비동기와 병렬을 포함하는 의미로 사용됩니다.

 

스레드 포기

Swift에서 비동기 함수는 실행중인 스레드를 포기할 수 있습니다. 이때 포기된 스레드는 이전 작업을 중단(suspend)하고 다른 작업을 진행할 수 있습니다. 추가적으로, Swift는 비동기 작업을 재개할 때 호출 스레드에서 실행할 것인지, 다른 스레드에서 실행할 것인지는 보장하지 않습니다.

 

Swift의 언어적 지원이 없는 비동기 코드

async-await과 같은 문법적 지원을 사용하지 않고도 동시성을 구현할 수는 있습니다. 하지만 이는 많은 문제점들이 있습니다.

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

이렇게 후행 클로저를 통해 비동기 함수를 구현할 수 있지만, 클로저를 중첩해서 작성해야하므로 가독성이 떨어지고 코드의 흐름을 읽는데 방해가 됩니다. (콜백 지옥 문제)

 

 

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


비동기 함수(asynchronous function) 또는 비동기 메서드(asynchronous method) 실행 도중에 일시적으로 중단(suspend)될 수 있는 특수한 함수/메서드입니다. Swift는 이를 쉽게 핸들링하기 위한 문법을 지원합니다.

 

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

위와 같이 async 키워드를 사용해 비동기 함수임을 나타낼 수 있습니다. async 키워드가 붙은 함수는 내부에서 중단 가능한 작업(시간이 오래 걸리는 작업)을 정의할 수 있습니다. 중단 가능한 지점은 await 키워드로 표시합니다. 에러가 발생할 여지가 있는 경우 try await을 이용해 에러를 catch할 수 있습니다.

 

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

비동기 함수는 위와 같이 사용할 수 있습니다. 위 코드에서 중단 지점은 await 키워드가 사용된 첫번째 줄과 네번째 줄입니다. await 키워드가 붙지 않은 다른 코드는 동기적으로 실행됩니다. 실행 흐름은 다음과 같습니다.

 

  1. listPhotos함수를 호출하고 해당 함수가 반환될 때까지 실행을 일시 중단
  2. 실행이 중단되는 동안 해당 함수가 아닌 다른 코드를 실행 (이를 스레드 양보 (yielding the thread)라고도 부른다)
  3. listPhotos가 반환되면 photoNames에 반환값을 할당한 후 해당 지점부터 다시 계속 실행
  4. 다음 두줄은 일반적인 동기 코드이므로 중단점이 없음
  5. downloadPhoto를 만나면 await가 있으므로 함수를 호출하고 중단
  6. downloadPhoto가 반환되면 반환값은 photo에 할당된 다음에 show()의 호출인자로 전달

 

await 호출 가능 위치

await 키워드는 코드의 실행을 일시 중단 하므로 특정한 비동기 컨텍스트 내에서만 사용할 수 있습니다. 사용 가능한 경우는 다음과 같습니다.

✅ 비동기 함수/메서드/프로퍼티(async 키워드가 붙은 함수/메서드/프로퍼티)의 body(클로저 내부)
✅ structure, class, enum의 static main() 함수 (@main으로 마크된 함수)
✅ 구조화되지 않은 자식 Task - 하단의 구조화되지 않은 동시성 부분 참고

 

 

비동기 시퀸스(Asynchronous Sequences)


파일을 한줄 씩 읽거나, 서버에서 청크로 나눠진 데이터를 받을 때 비동기 시퀸스를 이용할 수 있습니다. Swift에서는 비동기 요소가 여러게 있을 때 for 문을 통해 비동시 시퀸스를 핸들링할 수 있게 제공합니다.

 

import Foundation

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

for try await - in 을 사용하면 다음 요소를 사용할 수 있을 때까지 기다리고 잠재적으로 실행을 일시 중단할 수 있습니다.

 

✓ for - in 문이 Sequence 프로토콜을 준수하면 사용할 수 있듯, for try await - in 문AsyncSequence 프로토콜을 준수하면 사용할 수 있습니다. 따라서 자체 타입에 대해서도 for try await - in 문을 쓸 수 있게 커스텀할 수 있습니다.

 

 

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


Swift의 async-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의 함수 작업이 끝난 뒤 두번째 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)

이때 async let 키워드를 사용하면 한번에 함수들을 await해 병렬적으로 실행할 수 있습니다. 만약 스레드 풀의 여유가 충분하다면 세 작업은 동시에 실행됩니다.

 

✓ 또한 비동기 실행 결과가 필요하지 않은 경우에도 async let을 사용할 수 있습니다. 4번째 줄의 await 없이 3번의 async let 호출만 하게 되면 단순히 비동기 작업을 병렬적으로 실행하고 다음 코드를 수행합니다. (async let 없이 비동기 함수를 호출하면 컴파일 에러가 뜹니다)

 

 

구조적 동시성(structed concurrency)


작업

작업은 비동기 코드를 실행할 수 있는 최소 단위입니다.

따라서 모든 비동기 작업은 Task 작업의 일부로서 실행됩니다. 아까 보았던 async-let의 경우도 Task를 생성하여 실행하는 것입니다.

 

작업 그룹

작업 그룹(Task Group)을 사용하면 해당 그룹의 하위 Task를 추가할 수 있습니다.

이를 통해 Task 부모-자식 관계를 가진 계층 구조로 구성할 수 있습니다. 이를 구조적 동시성(structed concurrency)이라고 합니다. 구조적 동시성을 이용하면 그룹 단위로 작업을 취소하거나, 자식 작업의 취소를 부모로 전파할 수 있습니다.

 

사용 예시

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

그룹 Task는 withTaskGroup(of:returning:body:)이라는 메서드를 이용해 생성할 수 있습니다. TaskGroup에 하위 작업을 추가하기 위해서는 addTask 메서드를 사용합니다.

 

그러나 해당 메서드는 CancellationError(작업취소에러)에 대응할 수 없습니다. 따라서 지금까지 완료된 작업을 반환하거나 빈 결과를 반환하거나 nil을 반환하는 등 다른 방식으로 취소를 핸들링해야합니다. 따라서 작업 취소 가능성이 있다면 withThrowingTaskGroup(of:returning:body:) 메서드를 사용해야합니다. 자세한 사항은 링크를 확인해주세요.

 

 

구조화되지 않은 동시성 (Unstructured Concurrency)


상위 Task(부모 Task)가 없는 작업을 구조화되지 않은 동시성이라고 합니다. 좀 더 자유롭게 사용이 가능하지만 여러 동시성 문제에 대해 더욱 신중히 고려해야합니다.

 

현재 액터에서 실행되는 구조화되지 않은 작업을 생성하려면 Task.init(priority:operation:) 을 사용할 수 있습니다.

현재 액터에서 실행되지 않는 작업을 생성하려면 Task.detached(priority:operation:) 를 사용할 수 있습니다.

 

생성된 Task는 await하거나 취소할 수 있습니다.

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

 

 

작업 취소 (Task Cancellation)


Swift는 협동 취소 모델 (cooperative cancellation model)을 사용하기 때문에 각 작업은 적절한 시점에 취소되었는지 확인하고 적절한 방법으로 취소에 응답할 수 있습니다.

 

취소에 대한 대응 방법은 다음과 같습니다.

✅ CancellationError 와 같은 에러 발생
✅  nil 또는 빈 콜렉션 반환
✅ 부분적으로 완료된 작업 반환

 

작업 취소 확인 방법은 다음과 같습니다.

✅ Task.checkCancellation() 를 호출: CancellationError 를 발생시킴
✅ Task.isCancelled의 값을 확인하고 자체 코드에서 취소 처리

 

작업 취소는 Task.cancle()을 통해 할 수 있습니다.

// 비동기 작업을 수행하는 함수
func performAsyncTask() async {
    print("Task started")
    try await Task.sleep(until: .now() + 3)
    print("Task completed")
}

// 비동기 작업 시작
let task = Task { await performAsyncTask() }

// 2초 후에 Task 취소
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    print("Cancelling task")
    task.cancel()
}

do {
    try await task.value
    print("Task completed successfully")
} catch {
    if let cancellationError = error as? Task.CancellationError {
        print("Task cancelled: \(cancellationError)")
    } else {
        print("Task failed with error: \(error)")
    }
}

 

 

액터 (Actors)


비동기 작업을 안전하게 사용하기 위해서는 Actor를 사용해야합니다. Actor는 클래스와 마찬가지로 참조 타입입니다. Class와 다른점은 액터는 한 번의 단 하나의 작업(하나의 스레드)만 접근해 내부 상태를 변경시킬수 있도록 합니다.

 

액터 사용법

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를 정의할 수 있습니다.

 

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

이때 let인 label은 immutable한 변수(let)이므로 제한 없이 접근할 수 있습니다. 하지만 max는 mutable한 변수(var)이므로 await을 사용해 접근해야합니다. 즉, max에 접근하는 지점은 중단 지점이 되고 언제 looger.max에 접근할 수 있을지는 보장되지 않습니다.

 

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

update라는 내부 함수는 액터 컨텍스트 내(같은 스레드)에서 실행되므로 max에 접근할 때 await 표시를 하지 않아도 됩니다. (update의 호출은 반드시 동기-순차적으로 실행되기 때문에) 그리고, update가 실행되는 동안 다른 접근들은 await 되므로 동시성 문제가 발생하지 않습니다.

 

만약 액터의 외부 컨텍스트에서 await을 붙이지 않고 mutable 변수에 접근하게 되면 컴파일 에러를 띄워줍니다. 이렇게 액터 내부 코드만 액터 로컬 상태에 접근할 수 있도록 보장하는 것을 액터 분리(actor isolation)이라고 합니다.

 

 

전송 가능 타입(Sendable Types)


Task와 Actor를 이용하면 안전하게 동시성을 구현할 수 있습니다. Task나 Actor의 내부 변수, 프로퍼티를 변경하는 가능성을 포함한 코드를 부분을 동시성 도메인(concurrency domain)이라고 합니다.

 

한 동시성 도메인에서 다른 동시성 도메인으로 공유될 수 있는 타입을 전송 가능 타입(sendable type)이라고 합니다. sendable하지 않은 타입은 데이터 레이스와 같은 예상치 못한 결과를 발생시킬 수 있으므로 주의합니다.

 

sendable type은 Sendable 프로토콜을 채택하여 구현됩니다. 그러나 이 프로토콜이 특별히 구현에 강제하는 부분은 없고 Swift의 의미론적인 요구사항을 충족시키기 위해 채택됩니다. 즉, Sendable 프로토콜을 준수하는 객체는 다음과 같은 특징을 만족합니다.

✅ 값 타입이고 변경 가능한 상태는 다른 전송 가능한 데이터로 구성된다. - 예를 들어, 전송 가능한 저장된 프로퍼티가 있는 구조체 또는 전송 가능한 연관된 값이 있는 열거형이 있습니다. (구조체, 열거형 내의 내부 프로퍼티 또한 sendable 프로토콜을 준수하는 타입이어야합니다.)
✅ 변경 가능한 상태가 없으며 변경 불가능한 상태는 다른 전송 가능한 데이터로 구성됩니다 - 예를 들어, 읽기전용 프로퍼티만 있는 구조체 또는 클래스가 있습니다.
✅  @MainActor 로 표시된 클래스나 특정 쓰레드나 큐에서 프로퍼티에 순차적으로 접근하는 클래스와 같이 변경 가능한 상태의 안정성을 보장하는 코드를 가지고 있습니다.

 

struct TemperatureReading {
    var measurement: Int
}

값타입인 struct같은 경우는 암시적으로 Sendable 프로토콜을 준수하므로 동시성 도메인간에 공유될 수 있습니다. 물론 Actor 또한 Sendable 프로토콜을 준수합니다.

 

 

 


이상 최대한 이해해보며 정리해보았습니다.

감사합니다.

 

ref. 공식문서

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함