티스토리 뷰

반응형

안녕하세요 Pingu입니다.🐧

 

오늘은 Swift 5.5에서 공식문서에 추가된 내용인 Concurrency를 읽고 정리한 글을 써보려고 합니다.

 

Swift 공식 문서 - Concurrency

Concurrency

Swift 5.5부터는 built in 방식으로 비동기 코드, 병렬 코드를 지원합니다. 비동기 코드는 일시 정지되었다가 나중에 다시 시작할 수 있지만 한 번에 프로그램의 한 부분만 실행하게 됩니다. 비동기로 코드를 작성하게 되면 UI 업데이트 같은 작업을 진행하면서 네트워크에서 데이터를 가지고 오거나 디스크에서 파일을 가지고 오는 등, 비교적 오래 걸리는 작업을 계속해서 실행할 수 있습니다. 즉 한 시점에 하나의 일만 하는 코드가 비동기 코드입니다.

 

병렬 코드의 경우에는 한 시점에 여러개의 코드들이 실행되는 것을 말합니다. 예를 들어 4 코어 프로세서가 있는 컴퓨터는 각 코어가 하나의 작업을 실행할 수 있으므로 4개의 코드를 동시에 실행할 수 있습니다. 병렬, 비동기 코드를 사용하는 프로그램은 한 번에 여러 작업을 수행합니다.

 

병렬, 비동기 코드로 인해 필요한 추가적인 스케줄링은 복잡성을 증가시킵니다. Swift를 사용하면 일부 컴파일 시간에 검사를 하는 방식으로 개발자의 의도를 표현 할 수 있는데요(이 부분이 Swift lets you express your intent in a way that enables soee compile-time checking"인데.. 번역이 어렵네요), 예를 들어 actors를 사용하면 변경 가능한 상태에 안전하게 접근이 가능합니다. 하지만 문제가 있는 코드에 동시성을 추가한다고 해서 빠르게 실행되거나 정확성을 보장하지는 않습니다. 실제로 동시성을 추가하면 코드를 디버깅하기는 더 어려워질 때도 있습니다. 하지만 동시성을 사용해야 할 때 Swift 언어 자체에서 이를 지원하게 되면 Swift가 컴파일 시간에서 이를 파악할 수 있게 됩니다.

 

이번 글의 나머지 부분에서는 동시성이라는 용어를 비동기, 병렬 코드라는 의미로 사용하겠습니다.

 

Note : Swift 5.5버전 이전에 동시성 코드를 작성한 경험이 있다면 스레드 작업에 익숙하실 텐데요, Swift의 동시성 모델은 스레드 위에 구축되지만 직접 상호 작용하지는 않습니다. Swift의 비동기 함수는 실행 중인 스레드를 포기할 수도 있습니다. 그러면 첫 번째 함수가 차단되는 동안 해당 스레드에서 다른 비동기 함수를 실행할 수 있게 됩니다.

 

Swift 언어 자체에서 제공하는 기능을 사용하지 않고도 동시성 코드를 작성하는 것은 가능하지만, 그렇게 하면 코드가 좀 더 복잡해집니다. 예를 들어 다음 코드는 사진 이름 목록을 다운로드하고 해당 목록의 첫 번째 사진을 다운로드하고, 첫 번째 사진을 사용자에게 보여주는 코드입니다.

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

위와 같은 간단한 코드에도 코드는 completion handler로 완료이후의 작업을 작성해야 하기 때문에 nested 클로저를 사용해야 합니다. 이러한 형태로 여러 개의 중첩이 있는 코드를 작성한다면 좀 더 복잡한 코드가 탄생하게 되겠죠?

Defining and Calling Asynchronous Functions

비동기 메서드는 실행 도중 일시정지 할 수 있는 특수한 종류의 메서드입니다. 이는 완료될 때까지 실행되거나, 오류가 발생하거나, 반환되지 않는 일반적인 메서드와는 다른 점이라고 할 수 있습니다. 비동기 메서드는 기존의 메서드와 동일하게 3가지 중 하나를 수행하지만 실행 도중 일시정지할 수 있다는 점이 다릅니다. 이를 위해 비동기 메서드의 내부에서 실행을 일시정지하는 위치를 표시합니다.

 

메서드가 비동기임을 나타내기 위해서는 throw를 사용하여 throw 메서드를 표시하는 방법과 유사하게 해당 선언에서 매개변수 뒤에 async 키워드를 작성하면 됩니다. 메서드가 값을 반환하는 경우, 반환 화살표 앞에 async를 작성합니다. 예를 들어 갤러리에 있는 사진의 이름을 가지고 오는 방법은 다음과 같습니다.

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

위와 같이 비동기 코드를 작성 할 땐 async를 작성해주면 됩니다.

 

비동기 메서드를 호출하면 해당 메서드가 반환될 때까지 실행이 일지 정지됩니다. 호출이 중단될 가능성이 있는 지점을 표시하기 위해 호출하는 곳 앞에 wait라고 작성합니다. 이는 오류가 있을 수 있다고 표시하는 throw 함수를 호출할 때 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. 위의 코드는 첫 번째 줄에서 실행을 시작하고 첫 번째 await까지 실행됩니다. 즉 listPhotos(inGallery:) 메서드를 호출하고 해당 메서드가 반환될 때까지 기다리는 동안 실행을 일시 정지합니다.
  2. 위의 코드의 실행이 일시 정지되는 동안 동일한 프로그램의 다른 동시성 코드가 실행됩니다. 예를 들어 오랜 시간이 걸리는 백그라운드 작업인 새로운 사진 갤러리 목록을 계속해서 업데이트할 수도 있습니다. 이러한 코드는 await로 표시된 다음 일시정지 지점까지 혹은 실행 완료될 때까지 실행됩니다.
  3. listPhotos(inGallery:)가 반환되면 위 코드는 해당 지점에서 시작하여 계속해서 실행됩니다. 즉 반환된 값을 photoNames에 할당하게 됩니다.
  4. sortedNames 및 name을 정의하는 줄은 일반적인 동기 코드이므로 가능한 정지 지점은 없습니다.
  5. 다음 await는 downloadPhtoo(named:) 메서드에 대한 호출을 표시합니다. 이 코드는 해당 메서드가 반환될 때까지 실행을 다시 일시 정지하여 다른 동시성 코드가 실행되도록 합니다.
  6. downloadPhoto(named:)가 반환된 값이 photos에 할당되고 slow(_:)를 호출할 때 인수로 전달됩니다.

await로 표시된 코드의 가능한 일시정지 지점은 비동기 메서드가 반환되기를 기다리는 동안 현재 코드 부분이 실행을 일시 중지할 수 있음을 나타냅니다. 이를 스레드 양보(Yielding)라고 부르기도 합니다. 그 이유는 뒤에서 Swift가 현재 스레드에서 코드 실행을 중단하고 다른 코드를 실행하기 때문입니다. await가 있는 코드는 실행을 일시정지할 수 있어야 하므로 프로그램의 특정 위치에서만 비동기 메서드를 호출할 수 있습니다.

 

  • 비동기 메서드 또는 프로퍼티에 있는 코드
  • @main으로 표시된 구조체, 클래스, 열거형의 static main() 메서드에 있는 코드
  • 이번 글의 이후에 나올 Unstructed Concurrency에서 보게 될 하위 작업의 코드

Note : Task.sleep(_:) 메서드는 동시성 작동방식을 배우기 위해 간단한 코드를 작성할 때 유용합니다. 이 메서드는 아무 작업도 수행하지 않고 그냥 시간을 기다리는 메서드입니다. 네트워크 작업을 시뮬레이션하는 등의 실험을 해보고 싶을 때 이 메서드를 사용하면 됩니다.

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를 사용하여 비동기 메서드를 호출하면 한 번의 하나의 코드만 실행됩니다. 비동기 코드가 실행되는 동안 호출자는 다음 코드 줄을 실행하려고 이동하기 전에 해당 코드가 완료되는 것을 기다리게 됩니다. 예를 들어 갤러리에서 처음 세 장의 사진을 가지고 오려면 다음과 같이 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:)는 한 시점에 하나만 실행된다는 것이죠. 하지만 개발자들은 3개의 사진이 동시에 다운되기를 바랍니다.

 

