Concurrency
스위프트는 구조화된 방식으로 비동기 및 병렬 코드를 작성하는데 built-in 지원을 제공한다.
한번에 하나의 프로그램만 실행시킬 수 있다해도 비동기 코드는 일시중지하고 재개될 수 있다.
프로그램에서 코드를 일시중지하고 재개시킨다는 것은 네트워크를 통해 데이터를 가져오거나 파일을 파싱하는것과 같은 긴 작업을 진행하면서 UI를 업데이트하는 짧은 작업을 계속 진행 할 수 있다는 것을 의미한다.
Parallel code(병렬코드) 는 여러 코드가 동시에 안전한 방법으로 실행되는것을 의미한다. 예를들어, 4코어를 가진 컴뷰터는 동시에 4개의 코드 조각을 실행시킬 수 있으며, 각 코어는 작업들중 하나를 수행해 나간다. 병렬 그리고 비동기 코드를 사용하는 프로그램은 동시에 여러가지의 연산을 수행할 수 있다. 해당 프로그램은 외부시스템을 기다리는 작업을 일시중지하고, 비동기 코드를 메모리에 더 쉽게 작성할 수 있다.
병렬 또는 비동기 코드의 추가 스케줄링 유연성 또한 증가된 복잡성에 따른 비용과 함께 제공된다. 스위프트를 사용하면 컴파일 시간 확인을 가능하게 하는 방식으로 의도를 표현할 수 있다.
예를들어 mutable 상태에 안전하게 접근할 수 있도록 actors를 사용할 수 있다. 그러나 느리거나 버그가 발생할 수 있는 코드에 concurrency를 추가하는것은 해당 코드가 더 빨라지거나 정확해지는것을 보장할 수 없다.
실제로 코드에 concurrency를 추가하는것은 디버깅하기 더 힘들어질 수 있다. 그러나 동시에 필요한 코드의 동시성을 위한 스위프트의 언어 수준의 지원은 스위프트가 컴파일 타임에 문제점을 잡을 수 있도록 도와준다는 것을 의미한다.
(이 챕터의 나머지 부분은 비동기 코드와 병렬 코드의 일반적인 조합을 지칭하기 위해 동시성이라는 용어를 사용한다.)
스위프트의 언어적인 지원없이 동시 코드를 쓸 수 있다해도, 해당 코드는 읽기 힘들어지는 경향이 있다. 예를들어, 아래 코드는 사진 이름들의 리스트와 그 리스트안에 있는 첫번쨰 사진을 다운로드하고 해당 사진을 유저에게 보여주는 코드이다.
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in
show(photo)
}
}
이렇게 간단한 코드임에도 이 코드는 일련의 completion 핸들러로 작성되었기 때문에 결국 중첩된 클로저로 코드를 마무리짓게 된다.
이러한 깊이 중첩된 더 복잡한 코드는 빠르게 이해할 수 없게 된다.
Defining and Calling Asynchronous Functions
비동기 함수 또는 비동기 메서드는 실행중에 중단될 수 있는 함수 또는 메서드의 특별한 종류이다. 이러한 비동기 함수 또는 메서드는 절대 리턴되지 않거나 에러를 던지거나 결론을 향해 실행될 수 있는 일반적이고 동기적인 함수 또는 메서드와 상반된다.
비동기 함수 또는 메서드는 여전히 그러한 세가지 중 하나를 실행하면서 어떠한 것을 위해 기다리는 중간에 중지를할 수 있다. 비동기 함수 본문 내에서 실행이 중단될 수 있는 지점을 표시할 수 있다.
함수 또는 메서드가 비동기라는 것을 의도하기 위해 파라미터 앞에 async 키워드를 붙여야한다. 만약 해당 함수 또는 메서드가 값을 리턴한다면, 리턴 화살표 (->) 전에 붙여야 한다. 예를들어 갤러리의 사진 이름을 가져오는 함수를 다음과 같이 정의할 수 있다.
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
함수 또는 메서드가 에러를 던지는 동시에 비동기적으로 작동한다면 throws 이전에 async를 붙여야 한다.
비동기적인 함수를 호출할때 실행은 메서드가 종료될 때 까지 중단된다. 이 때 await를 중단되는 지점 앞에 써야한다. 이것은 함수에서 에러를 던질때 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로 표시된 그다음 중단 지점 까지 실행이 되거나 코드가 종료될 때 까지 실행된다.
(기다리는동안 실행되는 다른 코드 또한 다른 await를 만날때 까지 실행된다는 것이다.)
3. listPhotos(inGallery:) 가 종료된 후, 이 코드는 그 지점에서 실행을 이어나가 photoNames에 반환된 값을 할당한다.
4. sortNames와 name을 지정하는 코드는 일반적인 동기적 코드이다. 이 코드중 await로 표시된 부분이 어디에도 없기 때문에 가능한 중단지점 또한 없다.
5. awiat로 표시된 그다음 중단지점은 downloadPhoto(named:) 함수이다. 이 코드는 또다시 downloadPhoto 함수가 종료될 때 까지 실행을 중단시키고 다른 동시 코드가 실행될 수 있는 기회를 부여한다. (다른 동시코드가 돌아갈 수 있게 해준다)
6. downloadPhoto(named:)가 종료된 후 리턴된 값을 photo에 할당하고 해당 photo값을 show함수가 호출될 때 파라미터로 사용된다.
await로 표시된 가능한 중단지점은 비동기 함수나 메서드가 종료되기를 기다리는동안 현재 코드 조각이 실행을 일시 중지할 수 있음을 나타낸다.
이것은 스레드 산출(thread yielding)이라고도 불리는데, 스위프트가 현재 스레드에서 코드실행을 중단하고 대신 해당 스레드에서 다른 코드를 실행하기 때문이다.
await 코드는 실행을 일시중지할 수 있어야 하기 때문에 다음과 같은 프로그램의 특정 부분에서만 비동기 함수나 메서드를 호출할 수 있다:
- 비동기적인 함수, 메서드 또는 프로퍼티의 본문
- @main으로 표시된 구조체, 클래스 또는 열거형의 정적 main() 메서드의 코드
- 구조화되지 않은 자식 작업(unstructured child task) 의 본문 (밑에 나옴)
Task.sleep(nanoseconds:) 메서드는 동시성이 어떻게 작동하는지 배우기 위해 간단한 코드를 작성할때 유용하다. 해당 함수는 어떠한 작동도 하지않지만 반환되기 전에 적어도 주어진 나노초를 기다린다.
다음은 sleep(nanoseconds:)를 사용하여 네트워크 작동 대기 시뮬레이션을 하는 listPhotos(inGallery:)함수이다.
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:) 함수는 비동기적으로 배열의 모든 원소가 준비된 이후에야 배열 전체를 한번에 리턴한다. 또다른 하나의 접근으로는 비동기 시퀀스(Asynchronous Sequence)를 사용하여 한번에 컬렉션의 하나의 원소만 기다리는 것이다. 아래는 비동기 시퀀스를 반복하는 것의 예시이다.
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
일반적으로 for-in 반복문을 사용하는것이 아닌 위의 예시에서는 for 문 앞에 await를 사용한다. 비동기 함수 또는 메서드를 호출할 때 처럼, await를 사용하는것은 가능한 중단 지점을 나타내는 것이다.
for-await-in 반복문은 다음 요소를 사용할 수 있도록 기다릴 때 각의 반복의 시작 지점에서 잠재적으로 실행을 중단시킨다.
(문맥상 다음 요소가 line 을 의미하는것 같음)
같은 방법으로 for-in 반복문을 Sequence 프로토콜을 채택하여 자신만의 방법으로 사용할 수 있고 AsyncSequence 프로토콜을 채택하여 for-await-in 반복문을 사용할 수 있다.
Calling Asynchronous Functions in Parallel
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)
이러한 접근은 큰 단점이 있다: 다운로드는 비동기적이며 이러한 작업을 실행하는동안 다른 작업을 수행할 수 있지만, 오직 한번에 하나의 downloadPhoto(named:) 호출만이 가능핟.
각 Photo는 그다음 Photo가 다운로드를 시작하기 전에 완벽하게 다운로드된다. 그러나 이러한 작업은 기다릴 필요가 없다. 각 사진은 독립적으로 또는 심지어 동시에 다운로드될 수 있기 때문이다. (어떻게?)
비동기 함수를 호출하고 주변 코드와 병렬로 실행되도록 하려면 변수를 선언할 때 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:) 함수를 호출할 때, 이전의 호출이 끝나는것을 기다리지 않고 시작한다.
만약 충분한 시스템 리소스가 가능하다면, 위의 세 코드는 동시에 실행될 것이다.
함수의 결과를 기다리기 위해 코드는 중단되지않기 때문에 함수 호출 어느것도 await로 표시되지 않는다. 대신 실행은 Photo가 정의된줄 까지 계속된다. 그 시점에서 프로그램은 이러한 비동기 호출의 결과가 필요하므로 세장의 사진 모두 다운로드가 끝날 때까지 실행을 일시중지하기 위해 기다릴 수 있다.
다음은 두 접근 방법에 대해 생각해볼 수 있는 차이점들이다.
- 다음 줄의 코드가 함수의 결과에 따라 다를 때 await으로 비동기 함수를 호출하라. 이것은 순차적으로 수행되는 작업을 만든다.
- 당신의 코드에서 결과가 나중까지 필요하지 않을 때 async-let으로 비동기 함수를 호출하라. 이것은 병렬로 수행할 수 있는 작업ㅇ르 만든다.
- await과 async-let 모두 일시 중지되는 동안 다른 코드를 실행할 수 있다.
- 두 경우 모두 가능한 중단 지점을 await으로 표시하여 필요한 경우 비동기 함수가 반환될 때까지 실행이 일시 중지 되었음을 나타낸다.
또한 두 코드를 섞어 한번에 사용할 수도 있다.
Tasks and Task Groups (Structured Concurrency)
task는 프로그램의 일부로서 비동기적으로 작동할 수 있는 일의 단위이다. 모든 비동기 코드는 특정 task의 일부로써 돌아간다. 이전에 설명되었던 async-let 구문은 자식 task를 만든다.
또한 task group을 만들 수 있고 그 그룹에 자식 task를 추가할 수 있다. task 그룹은 우선순위와 실행 취소와 같은 제어를 넘어 task에 대한 dynamic number를 만들 수 있게 한다.
Task는 계층 구조로 배열되어 있다. task 그룹의 각 task에는 동일한 부모 task가 있으며, 각 task은 자식task를 가질 수 있다. task와 task 그룹 간의 명시적인 관계 때문에 이 접근 방식은 구조화된 동시성이라고 불린다. 정확성에 대한 책임을 지지만, task간의 명시적인 부모-관계를 통해 스위프트는 취소 전파와 같은 일부 행동을 처리할 수 있으며, 스위프트는 컴파일 시 일부 오류를 감지할 수 있다.
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
위에서 설명한 구조적인 동시성에 대한 접근방법에 추가하여, 스위프트는 구조적이지 않은 동시성(unstructured concurrency) 또한 지원한다. task 그룹의 일부로서 task와는 다르게 unstructured task는 부모 task를 가지지 않는다.
당신의 프로그램이 필요로하는 어떤 방식이든 당신은 unstructured task를 관리를 위한 완벽한 유연성을 갖게된다. 하지만 또한 당신은 그들의 정확성을 위한 완전한 책임을 갖게된다
현재 actor에서 실행되는 unstructured task를 만들기 위해 Task.init(priority:operation:) 이니셜라이저를 사용해야한다.
현재 actor의 일부가 아닌 unstructured task를 만들기 위해서는 detached task라 불리는 Task.detached(priority:operation:) 클래스 메서드를 호출해야한다.
이 두가지 연산 모두 task 핸들을 리턴하며 이를 통해 task와 상호작용할 수 있게된다. 예를들어 작업의 결과를 기다리거나 작업을 취소시키는것과 같은 것들 말이다.
let newPhoto = // ... some photo data ...
let handle = Task {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
Task Cancellation
스위프트의 Concurrency는 협력적인 취소 모델을 사용한다. 각 task는 자신의 실행시간동안 적절한 지점에서 취소 됐는지 체크하고 적절한 방식으로 취소에 대한 응답을 한다.
당신의 작업에 따라 이 task 취소라는 작업은 다음과 같은 의미를 가진다.
- CancellationError와 같은 에러를 던진다.
- 빈 컬렉션 또는 nil을 반환한다.
- 부분적으로 완료된 작업을 반환한다.
취소를 체크하려면, Task.checkCancellation()를 호출한다. 해당 함수는 만약 task가 취소됐다면 CancellationError 에러를 던져야한다. 또는 Task.isCancelled 값을 체크하고 당신의 코드 내에서 취소를 관리하면된다.
예를들어 갤러리부터 이미지를 다운로드하는 task는 아마 부분적으로 다운로드를 삭제하고 네트워크를 종료시켜야 할 필요가 있을것이다.
(매우 직역톤..ㅋㅋㅋ)
수동으로 취소시키려면 Task.cancel()를 호출하면된다.
Actors
클래스처럼 actor는 참조타입이다. 클래스와는 다르게 actors는 한번에 하나의 task만을 그들의 mutable state에 접근할 수 있도록 허락한다. 그렇게 함으로써 하나의 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 actor은 외부의 actor가 접근할 수 있고 내부의 actor만이 max 프로퍼티에 접근하여 최대값을 업데이트해 제한할 수 있다.
구조체와 클래스처럼 이니셜라이저 문법을 사용하여 actor 인스턴스를 만들 수 있다. actor의 메서드 또는 프로퍼티에 접근할 때, await를 사용하여 잠재적인 중단 지점을 표시할 수 있다.
예를들어
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
이 예시에는 logger.max 에 접근하는것이 발생할 수 있는 중단 지점이다. actor의 mutable state엔 한번에 오직 하나의 task만이 접근할 수 있으므로 이미 logger와 상호작용하고 있는 또다른 task 코드가 있다면 이 코드는 max프로퍼티에 접근하기 위해 기다리는동안 중단된다.
반대로 actor의 한 부분의 코드는 해당 actor의 프로퍼티에 접근할 때 await를 쓰지 않는다.
예를들어 TemperatureLogger 클래스의 새로운 온도를 업데이트하는 메서드 예시이다.
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
update(with:) 메서드는 이미 actor에서 실행되고 있다. 따라서 자신의 프로퍼티에 접근한다는것을 await로 표시하지 않는다.
이 메서드는 또한 왜 actor의 mutable state가 한번에 하나의 task만 상호작용 가능한지에 대해 보여준다 : 어떤 actor의 상태에 대한 업데이트는 일시적으로 불변성을 깨트린다.
TemperatureLogger actor은 최대 온도값과 온도 리스트를 추적하고 새로운 온도를 기록할 때 해당 actor는 최대 온도값을 수정한다. 이러한 업데이트 중, max 프로퍼티를 업데이트 하기전 그리고 새로운 측정치를 추가한 후에 온도 logger는 순간적으로 일관성 없는 상태에 이른다.