이번 WWDC 21에서 Swift에 async, await를 지원하면서 동시성에 대한 이야기가 있었다. 그래서 Swift 공식 문서에 나온 동시성에 대해 정리해보려고 한다.
동시성(Concurrency)은 다수의 코어가 있는 환경에서 각 코어가 작업 중 코어마다 동시에 여러개의 코드를 실행하는 것을 말한다.
Swift의 언어적인 지원 없이 동시적인 코드를 작성하는 것이 가능해도, 코드를 읽는 점에서는 어려운 경향이 있다. 예를 들어, 아래 코드는 사진명 리스트를 다운로드하고 리스트의 첫번째 사진을 다운로드 한 다음 사진을 사용자에게 보여주는 코드다.
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[1]
downloadPhoto(named: name) { photo in
show(photo)
}
}
이러한 간단한 경우에도 코드는 일련의 completion handlers로 작성되기 때문에 결국엔 중첩 클로저를 작성하게 된다. 이러한 스타일은 중첩이 많은 더 복잡한 코드를 다루기 힘들게 만든다.
비동기 함수나 메서드는 실행 중 종료될 수 있는 특별한 종류다.
함수나 메서드가 비동기인 것을 표현하려면 async
키워드를 매개변수 다음에 명시한다. 마치 throw
를 명시하는 것 처럼. 그리고 반환하는 값이 있으면 async
키워드 다음에 명시해주면 된다.
func listPhotos(inGallery name: String) async -> [String] {
...
return
}
// 비동기이면서 에러를 던진다면?
func listPhotos(inGallery name: String) async throws -> [String] {
...
return
}
비동기 메서드를 호출하면 해당 메서드가 리턴할 때까지 실행이 중지가 된다. 일시중지할 포인트를 마킹하기 위해서 호출부분 앞에 await
키워드를 작성한다. 이것은 try
키워드를 작성하는 것과 같다.
아래 코드는 갤러리에 있는 모든 사진의 이름을 가져오고 첫번째 사진을 보여주는 코드다.
// 1)
let photoNames = await listPhotos(inGallery: "Summer Vacation")
// 2)
let sortedNames = photoNames.sorted()
// 3)
let name = sortedNames[1]
// 4)
let photo = await downloadPhoto(named: name)
// 5)
show(photo)
1번과 4번에서 await
키워드가 작성되었다. 그러면 어떻게 실행되는지 짚어보면..
await
표시된 일시 정지 포인트나 완료될 때 까지 실행된다.photoNames
에 리턴된 값이 할당된다.await
키워드로 인해 값을 반활할 때까지 일시정지되고 다른 동시 코드를 실행할 수 있는 기회를 제공한다.await
키워드를 사용하려면 일시 정지를 할 수 있어야하므로 프로그램의 특정 위치에서만 비동기 함수나 메서드를 호출할 수 있다.
위 코드의 작업은 반환되는 모든 배열의 요소를 기다린 다음에 전체 배열을 한 번에 비동기식으로 반환하지만 비동기 시퀀스를 사용하면 한번에 하나의 요소만 기다리도록 할 수 있다.
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
for-await-in
루프는 각 반복의 시작점에서 일시 정지해서 다음 요소를 사용할 수 있을 때 까지 기다린다.
await
키워드를 사용해서 비동기 함수가 호출하면 한 번에 하나의 코드만 실행된다. 비동기 코드가 실행이 되면 호출자는 다음 줄이 실행되기 전에 코드가 끝날 때 까지 기다린다. 예를 들어, 갤러리에서 처음 3개의 사진을 가져오려면 다음과 같이 downloadPhoto 메서드에 대한 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 메서드가 호출이되고 다음 것이 진행되기 위해서는 전 작업이 마무리 되어야 한다는 점이다. 그러나 각 작업을 굳이 기다릴 필요가 없다. 각 작업은 독립적으로 진행할 수 있거나 동시에도 가능하다.
비동기 함수를 호출하고 주변 코드와 병렬로 실행되도록하려면 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)
이 예제가 바로 하나의 작업이 완료되는 것을 기다리지 않고 3개의 downloadPhoto 메서드를 호출하여 사용할 수 있다.
task는 프로그램에서 비동기적으로 실행할 수 있는 작업의 단위를 말한다. 모든 비동기 코드는 task의 일부로 실행이 된다. 위에서의 async-let
문맥은 하위 task 생성을 보여준다. 또한 task group을 생성한 다음 하위 task를 추가할 수 있다. 이로 인해서 priority와 cancellation을 세부적으로 제어 할 수 있으며 동적인 task들을 생성할 수 있게 한다.
task는 계층적으로 정렬되며 각 task group의 task는 같은 상위 task를 갖고 각 task는 하위 task를 갖을 수 있다. task와 task group 간의 명시적인 관계 때문에 이러한 접근을 structed concurrency(구조화된 동시성?) 라고 부른다.
await withTaskGroup(of: Data.self) { taskGroup in
let photoNames = await listPhotos(inGallery: "Summer Vacation")
for name in photoNames {
taskGroup.async { await downloadPhoto(named: name) }
}
}
swift는 unstructed concurrency(구조화되지 않은 동시성?)도 지원한다. 구조화되지 않은 task는 상위 task가 없다.
현재 actor에서 실행되는 구조화되지 않은 task를 생성하려면 async(priority: operation: )
함수를 호출한다. 현재 actor의 일부가 아닌 구조화되지 않은 task를 만드려면 구체적으로 분리 된 작업으로 알려진 asyncDetached(priority: operation: )
을 호출한다. 두 함수는 결과를 기다리거나 취소하는 등 task와 상호작용 할 수 있는 task handler를 반환한다.
let newPhoto = // ... some photo data ...
let handle = async {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.get()
swift 동시성은 cooperative cancellation 모델을 사용한다. 각 task는 실행 중 적절한 지점에서 취소되었는지 여부를 확인하고 적절한 방식으로 취소에 응답한다. 수행하는 작업에 따라 보통 다음을 의미한다.
cancellation을 확인하려면 Task.checkCancellation()을 호출해서 Cancellation Error를 발생 시키거나 Task.isCancelled 값을 확인하고 코드에서 취소를 처리하면 된다.
wwdc 21에서 나왔던 actor도 내용이 있다. class처럼 actor도 참조타입이다. 하지만 class와 다르게 actor는 한번에 변경 가능한 상태에 하나의 task만 접근하는 것을 허락하고 다중 task의 코드가 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 인스턴스는 클래스나 구조체와 같은 문맥을 사용해서 생성할 수 있다. actor의 프로퍼티나 메서드의 접근할 때, await 키워드를 사용해서 잠재적인 정지 포인트를 표시해야한다.
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
logger.max에 await 없이 접근하려면 실패하게 된다. 왜냐하면 actor의 프로퍼티는 actor의 격리된 로컬 상태의 일부분이기 때문이다. Swift는 actor안에 코드만 actor의 로컬 상태에 접근하는 것을 보장한다. 이것을 actor isolation 이라고 한다.