비동기 메서드를 호출하고 주변의 코드와 병렬로 실행하기 위해서는 상수를 선언하기 전 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:)를 3번 호출하게 되는데, 모든 호출이 다른 호출의 완료를 기다리지 않고 실행되게 됩니다. 즉 동시에 3개의 메서드가 실행될 수 있습니다. 코드가 함수의 결과를 기다리고 일시정지할 필요가 없기 때문에 await를 표시하지 않아도 됩니다. 따라서 photos가 정의된 라인까지 실행이 되는데요, photos를 선언하는 시점에서는 사진들이 모두 다운이 되어 있어야 하므로 await를 작성하여 이를 기다려줘야 합니다.

 

다음은 이번 섹션에서 본 두 코드의 차이점에 대해 생각할 수 있는 방법입니다.

  • 다음 줄의 코드가 해당 함수의 결과에 따라 달라지면 await를 사용하여 비동기 함수를 호출합니다. 이는 순차적으로 수행되는 작업을 생성합니다.
  • 코드의 결과가 필요하지 않은 경우엔 async let을 사용하여 비동기 메서드를 호출합니다. 이렇게 하면 병렬로 수행할 수 있는 작업이 생성됩니다.
  • await와 async-let은 모두 일시 중단된 동안 다른 코드를 실행할 수 있습니다.
  • 두 경우 모두 비동기 메서드가 반환될 때까지 필요한 경우 일시정지를 해야 하므로 해당 지점을 await로 표시합니다.

