Swift Programming Language를 해석하여 Swift Concurrency에 대해서 알아보자.
자세한 내용은 위 링크를 참고하시면 좋겠다.
Swift Concurrency
우리는 UI업데이트나 무거운 작업을 하기 위해서 비동기 / 병렬 실행 코드를 작성하곤 한다.
하지만.. 이런 병렬 또는 비동기 코드는 많아지면 질수록 관리에 많은 어려움을 겪는다.
또한, 느리거나 버그가 많은 코드에 동시성을 추가한다고 해서 그것이 빨라지거나 정확해진다는 보장은 없을 뿐더러 최악의 경우엔 디버깅만 어려워 질 수 있다.
이를 어떻게 Swift에서는 해결하려 했는지 확인해 보자.
Without Swift Concurrency
Swift Concurrency 언어 지원을 사용하지 않고 비동기 코드를 작성해보자.
1. 사진 이름목록을 가지고옴.
2. 이름으로 정렬함
3. 정렬한 사진중 첫번째를 가지고옴
4. 첫번째 사진을 다운로드 받아서 보여줌
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in
show(photo)
}
}
간단한 예제임에도 많은 꽤나 많은 클로저들이 중첩이 되어 있음을 알 수 있는데..
그 이유는 비동기 함수의 실행 시점을 맞추기 위해서 많은 Completion Handler가 사용되고, 중첩됬기 때문이다.
결국, 많은 클로저 -> 콜백지옥 -> 유지/보수 힘듦이라는 순환 고리를 만들게 된다.
With Swift Concurrency(async , await)
Swift Concurrency를 이용하여 위와 같은 동작을 하는 비동기 코드를 작성해보자.
먼저 근본적인 문제였던 completion Handler가 있던 함수를 밑과 같이 바꾼다.
aysnc 키워드가 붙었다는 것은 비동기 함수라는 것을 명시해주는 것이라 생각하면 편하다.
func listPhotos(inGallery name: String, completionHandler: @escaping( ([String]) -> Void) ) {
}
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
함수의 실행은 밑과 같이 바뀔 것이다.
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in
show(photo)
}
}
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 키워드를 사용함으로 써 possible suspension point를 설정 할 수 있다.
그 덕분에 클로저의 중첩들이 사라지고 마치 동기 코드인것처럼 코드를 작성 할 수 있게 됐다.
Flow
위 코드의 Flow를 조금더 자세히 살펴보자.
1. 코드는 첫줄부터 시작하여 첫번째 await를 만날때 까지(listPhotos 전까지) 실행된다.
2. 해당 함수가 return 될때 까지 실행을 일시 중단한다.
3. 일시 중단 되는 동안 다른 concurrent code가 실행이 된다. 예를 들어 background에서 갤러리 목록을 계속 업데이트 할 수 도 있다.
4. listPhotos가 반환된 후 해당 시점부터 실행을 이어간다.
5. sortedNames코드와 Name코드의 실행은 비동기 코드가 아니기 때문에 suspension point가 없다. 따라서 계속 진행한다.
6. 다시 await가 붙은 downloadPhoto함수를 만났다.(일시정지)
7. show(photo)
위와 같이, await앞에서 실행이 끝나기를 기다리는 것을 스레드를 양보(yielding)?하는 것이라고도 한다.
왜냐하면, 실행 중이던 스레드를 차단하고 다른 스레드를 할당하는게 아니라, 현재 스레드가 다른 코드를 실행하기 때문이다.
// Thread A
let photoNames = await listPhotos(inGallery: "Summer Vacation") // Thread A?
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name) // Thread A?
show(photo)
예를들어서, 위코드가 A라는 Thread에서 실행된다고 해보자.
await를 만난 순간 A스레드에 대한 제어권은 시스템에게 넘어간다.
그리고 await 코드가 리턴이 되고 어느순간 진행이 될 수 있을때, 이때 진행되는 스레드는 기존에 진행했든 Thread A라는 보장이 전혀 없다.
마치 내가 가지고 놀던 장난감을 내가 잠시 자리를 비운 사이에 가만히 나두는게 아니라, 선생님(시스템)이 다른 친구들이 놀수 있게 주는 것이다. 그리고, 내가 자리에 복귀했을때 선생님(시스템)은 장난감중에 하나를 나에게 다시 쥐어준다.
이것은 이전 방식(GCD)과 큰 차이점을 나타내게 된다.
GCD vs Async, Await
위 처럼 해당 스레드를 차단하는게 아니라, 스레드를 양보하게 된다면 뭐가 좋을까?
이전의 GCD방식은 작업이 큐에 들어오게 되면 CPU가 포화가 될때까지 스레드를 불러와서 병렬 작업을 진행하게 된다.
스레드가 일이 생길때 마다 계속 생긴다?
이는Thread Explosion을 야기하게 된다.
그리고, Thread Explosion은 과도한 Context Switch, memory overhead와 같은 많은 문제를 발생시킨다.
하지만, Swift Concurrency에서는 스레드를 계속해서 만드는게 아니라 최대로 실행되는 스레드의 갯수가 코어의 갯수와 동일하다.
Asynchronous Sequences
현재, listPhotos(inGallery:)함수는 모든 요소들이 준비가 되면 모든 배열을 한번에 리턴한다.
위 방식 대신에 배열안의 각 요소 하나하나를 await 할 수도 있다.
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
Calling Asynchronous Functions in Parallel
await를 사용하게 된다면 한번에 하나의 코드만 실행이 되게 된다.
이는 다수의 await 코드가 있을때 비효율성을 만들게 된다.
왜냐하면, 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)
예를 들어 위와같은 코드가 있다고 해보자.
SecondPhoto는 FirstPhoto가 리턴이 되기 전까지는 실행되지 않는다.
실행순서는 상관이 없음에도 불구하고, SecondPhoto는 FirstPhoto를, thirdPhoto는 SecondPhoto가 리턴되기를 기다렸다가 실행이 되는것이다.
이는 동시에 실행할 수 있는 코드를 순차적으로 실행하기 때문에 비효율적이다.
이를 해결하기 위해 async let을 이용하자.
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를 넣어주자.
Tasks and Task Groups
Task는 우리 프로그램에서 비동기적으로 실행될 수 있는 작업 단위이다.
모든 비동기 코드는 어떠한 task의 일부분으로써 실행이 된다.
위에서 봤던 async let 을 사용하면 child Task가 생성이 된다.
Task Group은 child task를 좀더 쉽게 컨트롤 할 수 있게 한다.
Task Group을 생성하고 child task를 추가하면 우선순위 및 취소작업을 효과적으로 제어하고 동적인 수의 task를 생성 할 수 있다.
Task들은 계층구조로 정렬된다.
Task Group내의 각 Child Task들은 동일한 Parent Task를 가지며, 각 Task들은 child Task를 가질 수 있다.
Task와 Task Group간의 명확한 관계 때문에 이 접근을 structured concurrency라고 한다.
Task간의 명확한 부모-자식 관계로 인해 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) }
}
}
More about Task Group
Unstructured Concurrency
Structured Concurrency뿐만 아니라 Unstructured Concurrency도 있다.
이 경우에 Task Group과 달리 상위 Task가 없다.
아주 큰 유연성을 가지고 있지만, 그에 따른 책임도 본인에게 있다.
현재 actor에서 실행되는 Unstructured Concurrency Task를 만들려면 Task.init(priority:operation:) 이니셜 라이저를 사용해야한다.
현재 actor가 아닌 부분에서 Unstructured Concurrency Task를 만들려면 Task.detached(priority:operation:) 클래스 메서드를 이용해야 한다.
Actor? 는 밑에서 더 살펴 보자.
let newPhoto = // ... some photo data ...
let handle = Task {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
Task Cancellation
Swift Concurrency는 cooperative cancellation model을 사용한다.
각 Task는 실행시 적절한 시점에서 취소되었는지 확인하고, 적절한 방법으로 취소에 응답한다.
일반적으로 다음과 같다.
- Throwing an error like CancellationError
- Returning nil or an empty collection
- Returning the partially completed work
Cancellation을 확인하기위해, Task.checkCancelation()을 호출
만약 작업이 취소되었다면..
CancelationError를 발생시키거나 Task.isCancelled 값을 확인하고 본인의 코드로 취소처리를 할 수 있다.
만약 수동으로 취소를 하고 싶다면 Task.cancel() 을 호출할 수 있다.
Actors
Task를 이용하면 동시에 많은 일을 안전하게 수행할 수 있지만, 때로는 Task간에 일부 정보를 공유해야 할 수 도 있다.
그때 필요한것이 Actor이다.
Class와 마찬가지로 참조유형이다.
class와 달리 엑터는 mutable State에 한번에 하나의 작업만 접근 할 수 있다.
-> Race Condition과 같은 문제점을 방지할 수 있다.
-> 안전하게 인스턴스를 관리 할 수 있다.
예를 들어 온도를 기록하는 코드가 있다.
마치 class를 선언하듯이 선언을 하면된다.
밑 코드는.
외부의 다른 코드가 액세스 할 수 있는 property가 있고, Actor내부의 코드만 max값을 업데이트 할 수 있도록 private(set)으로 둔다.
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
}
}
그리고 내부의 속성에 접근을 할때, await키워드를 사용하여 poential suspension point를 설정한다.
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
위 코드를 통해 logger.max에 접근 할 때에는 한번에 하나의 작업만 허용하기 때문에, 다른 작업의 코드가 상호작용 하고 있을 경우
위 코드는 접근 할 수 있는 상태가 될때까지 기다린다.
반대로, Actor 내부의 코드들은 await키워드를 사용하지 않고 프로퍼티에 접근 할 수 있다.
update(with measurement: Int)메서드는 이미 actor에서 실행되고 있기 때문에 await키워드를 사용하지 않아도 되는 모습이다.
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
이런 시나리오를 생각 해 볼 수 있겠다.
- Your code calls the update(with:) method. It updates the measurements array first.
- Before your code can update max, code elsewhere reads the maximum value and the array of temperatures.
- Your code finishes its update by changing max.
이 경우 2번째 부분에서 오류가 발생할 수 있다.
update가 완료되지 않은 시점에서 데이터를 읽으려고 했기 때문이다.
하지만, Actor를 사용하면 한번에 하나의 작업만 Mutable State에 접근 할 수 있기에 위와 같은 상황을 방지 할 수 있다.
따라서, 외부에서 속성에대해 접근할때 await는 필수로 작성해주어야 한다.
print(logger.max) // Error
Sendable Types
Task 혹은 Actor의 인스턴스에서 mutable한 부분들(property, variables)등등을 Concurrency Domain이라고 한다.
몇몇의 데이터들은 Concurrency Domain사이에서 공유 할 수 없다.
왜냐하면 중복 접근으로 부터 보호되지 않기 때문이다.
어떤 Concurrecny Domain에서 다른 Domain으로 공유할 수 있는 type을 Sendable Type이라고 한다.
이 Type들은 Actor method로 호출할 때 인수로 전달되거나 결과로서 반환 될 수 있다.
위에서 봤던 예제들은 항상 안전하게 공유할 수 있는 단순한 값 유형을 사용하기 때문에 Sendable Type에 대해 걱정할 일이 없었다.
하지만, 반대로 생각하면 다른 몇몇 유형들은 안전하지않고 안전 문제를 염두에 두어야 한다는 것.
예를들어 어떤 Class가 mutable한 값을 가지고 있지만 이 값으로 접근 하는 것에 대해, 직렬화를 시켜놓지 않는다면 항상 해당 클래스의 인스턴스를 전달할 때 예측불가한 가능성을 내포하고 있다.
Sendable 프로토콜을 채택함으로써 이를 해결할 수 있다.
프로토콜에서 코드 요구사항은 따로 없지만, Swift에서 요구하는 semantic requirenments? 가 있다.
일반적으로 Sendable한 값이 되기 위한 세가지 방법이 있다.
- The type is a value type, and its mutable state is made up of other sendable data—for example, a structure with stored properties that are sendable or an enumeration with associated values that are sendable.
- The type doesn’t have any mutable state, and its immutable state is made up of other sendable data—for example, a structure or class that has only read-only properties.
- The type has code that ensures the safety of its mutable state, like a class that’s marked @MainActor or a class that serializes access to its properties on a particular thread or queue.
1. Value type이며, mutable State가 모두 Sendable type일때
2. Mutable State가 없는 Type(읽기 전용)
3. mutable State에 대해 안전을 보장하는 타입
Sendable Type이나 Enum만 있는 Struct는 항상 Sendable 하다
struct TemperatureReading: Sendable {
var measurement: Int
}
extension TemperatureLogger {
func addReading(from reading: TemperatureReading) {
measurements.append(reading.measurement)
}
}
let logger = TemperatureLogger(label: "Tea kettle", measurement: 85)
let reading = TemperatureReading(measurement: 45)
await logger.addReading(from: reading)
more about Sendable
소소한 Tip.
기존의 함수를 우클릭 Refactor -> convert Function to Async를 클릭하면 Swift Concurrency 코드로 자동 변환해줌!
참고.
https://eunjin3786.tistory.com/459
https://sujinnaljin.medium.com/swift-actor-%EB%BF%8C%EC%8B%9C%EA%B8%B0-249aee2b732d
https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html
'Swift' 카테고리의 다른 글
Task Group (0) | 2023.01.14 |
---|---|
클래스의 초기화 (0) | 2022.08.07 |
DiffableDataSource 알아보기. (7) | 2022.07.25 |
정규 표현식 in Swift (2) | 2022.06.14 |
Type Erasure in Swift (0) | 2022.05.23 |
댓글