Swift는 Asynchronous하고 Parallel하게 코드를 작성할 수 있는 기능이 내장된 언어이다.
프로그램은 한 번에 하나의 동작을 하지만, Asynchronous한 코드는 잠깐 중단되었다가 나중에 실행될 수 있는 코드이다. Asynchronous한 코드는 네트워크를 통해 데이터를 가져오는 작업을 실행하는 와중에 UI 업데이트와 같은 작업들을 계속해서 실행할 수 있도록 한다.
Parallel한 코드는 동시에 여러 작업을 할 수 있는 코드이다. 예를 들어, 4개의 코어가 있는 컴퓨터는 각각의 코어가 하나의 작업을 수행하여 동시에 4개의 작업을 할 수 있다. Asynchronous하고 parallel한 코드는 한 번에 여러 작업을 실행할 수 있으며, Memory-safe하게 코드를 작성할 수 있다.
처음에는 Asynchronous와 Synchronous, Serial과 Parallel의 개념의 혼동이 있었는데, 쉽게 말해서 작업을 실행시키고 그 작업이 끝날 때까지 기다리는 것이 Synchronous, 기다리지 않고 다른 작업을 하는 것이 Asynchronous이며, 한 번에 여러 작업을 하는 것은 Parallel, 한 번에 하나의 작업을 하는 것이 Serial이다.
느리거나 버그가 많은 코드에 동시성을 추가하는 것은 그 코드가 빨라지거나 정확해지는 것을 보장하는 것은 아니다. 사실 동시성을 추가하면 디버깅이 더 힘들어 질 수 있다. 하지만 동시성을 위해 Swift의 언어 레벨의 지원을 사용하는 것은 Swift가 컴파일 타임에 여러 문제들을 발견하는 데 도움을 줄 수 있다는 것을 의미한다.
Swift가 지원해주는 concurrent를 사용하지 않고 concurrent한 코드를 작성할 수는 있지만, 아마 읽기가 굉장히 어려울 것이다.
아래의 코드는 사진 이름의 리스트를 다운받고, 리스트의 첫 번째 사진을 다운로드 받아 사용자에게 보여주는 코드이다.
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in
show(photo)
}
}
이런 간단한 케이스에서도 코드는 completion handler를 여러 개 작성해야하기 때문에 계속해서 클로져가 중첩될 것이다. 이와 같이 계속해서 중첩이 된다면 코드는 더 복잡해질 것이다.
Asynchronous 함수는 실행 중에 중단할 수 있는 함수이다. 따라서 Asynchronous 함수의 내부에 어느 작업을 중단할 수 있는지 표시해야한다.
함수가 Asynchronous 하다는 것을 나타내기 위해 throws
키워드와 비슷하게 함수의 파라미터 뒤에 async
키워드를 작성한다. 만약 함수의 리턴 값이 존재한다면 return 타입
앞에 작성한다.
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
만약 함수가 에러도 발생시키고, Asynchronous 함수임을 나타내려면
throws
전에async
를 작성한다.
Asynchronous 함수를 호출할 때, 그 함수가 리턴될 때까지 실행을 중단한다. await
키워드를 코드의 앞에 작성하여 "이 코드는 중단할 수 있는 작업이다"
라는 것을 표시한다. 앞서 포스팅했던 Error Handling에서 에러를 발생시킬 수 있는 함수에서 try
키워드를 앞에 작성했던 것과 매우 유사하다.
Asynchronous 함수 내에서의 실행 흐름은 다른 Asynchronous 함수를 호출했을 때에만 중단된다. 쉽게 말해 실행이 중단될 수 있는 모든 위치에 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:)
함수는 네트워크 작업이 필요하기 때문에 완료되기까지 상대적으로 더 많은 시간이 필요하다. Asynchronous하게 처리하면 사진이 준비될 때까지 나머지의 코드는 계속해서 실행될 것이다. 위 코드의 실행 순서는 다음과 같다.
- 코드는 첫 번째 줄부터 시작하여 처음
await
키워드가 나올 때까지 실행된다.listPhotos(inGallery:)
함수가 실행되고 이 함수가 리턴될 때까지 기다린다(== 중단한다).- 작업이 중단되는 동안, 다른 동시성 코드(예를 들어 새로운 사진 갤러리의 리스트를 업데이트하는 백그라운드 작업)가 실행된다. 그 코드 또한 다음의
await
까지 실행될 것이다.listPhotos(inGallery:)
함수가 작업을 마치고,photoNames
상수에 리턴값이 할당된다.sortedNames
와name
는 일반 코드와 같이 동작한다.- 다음
await
키워드가 작성된 부분은downloadPhoto(named:)
함수이다. 이 함수 역시 함수가 완료될 때까지 실행을 중단할 것이고, 다른 동시성 코드가 실행될 것이다.downloadPhoto(named:)
함수가 완료된 후photo
상수에 리턴값이 할당되고, 그 후에show(_:)
함수의 인자로서 전달된다.
await
키워드가 명시된 코드는 Swift가 현재 쓰레드에서 코드의 작업을 중단하고, 다른 코드를 실행하기 때문에 "yielding the thread"
라고 불린다.
Asynchronous 함수를 호출할 수 있는 특정 위치에만 작성할 수 있다.
- Asynchronous 함수, 메소드나 프로퍼티의 내부
@main
으로 명시된class, struct, enum
의"static main()"
메소드"Unstructured Concurrency"
와 같은 구조화되지 않은 하위의 작업
앞서 예시를 들었던 listPhotos(inGallery:)
함수는 배열의 요소들이 준비가 되면 비동기적으로 배열을 리턴한다. 이와는 조금 다르게 Asynchronous sequence를 사용하여 한 번에 하나의 요소를 기다리는 것이 있다. 반복을 통한 Asynchronous sequence의 예시는 다음과 같이 나타낼 수 있다.
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
일반적인 for-in
반복문 대신에 for
뒤에 await
를 작성한다. for-await-in
반복문은 각 반복이 시작될 때, 다음 요소를 사용할 수 있을 때까지 작업을 중단한다.
Sequence 프로토콜을 준수함으로써 커스텀 타입을 for-in
반복문에서 사용할 수 있는 것처럼, AsyncSequence 프로토콜을 준수하여 커스텀 타입을 for-await-in
반복문에서 사용할 수 있다.
Asynchronous 함수는 한 번에 하나의 작업을 실행할 수 있다. Asynchronous 작업이 실행되는 동안 함수를 호출한 친구는 다음 줄의 코드를 실행하기 전에 그 코드가 완료될 때까지 기다린다.
이는 아래의 코드와 같이 작성될 수 있다. 갤러리에서 3장의 사진을 가져오려면, downloadPhoto(named:)
함수를 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)
이런 식으로 코드를 작성해도 괜찮아보이지만 전혀 그렇지 않다.
여기에 힌트가 담겨있다.
"갤러리에서 3장의 사진을 가져오려면,
downloadPhoto(named:)
함수를 3번 기다려야한다."
사진 다운로드가 Asynchronous하게 진행되고, 동시에 다른 작업이 실행될 수 있지만, downloadPhoto(named:)
함수는 한 번에 하나씩만 실행할 수 있다는 것(=="함수를 3번 기다려야한다")이 좀 거슬린다.
각 사진은 다음 사진이 다운로드 되기 전에 다운로드가 완료된다. 하지만 그렇다고 각 사진이 다운로드 되는 동안 다음 사진의 다운로드가 이를 기다릴 필요는 없다. 각 사진의 다운로드는 독립적이기 때문이다.
Asynchronous 함수를 Parallel하게 실행하고 싶다면 상수를 정의할 때 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
키워드가 작성되지 않았다. 대신에, 마지막에 모든 사진을 받아올 때까지 기다려야하기 때문에 await
키워드를 작성해야한다.
- 이전의 함수의 결과에 연관이 있다면, 다시 말해 독립적이지 않다면,
await
키워드로 Asynchronous 함수를 호출한다. 이것은 결과적으로 순차적인 작업을 이끌어낸다.- 이전의 함수의 결과와 상관없을 경우
async-let
키워드로 Asynchronous 함수를 호출한다. 이것은 위와 반대로 병렬적인 작업을 이끌어낸다.await
와asynce-let
모두 작업을 중단하고 다른 작업을 할 수 있도록 한다.- 또한 두 가지 방법을 섞어서 사용할 수도 있다.
async-let
구문은 자식 task를 생성할 수 있도록 한다."structured concurrency"
라고 불린다.await withTaskGroup(of: Data.self) { taskGroup in
let photoNames = await listPhotos(inGallery: "Summer Vacation")
for name in photoNames {
taskGroup.addTask { await downloadPhoto(named: name) }
}
}
앞서 언급한 동시성에 대한 구조화된 접근 외에도, Swift는 "Unstructured Concurrency"
를 지원한다. task group의 부분인 task와 달리, unstructured task
는 부모 task를 가지고 있지 않다. unstructured task
를 관리함에 있어서 유연성을 갖췄지만, Swift의 도움 없이 모든 책임을 져야한다.
unstructured task
를 만들려면 Task.init(priority:operation:)
이니셜라이저 또는 Task.detached(priority:operation:)
클래스 메소드를 호출해야 한다. 두 동작은 task와 상호작용할 수 있는 task handle을 리턴한다.
let newPhoto = // ... some photo data ...
let handle = Task {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
actor
는 class
와 마찬가지로 참조타입이다. class
와 달리 actor
는 한 번에 하나의 mutable한 상태의 작업에 접근할 수 있어서 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
는 class
, struct
그리고 enum
과 선언하는 방식이 비슷하다. TemperatureLogger
는 외부에서 접근할 수 있는 프로퍼티들을 가지고 있고, max
프로퍼티는 외부에서 set
할 수 없도록 선언되어 있다.
class
, struct
와 동일하게 이니셜라이저 구문을 사용하여 actor
의 인스턴스를 생성할 수 있다. 하지만 프로퍼티나 메소드에 동일하게 접근한다면 컴파일 에러가 발생할 것이다.
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(logger.max) // Error
actor
의 프로퍼티나 메소드에 접근할 때, await
키워드를 사용하여 접근해야한다.
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
actor
의 프로퍼티는 actor
의 "isolated local state"의 일부이기 때문에 actor
에 await
키워드 없이 접근하는 것은 에러가 발생한다.(사실 뭔 개소리인지 모르겠다.) Swift는 actor
의 내부 코드만이 actor
의 local state에 접근할 수 있도록 보장하는데 이를 actor isolation
이라고 한다.
actor
는 한 번에 하나의 mutable한 상태의 작업에 접근할 수 있으므로, 외부의 다른 작업이 먼저 logger
와 상호작용하고 있다면 그 상호작용이 끝날 때까지 기다려야하므로 await
키워드를 사용해야한다.
그렇다고 actor
자체가 자신의 내부 프로퍼티에 접근할 때 await
키워드를 작성하는 것은 아니다.
아래의 코드는 TemperatureLogger
를 새로운 온도로 업데이트하는 함수이다.
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
update(with:)
함수는 왜 actor
는 한 번에 하나의 작업에만 접근하여 상호작용할 수 있는지에 대한 이유를 보여준다.
actor
의 상태를 업데이트하는 것은 불변성을 깨트리기 때문이다.
TemperatureLogger
는 max
와 measurements
를 추적하면서 새로운 measurement
가 append 되었을 때, max
값을 업데이트한다. 만약 max
값을 업데이트 도중의 아주 찰나의 시간에 새로운 measurement
값이 추가된다면 TemperatureLogger
는 일관성이 없는 상태가 되버린다.
여러 작업이 같은 인스턴스에 접근하여 상호작용하는 것을 막는다면 아래와 같은 문제를 방지할 수 있다.
update(with:)
함수를 호출하여measurements
배열을 업데이트한다.- 1번에서
measurements
배열을 업데이트하려는 찰나에 (max
값을 업데이트하기 전에) 외부에서max
,measurements
값을 읽는다.max
값을 업데이트한다.
이 경우, 외부에서 2번처럼 접근한다면 잘못된 데이터를 읽게 된다. 그 잠깐의 찰나의 데이터가 잘못되었을 때 접근했기 때문이다.
actor
는 한 번에 하나의 상태에 대한 작업에 접근하는 것을 허용하기 때문에, 그리고 await
키워드가 있는 라인은 실행을 중단할 수 있기때문에 이 문제를 방지할 수 있다.