동일한 코드에서 두 가지 방법을 모두 사용할 수도 있습니다.

Task and Task Groups

Task(작업)는 프로그램의 일부로 비동기적으로 실행할 수 있는 작업 단위입니다. 모든 비동기 코드는 task의 일부로 실행되는데요, 이전 섹션에서 알아본 async-let 구문은 자식 작업을 생성합니다. 또한 작업 그룹을 만들고 해당 그룹에 하위 작업을 추가할 수 있습니다. 그러면 우선순위와 취소를 잘 제어할 수 있고 동적인 수의 작업을 만들 수 있습니다.

 

작업은 계층 구조로 정렬됩니다. 작업 그룹의 각 작업에는 동일한 상위 작업이 있으며, 각 작업에는 하위 작업이 있을 수도 있죠. 작업과 작업 그룹 간의 명시적 관계 때문에 이러한 접근 방식을 구조적 동시성이라고 합니다. 정확성에 대한 책임 중 일부는 사용자가 담당하지만, 작업 간의 명시적 부모, 자식 관계를 통해 Swift는 취소 전파와 같은 일부 동작을 처리할 수 있고 Swift는 컴파일 시간에 일부 오류를 감지할 수 있습니다.

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

작업 그룸에 대한 더 많은 정보는 여기에서 알아보세요!

Unstructured Concurrency

이전 섹션에서 설명한 동시성에 대한 구조화된 접근 말고도 Swift는 구조화되지 않은 동시성도 지원합니다. 작업 그룹의 일부인 작업과 달리 구조화되지 않은 작업에는 상위 작업이 없습니다. 프로그램이 필요로 하는 방식으로 비정형 작업을 관리할 수 있는 완전한 유연함이 있지만, 정확성에 대한 책임은 개발자에게 있습니다. 현재 actor에서 실행되는 비정형 작업을 생성하려면, Task.init(priority:operation:) 생성자를 사용하면 됩니다. 현재 actor의 일부가 아닌 비정형 작업을 만들려면 Task.detached(priority:operation) 메서드를 호출합니다. 이러한 작업은 모두 작업과 상호 작용할 수 있는 task handle을 반환합니다. 예를 들어 이러한 작업의 결과를 기다리거나 취소할 수 있습니다.

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

Detach Task를 관리하는 더 많은 정보는 여기에서 알아보세요!

Task Cancellation

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

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

Task.isCancelled의 값을 확인하고 자체 코드에서 취소를 처리합니다. 예를 들어 갤러리에서 사진을 다운로드하는 작업은 부분 다운로드를 삭제하고 네트워크 연결을 닫아야 할 수 있습니다.

 

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

Actors

그럼 이번엔 이번 글 여기저기서 나오던 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
    }
}

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는 한 번에 하나의 작업만 변경 가능한 상태에 접근 할 수 있도록 허용하기 때문에 다른 작업 코드가 이미 logger에 접근 중인 경우에는 기다리게 됩니다. 마치 lock, semaphore를 사용하여 상호 배제를 적용해주는 것과 동일하게 말이죠.

 

대조적으로 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가 변경 가능한 상태와 상호작용하기 위해 한 번에 하나의 작업만 허용하는 이유 중 하나를 보여줍니다. Actor의 상태에 대한 일부 업데이트는 일시적으로 불변성을 깨트리는데요, TemperatureLogger Actor는 온도 목록과 최대 온도를 추적하고 새로운 측정값을 기록할 때 최대 온도를 업데이트합니다. 업데이트 도중에 새로운 측정이 추가되면 이는 logger의  데이터가 일치하지 않게 되는 문제가 발생합니다. 따라서 여러 작업이 동일한 인스턴스와 상호작용하는 것을 방지하여 이를 방지해야합니다. 아까의 문제를 순차적으로 보면 아래와 같습니다.

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

위와 같은 경우 다른 곳에서 실행 중인 코드는 부정확한 정보에 접근하게 됩니다. Swift Actor를 사용하면 이러한 문제를 방지할 수 있습니다. Actor는 한 시점에 하나의 작업만 허용하고 해당 코드는 await로 정지 가능한 지점을 표시하기 때문입니다. update(with:)는 중단 지점을 포함하지 않기 때문에 다른 코드는 업데이트 도중 데이터에 접근할 수 없습니다.

 

클래스의 인스턴스에서와 같이 Actor 외부에서 이러한 프로퍼티에 접근하려고 하면 아래와 같이 컴파일 오류가 발생합니다.

print(logger.max)  // Error

logger.max에 await 없이 접근하면 오류가 발생하는 이유는 actor의 프로퍼티는 actor의 독립 상태의 일부이기 때문입니다. Swift는 actor 내분의 코드만 actor의 로컬 상태에 접근 할 수 있도록 보장합니다. 이러한 보장을 actor isolation이라고 합니다.

 

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
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
글 보관함