ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Concurrency
    iOS/Swift 공식문서 2022. 2. 23. 13:23

    Concurrency

    Swift는 비동기/병렬 코드를 built-in 방식으로 지원한다. 비동기 코드는 일시정지했다가 나중에 실행될 수 있지만 한 번의 프로그램의 한 부분만 실행된다. 이러한 방식으로 데이터를 네트워크에서 가져오거나 파일을 parsing하는 등의 오래 걸리는 작업을 계속하는 동안 UI 업데이트와 같은 짧은 시간동안의 작업을 계속 진행할 수 있다.


    병렬 코드는 여러 조각의 코드가 동시에 실행되는 것을 말한다. 예를 들어, 4코어 프로세서는 동시에 4개의 코드를 실행할 수 있다. 병렬 코드와 비동기 코드를 사용하는 프로그램은 한 번에 여러 작업을 수행할 수 있다. 이러한 프로그램은 외부 시스템을 기다려야 하는 작업을 일시 정지하고, memory-safe한 방식으로 코드를 작성할 수 있게 한다.


    비동기/병렬 코드로 인한 추가적인 스케줄링은 복잡성을 증가시킨다. Swift는 일부 compile-time checking이 가능한 방식으로 개발자의 의도를 표현할 수 있게 한다. 예를 들어, mutable한 상태에 안전하게 접근할 수 있게 하기 위해 actor를 사용할 수 있다.


    그러나, 느리고 버그가 있는 코드에 동시성을 추가한다고 해서 코드가 빠르고 정확히 동작할 것이라는 보장은 없다. 오히려 문제가 더 심각해질 수 있다. 그러나, 동시성에 대한 Swift의 언어 수준의 지원은 Swift가 compile time에 문제를 파악할 수 있게 한다.


    앞으로는 "동시성"이라는 단어를 비동기/병렬 코드의 common combination을 지징하는 의미로 사용할 것이다.


    note

    Swift의 동시성 모델은 스레드를 기반으로 만들어졌지만, 개발자는 스레드와 직접적으로 상호작용하지 않는다. Swift의 비동기 함수는 실행 중인 스레드를 포기할 수 있다. 그러면 첫번째 함수가 block되는 동안 해당 스레드의 다른 비동기 함수가 실행되게 된다.


    Swift의 언어적 지원 없이 동시성 코드를 작성할 수도 있지만, 그 경우 가독성이 떨어지는 경향이 있다. 다음 코드는 사진 이름들의 리스트를 다운로드하고, 리스트의 첫번째 사진을 다운로드하고, 해당 사진을 사용자에게 보여주는 코드이다.

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

    이런 단순한 케이스조차도, 코드가 일련의 completion handler로 작성되어야 하기 때문에 여러 중첩 클로젿들을 작성하게 된다.


    Defining and Calling Asynchronous Functions

    비동기 함수/메서드는 실행 중에도 일시정지될 수 있는 함수/메서드이다. 이는 끝까지 실행되거나, 에러를 던지거나, 반환되지 않는 일반적인 동기 함수/메서드와 반대이다. 비동기 함수는 이 3가지 중 하나를 할 수 있지만, 도중에 일시정지할 수도 있다는 점이 다르다. 비동기 함수/메서드 내부에서 개발자는 일시정지될 수 있는 지점을 표시한다.


    비동기라는 것을 나타내기 위해, 매개변수들 뒤에 async 키워드를 작성해야 한다.

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

    에러 던지기도 해야 하는 경우 throw 앞에 async를 적는다.


    비동기 메서드를 호출하면, 해당 메서드가 반환될 때까지 실행이 중단된다. 그리고 중간에 일시정지할 수 있는 지점을 표시하기 위해 비동기 함수를 호출할 때 앞에 await로 표시해준다. 비동기 메서드 안에서, 실행 흐름은 오직 다른 비동기 메서드가 호출되었을 때에만 일시정지된다. 일시정지는 절대 암시적이거나 선점적(preemptive)이지 않다. 이는 모든 일시정지 포인트가 await로 표시된다는 것을 의미한다.


    다음 코드의 실행 과정을 살펴보자.

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

    a. 코드가 첫번째 줄부터 실행되면서 첫번째 await까지 실행된다. listPhotos(inGallery:) 함수를 호출하고, 해당 함수가 반환될 때까지 실행이 중지된다.


    b. 위의 코드의 실행이 일시정지된 동안, 같은 프로그램의 다른 동시성 코드가 실행된다. 예를 들어, 새로운 사진 갤러리를 업데이트 하는 등 오래 걸리는 백그라운드 작업을 수행할 수 있다.


    c. listPhotos(inGallery:)가 반환된 이후, 실행을 계속한다. photoNames에 반환값을 할당한다. 2, 3번째 줄은 await가 없으므로 일시정지가 가능한 포인트가 없고, 동기적 코드이다.


    d. 다음 awaitdownloadPhtoo(named:) 함수에 대한 호출을 표시한다. 위의 코드는 해당 함수가 반환될 때까지 실행을 멈춘다. 그리고 다른 동시성 코드가 실행될 기회를 얻는다.


    e. downloadPhtoo(named:) 함수가 반환된 후, 반환 값이 photo에 할당되고, show(_:)의 매개변수로 들어간다.


    실행중인 코드가 await에 도달하면 실행을 멈추고 비동기 함수/메서드가 반환되기를 기다리는 것을 스레드 양보(yielding the thread)라고 한다. 내부적으로는, 현재 스레드에서 해당 코드를 실행하는 것을 일시중지하고, 다른 코드를 실행하는 것이다. await가 있는 코드는 실행을 일시정지할 수 있어야 하기 때문에, 프로그램에서 특정 지점만 비동기 함수/메서드를 호출할 수 있는 것이다.


    note

    Task.sleep(nanoseconds:) 메서드는 동시성 동작 방식을 배우기 위해 간단한 코드를 작성할 때 유용하다. 주어진 시간동안 아무 동작도 하지 않고 반환된다.

    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:) 함수는 모든 element가 준비된 후, 배열 전체를 비동기적으로 한 번에 반환한다. 다른 접근법이 있는데, asynchronous sequence를 사용하여 각 element가 하나씩 준비되기를 기다리는 것이다. for-in문에서 for 뒤에 await를 쓰면, 함수/메서드를 호출할 때처럼 일시정지 포인트를 나타낼 수 있다. for-await-in문은 각 iteration을 시작할 때마다 실행을 일시정지하고, 다음 element가 준비될 때까지 기다린다.


    for-in 문을 Sequence 프로토콜을 따르는 타입에 사용하는 것처럼, for-await-in 문은 AsyncSequence 프로토콜을 따르는 타입에 사용할 수 있다.


    Calling Asynchronous Functions in Parallel

    비동기 함수를 await로 호출하면 한 시점에 하나의 코드 조각만 실행된다. 비동기 코드가 실행되는 동안, 비동기 함수를 호출한 코드는 다음 줄을 실행하기 전에 해당 코드가 끝나기를 기다린다. 비동기 함수를 3번 호출하는 다음 코드를 살펴보자.

    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:)의 각 호출은 한 시점에 하나만 실행된다는 것이다. 각 사진이 완전히 다운로드되고 나서야 다른 사진을 다운로드할 수 있는 것이다.


    비동기 함수를 호출하고 주변 코드와 병렬적으로 실행하기 위해서는, 상수 선언 앞에 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)

    두 접근접의 차이점은 다음과 같다.


    a. await는 다음 줄의 코드가 함수 호출 결과에 따라 달라지는 경우 사용하자. 이는 작업이 순차적으로 진행되게 한다.
    b. 함수 호출 결과가 다음 코드에서 필요하지 않은 경우 async-let을 사용하자. 이는 작업이 병렬적으로 진행되게 한다.
    c. awaitasync 모두 일시 중단되는 동안 다른 코드가 실행될 수 있게 한다.
    d. 두 경우 모두 비동기 메서드가 반환될 때까지 일시정지를 해야 할 경우 해당 지점을 await로 표시한다.


    Tasks and Task Groups

    task는 프로그램의 일부로서 비동기적으로 실행될 수 있는 작업의 단위이다. 모든 비동기 코드는 어떤 task의 일부로서 실행된다. 위에서 다룬 async-let 문법도 child task를 생성해준다. task group을 새로 만들어서 그 안에 child task들을 추가하는 것도 가능하다. 이러한 방식을 통해 우선순위와 취소에 대한 컨트롤을 할 수 있고, 동적인 개수의 task들을 생성할 수 있다.


    task들은 계층 구조로 정렬된다. task group의 모든 task는 같은 부모 task를 가지게 되며, 각 task는 자식 task를 가질 수 있다. 이러한 task와 task group의 명시적 관계 때문에, 이러한 접근법을 구조화된 동시성(structured concurrency)이라고 한다. 이러한 task들 간의 부모-자식 관계는 Swift가 취소 전파와 같은 동작들을 처리할 수 있게 한다. 그리고 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) }
        }
    }

    Unstructured Concurrency

    Swift는 구조화되지 않은 동시성도 지원한다. task gruop의 일부인 task들과는 달리, unstructured task는 부모 task가 없다. 프로그램에게 필요한 방식으로 unstructured task들을 완전히 유연하게 관리할 수 있지만, 정확성에 대한 책임은 개발자에게 있다. 현재 실행중인 actor에서 unstructured task를 생성하려면 Task.init(priority:operation:)을 호출해야 한다. 현재 actor에 속하지 않은 unstructured task를 생성하려면 Task.detached(priority:operation:) 메서드를 호출해야 한다. 두 메서드 모두 해당 task와 상호작용할 수 있게 하는 task handle을 반환한다.

    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)을 사용한다. 각 task는 실행의 적절한 지점에서 취소되었는지를 확인하고, 적절한 방식으로 취소에 응답한다. 수행 중인 작업에 따라, 다음 중 하나를 의미한다.


    a. CancellationError와 같은 에러를 던지는 것
    b. nil이나 빈 컬렉션을 반환하는 것
    c. 일부만 완료된 작업을 반환하는 것


    취소 여부를 확인하기 위해 Task.checkCancellation()을 호출하거나, Task.isCancelled의 값을 확인할 수 있다. 전자의 경우 task가 취소된 경우 CancellationError를 던진다. 취소를 manual하게 전파하기 위해서는 Task.cancel()을 사용하자.


    Actors

    actor는 클래스처럼 reference type이지만, 클래스와는 달리 한 번에 하나의 task만 actor의 mutable state에 접근할 수 있게 한다. 이 덕분에 여러 task와 안전하게 상호작용할 수 있다.


    다음 예시를 살펴보자. 온도를 기록하는 TemperatureLogger를 정의한다.

    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의 프로퍼티나 메서드에 접근하는 경우, 잠재적인 일시정지 지점을 표시하기 위해 await를 사용한다. 아래 코드에서, logger.max가 일시정지 지점이다. 하나의 task만 actor의 mutable state에 접근할 수 있기 때문에, 다른 task의 코드가 이미 actor와 상호작용하고 있으면, 코드는 프로퍼티에 접근하기 위해 대기한다.

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

    반면, actor에서의 코드는 프로퍼티에 접근할 때 await를 사용하지 않는다. 예를 들어, 다음 코드와 같이 TemperatureLogger를 새로운 온도로 업데이트하는 메서드가 있다고 하자.

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

    update(with:) 메서드는 이미 actor 안에 있기 때문에 max와 같은 프로퍼티에 접근할 때 await로 표시하지 않는다. 이 메서드에서 한 번에 하나의 task만 mutable state에 들어갈 수 있는 이유를 알 수 있다. actor의 상태에 대한 일부 업데이트의 경우 일시적으로 불변성을 깨뜨린다. update 메서드에서 measurement에 append를 한 이후이며 max를 업데이트하기 이전인 시점의 경우, 해당 actor는 일시적으로 일관성이 없는 상태이다. 업데이트 도중에 새로운 측정이 추가되면 logger 데이터가 일치하지 않게 되는 문제가 발생한다.


    이를 방지하기 위해 Swift의 actor를 사용할 수 있다. actor는 한 시점에 하나의 task만 허용하고, 해당 코드는 await가 일시정지 지점을 표시한 곳에서만 인터럽트될 수 있기 때문이다. update(with:)에서는 일시정지 지점이 없기 때문에, 업데이트 도중에 다른 코드가 접근할 수 없다.


    await 없이 접근하려고 하면 다음과 같이 컴파일 에러가 발생한다. actor의 프로퍼티는 actor의 독립 상태의 일부이기 때문이다. Swift는 actor 내부의 코드만 actor의 로컬 상태에 접근 할 수 있도록 보장한다. 이러한 보장을 actor isolation이라고 한다.

    print(logger.max)  // Error

    Reference

    https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html

    'iOS > Swift 공식문서' 카테고리의 다른 글

    Nested Types  (0) 2022.02.23
    Type Casting  (0) 2022.02.23
    Error Handling  (0) 2022.02.22
    Optional Chaining  (0) 2022.02.22
    Deinitialization  (0) 2022.02.21

    댓글

Designed by Tistory.