[Swift] 공식문서 18 - Concurrency(동시성)
Concurrency — The Swift Programming Language (Swift 5.5)
Concurrency Swift has built-in support for writing asynchronous and parallel code in a structured way. Asynchronous code can be suspended and resumed later, although only one piece of the program executes at a time. Suspending and resuming code in your pro
docs.swift.org
1차 수정 (2022 / 02 / 28) : 의역, 오타 및 스타일 수정
Concurrency
Swift는 비동기(asynchronous) 및 병렬(parallel) 코드를 작성할 수 있게 도와줍니다. 비동기적 코드는 잠시 중단되었다가 나중에 다시 실행될 수 있습니다. 한 번에 프로그램의 하나 씩만 실행됩니다. 프로그램에서 코드를 잠시 중단했다가 다시 실행하면, 긴 실행문을 실행하거나 네트워크에서 파일을 다운받는 등의 오랜 시간이 걸리는 작업을 하면서 UI 업데이트와 같은 단기 작업을 진행할 수 있습니다.
병렬 코드는 여러 개의 코드를 동시에 실행합니다. 예를 들어 4코어 프로세서는 4개의 코드를 동시에 작업하며, 각 코어는 작업 중 하나를 선택합니다. 병렬 및 비동기 코드를 사용하는 프로그램은 한 번에 여러 작업을 수행합니다. 외부 시스템을 기다리는 작업을 잠시 중단하기도 하고 메모리를 안전하게 관리하며 코드를 쉽게 작성하도록 합니다.
병렬 및 비동기 코드의 추가적인 스케줄링(scheduling) 유연성(flexibility)도 복잡성을 증가시킵니다. Swift는 컴파일-시간 검사를 수행할 수 있는 방식으로 사용자의 의도를 표현할 수 있습니다. 예를 들어 actors를 사용하여 변경가능한 상태에 안전하게 접근합니다.
그러나 느리고 버그가 있는 코드에 동시성을 추가하는 것이 더 빨라진다는 것을 보장하지는 않습니다. 동시성을 추가하는 것은 코드 디버깅을 더 어렵게 만듭니다. 동시성이 필요한 코드에 Swift의 언어-레벨 지원(language-level support) 을 사용하는 것은, Swift 가 컴파일 시간에 문제를 발견하게 도와줍니다.
이 장은 비동기 및 병렬 코드의 조합을 나타내는 용어로 동시성을 사용합니다.
이전에 동시적으로 작동하는 코드를 작성해봤다면, 아마 쓰레드를 사용했을 것 입니다. 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의 연속으로 작성됩니다. 사용자는 중첩된 클로져를 사용해야합니다. 이런 경우에 더 깊은 중첨을 사용하면 코드를 다루기 더 어려워집니다.
Defining and Calling Asynchronous Functions(비동기 함수 정의 및 호출)
비동기 함수 및 비동기 메소드는 실행 중에 일시 중단할 수 있는 특별한 종류의 함수 및 메소드입니다. 기존의 동기함수 와 메소드에 반대됩니다. 동기함수의 경우 1. 완료까지 실행하거나 2. 오류를 발생시키거나 3. 리턴을 하지 않습니다.
비동기 함수도 3가지 경우 중 하나를 수행합니다. 하지만 무언가를 기다리기 위해 코드 중간에서 잠시 멈출 수 있습니다. 비동기 함수 및 메소드의 본문 안에서 실행을 일시 중단시킬 수 있는 위치를 표시합니다.
비동기 함수 및 메소드를 작성하기 위해 기존 정의의 파라미터 () 뒤에 async 키워드를 사용합니다. 만약 함수나 메소드가 값을 리턴한다면, -> 전에 async 를 작성합니다.
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
비동기 함수이면서 스로잉하고 싶으면 throws 앞에 async 를 작성합니다.
func listPhotos(inGallery name: String) async throws -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
비동기 메소드를 호출하면, 메소드가 리턴될 때까지 실행이 유예됩니다. 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:) 함수가 네트워크 요청이 필요하기 때문에 실행을 완료하려면 상대적으로 긴 시간이 걸립니다. await 코드를 사용해 비동기적으로 만들어 사진이 준비될 때까지 앱의 나머지 코드를 계속 실행합니다.
위의 예제의 동시성을 이해하기 위해 다음과 같이 실행 순서를 작성할 수 있습니다.
- 코드의 첫 줄이 실행되면서 await 이 실행됩니다. listPhotos(inGallery:) 함수를 호출하고 해당 함수가 리턴할 때 까지 실행을 잠시 유예합니다.
- 코드 실행이 유예되는 동안, 같은 프로그램의 다른 동시적인 코드가 실행됩니다. 예를 들어 오랜 시간 실행되는 백그라운드 작업을 통해 새 사진 갤러리 목록을 업데이트 합니다. 이 코드는 다음 유예 지점(await으로 표시된 곳) 까지 실행되거나 완료될 때까지 실행됩니다.
- listPhotos(inGallery:)가 리턴을 한 후, 코드는 해당 지점에서 코드를 다시 실행합니다. (첫번째 줄의 await이 있는 곳에서 다시 코드 실행) 리턴 된 값을 photoNames에 할당합니다.
- sortedNames 와 name으 동기적이 코드이므로 정상 실행됩니다.
- downloadPhoto(named:) 함수 앞에 await이 표시돼 있습니다. 이 코드는 함수가 리턴할 때까지 실행을 멈추고 다른 동시적인 코드에게 실행할 수 있는 기회를 줍니다.
- downloadPhoto(named:) 함수가 리턴 한 후, photo 에 리턴된 값이 할당되며 show(photo)가 호출됩니다.
유예 가능 지점에 await을 사용해 `현재 코드는 다른 비동기 함수나 메소드가 리턴할 때까지 코드가 잠시 멈출 수 있다는 것`을 나타냅니다. 이것은 쓰레드 양보(yielding the thread)라고도 불립니다. 백그라운드에서 Swift는 현재 쓰레드에서 코드 실행을 멈추고 다른 코드를 실행하기 때문입니다. await이 있는 코드는 실행을 잠시 중단할 수 있기 때문에 비동기함수나 메소드가 호출될 수 있는 정확한 지점에서만 사용해야 합니다.
- 비동기 함수, 메소드, 프로퍼티 본문 내에 있는 코드
- @main이 표시된 구조체, 클래스, 열거형의 정적(static) main() 메소드 내부의 코드
- 분리된 하위 작업 내부의 코드. 자세한 사항은 아래서 설명할 Unstructured Concurrency
Task.sleep(_:)메소드를 사용해 동시성의 원리에 대한 간단한 코드를 작성할 수 있습니다. 이 메소드는 나노 초(10^-9) 만큼 기다렸다가 리턴합니다. 예제는 2 초 후에 값을 리턴합니다. 이를 통해 네트워크 동작이 실제로 실행되는 것처럼 코드를 작성할 수 있습니다.
func listPhotos(inGallery name: String) async -> [String] {
await Task.sleep(2 * 1_000_000_000) // Two seconds
return ["IMG001", "IMG99", "IMG0404"]
}
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 루프는 매 반복(iteration)의 시작마다 `다음 요소가 사용할 수 있을 때`까지 잠재적으로 실행을 잠시 중단합니다.
같은 방식으로 for-in 루프에 Sequence 프로토콜을 추가해서 사용자 지정 타입을 만들수 있습니다. for-await-in 루프에도 AsyncSequence 프로토콜을 추가해서 사용자 지정 타입을 만들수 있습니다.
+ for-in 루프에도 await을 사용해 반복적으로 비동기 실행이 가능하다.
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:) 가 한 번에 하나만 실행됩니다. 각 다운로드가 완전히 완료되야 다음 다운로드가 실행됩니다. 다른 코드를 기다려야할 이유가 없습니다. 각 다운로드는 독립적으로 진행되거나 동시에 진행될 수 있습니다.
비동기 함수를 호출하고 병렬적으로 코드를 실행합니다. 상수 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개는 동시에 실행될 수 있습니다. 다른 함수를 기다릴 필요가 없기에, 함수 선언시 await 을 작성하지 않았습니다. photos 를 선언하고 사용하기 위해서는 세 개의 다운로드가 모두 완료되어야 합니다. 따라서 photos에 할당하기 전에 await 을 작성해주어야 합니다.
두 가지 접근 방식이 어떻게 다른지 다음과 같이 생각해볼 수 있습니다.
- 다음(next) 줄의 코드가 함수 결과에 의존해야 할 때, await을 사용해 비동기 함수를 호출합니다. 이것은 순차적으로 실행되는 작업을 만듭니다.
- 나중 코드까지 결과가 필요하지 않을 때 async-let 을 사용해 비동기 함수를 호출합니다. 이것은 병렬적으로 실행되는 작업을 만듭니다.
- await과 async-let은 일시 중단되는 동안 다른 코드를 실행할 수 있습니다.
- 두 가지 경우 await을 사용해 모두 일시 중단이 예상되는 곳을 나타내줍니다. 필요한다면 비동기함수가 리턴될 때까지 중단되는 곳에도.
같은 코드에서 두 개를 섞어서 사용할 수 있습니다.
Tasks and Task Groups (작업 및 작업 그룹)
작업(Task)는 프로그램의 일부로써 비동기적으로 실행될 수 있는 일의 단위입니다. 모든 비동기 코드는 어떤 작업의 일부로 실행됩니다. 앞서 설명한 async-let 구문은 하위(child) 작업을 생성합니다.
작업 그룹을 생성하고 그룹에 하위 작업을 추가할수도 있습니다. 이를 통해 우선 순위(priority)와 취소(cancellation)를 효과적을 제어할 수 있으며 동적 작업을 생성할 수 있습니다.
작업은 계층 구조(hierarchy)로 정렬됩니다. 작업 그룹의 작업은 똑같은 부모(parent) 작업을 가집니다. 각각의 작업은 하위 작업을 가질 수 있습니다. 작업과 작업 그룹사이의 명시적 관계 때문에 이러한 방식을 체계적인 동시성(structured concurrency) 이라고 부릅니다.
사용자가 일부 책임을 지더라도, 부모-자식 작업간의 명시적 관계는 Swift가 취소(cancellation) 전달 같은 작업을 관리하게 해줍니다. 또 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) }
}
}
자세한 정보는 TaskGroup 참조
Unstructured Concurrency(체계적이지 않은 동시성)
Swift는 체계적이지 않은 동시성(Unstructured Concurrency) 도 지원합니다. 작업 그룹의 일부인 작업들과 달리 체계적이지 않은 작업(Unstructured task)은 부모 작업이 없습니다. 프로그램에 필요한 방식으로 Unstructured task를 관리하기 위한 유연성을 가졌지만, 책임 또한 전적으로 사용자에게 있습니다.
현재 동작의 일부로써 Unstructured task를 생성하기 위해서 Task.init(priority:operation:) 이니셜라이져를 호출합니다. 현재 동작의 일부가 아닌 부분에 Unstructured task를 생성하기 위해서는 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
자세한 정보는 Task 참조
Task Cancellation(작업 취소)
Swift 동시성은 협력적인 취소 모델을 사용합니다. 적절한 지점에서 실행이 취소됐는지, 적절한 방법으로 취소에 응답해는지, 각 작업이 확인합니다. 수행하는 작업에 따라 다르지만, 보통 다음 중 하나를 의미합니다.
- CancellationError와 같은 에러 스로잉
- nil 또는 빈 컬렉션 리턴
- 부분적으로 완료된 일 리턴
취소를 확인하기 위해 Task.checkCancellation() 를 호출하거나 Task.isCancelled의 값을 확인합니다. 예를 들어 갤러리에서 사진을 다운로드하는 작업은 부분적으로 다운로드를 삭제하고 네트워크 연결을 끊어야 할 때가 있습니다.
자세한 정보는 Task.cancel()
Actors(액터 = 행위자)
클래스처럼 액터도 참조(reference) 타입입니다. 클래서스와는 다르게 액터는 한 번에 하나의 작업이 액터의 가변 상태(mutable state)에 접근합니다. 이를 통해 여러 작업의 코드가 액터의 인스턴스와 상호작용하는 것을 안전하게 해줍니다.
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 액터는 코드 밖의 다른 액터들이 접근할 수 있는 프로퍼티를 가집니다. max 프로퍼티는 private으로 선언해 해당 액터안에서만 업데이트할 수 있도록 제한합니다.
구조체, 클래스와 동일한 이니셜라이져 구문을 사용해 액터의 인스턴스를 생성합니다. 액터의 프로퍼티나 메소드에 접근할 때, await을 사용해 잠재적 일시 중단 지점(potential suspension point)에 표시합니다.
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
예제에서 logger.max는 잠재적 일시 중단 지점입니다. 한 번에 하나의 작업만 액터의 가변 상태(mutable state)에 접근할 수 있습니다. 따라서 다른 작업이 이미 logger와 상호작용하고 있다면 이 코드는 프로퍼티에 접근하기 전까지 잠시 중단하고 기다립니다.
반대로 액터의 프로퍼티에 접근할 때, 액터의 일부인 코드는 awiat을 작성하지 않습니다.
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
update(with:) 메소드는 이미 액터에서 실행되고 있습니다. 따라서 await 키워드를 사용하지 않습니다. 이 메소드는 왜 액터가 한 번에 하나의 작업만 가변 상태(mutable state)에 접근하게 하는지 보여줍니다.
액터의 상태에 대한 일부 업데이트가 일시적으로 불변성(invariants)을 깨트립니다. TemperatureLogger 액터는 온도 리스트와 최대 온도를 추적하고 새 측정값이 기록될 때 최대 온도를 업데이트 합니다. 업데이트 중, 최대 온도를 업데이트 하기전에 새로운 측정값을 추가한다면 온도 측정은 일시적으로 일관성이 없는 상태가 됩니다.
여러 작업이 동시에 같은 인스턴스와 상호작용하는 것을 방지하면 아래와 같은 이벤트 시퀀스 문제를 방지할 수 있습니다.
- 코드가 update(with:) 메소드를 호출하고 measurements 배열을 먼저 업데이트 한다.
- 코드가 max를 업데이트 하기 전에, 코드의 다른 곳에서 최대값과 온도 배열을 읽는다
- 최대 값이 변경되고 코드가 업데이트가 종료된다.
이 경우에 코드의 다른 곳에서 잘못된 정보를 읽게됩니다. 데이터가 일시적으로 유효하지 않을 때 액터에 대한 접근을 시도했기 때문입니다.
Swift 액터를 사용해 이러한 문제를 방지할 수 있습니다. 액터는 한 번에 하나의 동작만을 허용하고, 코드는 await이 표시돼있는 일시 중단 지점에서만 중단될 수 있기 때문입니다.
update(with:) 메소드에는 일시 중단 지점이 없기 때문에, 업데이트 중에 접근할 수 있는 다른 코드가 없습니다.
액터의 밖에서 해당 프로퍼티에 접근하려고 하면 컴파일-시간 에러가 발생합니다.
print(logger.max) // Error
await 없이 logger.max에 접근하면 실패합니다. 액터의 프로퍼티는 액터 내에서 고립된 지역 상태( actor’s isolated local state)의 일부입니다.
Swift는 액터 내부의 코드만이 액터의 local state에 접근하도록 보장합니다. 이는 actor isolation 으로 알려져있습니다.
동시성 코드를 작성할 때 가장 힘든 것이 여러 곳에서 하나의 프로퍼티를 동시에 작업할 때이다.
액터(Actors)를 사용하면 한 번에 하나만 접근할 수 있기에 동시성 코드에서 에러가 발생하는 것을 막아준다.
Concurreny에 있는 비동기 함수, 작업(tasks), 액터(actor)는 얼마 전에 추가된 녀석들입니다. (2021-09-20 신상)
비동기 작업은 앱의 최적화를 위해서 매우 중요하므로 열심히 복습해야겠습니다.
Concurrency 끗 😚