Swift Concurrency
Swift는 구조화된 방식으로 비동기 및 동시성 코드 작성을 지원합니다.
프로그램 내 한번에 하나의 코드만 수행되지만 비동기 코드는 일시 정지 및 재개가 가능합니다.
일시 정지 및 재개하는 코드는 UI 업데이트와 같은 단기 작업을 계속 진행하는 동시에 네트워크를 통해 데이터를 가져오거나 데이터를 파싱하는 등의 장시간 실행 작업을 계속 수행할 수 있게 됩니다.
동시성 코드는 여러 조각의 코드를 동시에 수행함을 의미합니다.
예를 들면, 4개의 코어를 가지는 컴퓨터가 4개의 코드를 동시에 수행하듯 각각의 코어들이 각각의 작업들을 동시에 수행하게 됩니다.
프로그램은 동시성과 비동기 코드를 통해 여러 작업들을 동시에 수행합니다.
동시성 또는 비동기 코드로부터 오는 스케줄링에 대한 유연성은 복잡도를 증가시킵니다.
이제 예제 코드를 보도록 할게요.
아래 코드는 사진 이름들을 다운로드 받고 그 리스트의 첫 번째 사진을 다운로드받아 유저에게 보여주는 코드입니다.
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in
show(photo)
}
}
이렇게 간단한 코드임에도 불구하고 중첩된 completion handler로 코드를 읽기가 편하지는 않습니다.
더 복잡한 코드를 작성하다보면 중첩의 깊이가 더 깊어질수 있고 더 깊어질수록 코드의 유지보수가 어려워집니다.
비동기 코드 정의 및 호출
비동기 함수는 실행 도중 일시 정지 될 수 있습니다.
일반적인 동기 함수와는 대조되는 점입니다.
요청에 대한 결과를 받아오기 까지 다소 시간이 걸리는 작업의 경우 이러한 비동기 특성을 이용해 비동기 코드 내에서 이를 제어할 수 있습니다.
비동기 함수를 정의하기 위해서는 async
키워드를 사용합니다.
위에서 completion handler를 통해 처리했던 리스트 포토를 다운로드 받는 코드를 async
코드를 통해 재작성 해볼게요.
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... 비동기 네트워킹 코드
return result
}
비동기 함수를 호출할때, 호출한 함수가 리턴을 하기 이전까지 실행이 일시 정지됩니다.
이러한 경우 await
키워드를 통해 잠재적 일시 중단점임을 마킹합니다.
갤러리에서 사진 이름들을 가져오고 첫번째 사진을 다운로드 받아 보여주는 위의 코드를 async/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
키워드가 붙은 이유는 두 함수 내부에서 비동기 코드가 수행되기에 일시적 중단이 될 수 있음을 마킹하기 위함입니다.
listPhotos(inGalllery:)
함수를 호출하고 해당 함수가 결과를 받아오기 이전까지 실행이 중단됩니다.
이 코드의 실행이 일시 중단되는 동안 프로그램 내 다른 동시성 코드가 실행됩니다.
이후 앞서 요청한 비동기 작업의 결과가 반환되면 해당 코드의 다음 부분부터 이어서 코드 수행이 재개됩니다.
await
키워드는 async
한 작업을 모두 수행하고 그 결과를 받아오기 까지 잠재적으로 코드 실행이 일시 중단이 될 수 있음을 의미합니다.
Swift는 현재 스레드에서의 코드 실행을 일시 정지하는 대신 현재 스레드에서 다른 코드를 수행하기 때문에 이러한 동작을 스레드 양보(yielding the thread)
라고도 부릅니다.
비동기 실행 시퀀스
위에서 살펴보았던 listPhotos(inGallery:)
함수는 비동기로 동작하며 배열 내 모든 요소가 준비되면 배열 전체를 한번에 반환합니다.
다른 방식으로 접근한다면 비동기 시퀀스를 이용하여 한 번에 하나의 컬렉션 요소를 반환받는 방법을 반복하는 방식이 있으며 아래와 같이 수행합니다.
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
일반적으로 사용하던 for-in
구문에 await
키워드를 적용하는 방식입니다.
for
루프의 매 이터레이션에서 await
키워드를 만나 코드 수행이 잠재적 일시 중단이 될 수 있습니다.
for-await-in
의 경우 Sequence
프로토콜 대신 AsyncSequence
프로토콜을 통해 제공할수 있습니다.
동시적으로 비동기 코드 호출하기
await
키워드를 통해 비동기 함수 호출할 경우 한번에 하나의 코드만 수행합니다.
함수를 호출한 입장에서는 async
동작이 완료된 이후에 다음 코드 수행으로 넘어가게 됩니다.
아래 예시의 경우 3번의 순차적인 함수 호출 결과를 반환받기를 기다려야 합니다.
let firstPhoto = await downloadPhtoo(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:)
호출은 한 번에 하나씩만 수행됩니다.
즉 각각의 사진 다운로드는 앞서 시작한 다운로드가 완료된 이후 수행이 시작됩니다.
각각의 사진 다운로드는 독립적이기에 동시에 수행해도 무방하며 이러한 경우 위처럼 기다릴 필요가 없습니다.
비동기 함수를 동시적으로 호출하기 위해서는 async
키워드를 상수 앞에 기재하여 아래와 같이 작성합니다.
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(named:)
가 별도의 기다림 없이 각각 독립적으로 동시에 실행됩니다.
함수의 결과를 기다린 이후 다음 코드를 수행할 필요가 없기 때문에 함수 호출 코드들을 보면 await
키워드가 사용되지 않은것을 볼 수 있습니다.
이 두 가지 접근 방식의 차이점은 아래와 같습니다.
- 함수 호출 라인 이후의 코드가 해당 함수의 결과에 따라 달라지는 경우
await
키워드를 사용하여 비동기 함수를 호출합니다. 이러한 경우 순차적으로 작업이 생성됩니다. - 함수 호출 라인 이후의 코드가 해당 함수의 결과가 필요하지 않은 경우
async-let
을 통해 비동기 함수를 호출합니다. 이러한 경우 코드 작업들을 동시적으로 수행할 수 있습니다.
Task 와 Task Groups
task
란 비동기적으로 수행할 수 있는 작업 단위를 의미합니다.
모든 비동기 코드는 일부 task
의 일부로 실행됩니다.
위에서 살펴보았던 async-let
의 경우 하위 task
생성합니다.
task group
을 생성하고 해당 그룹의 하위에 task
를 추가할 수 있으며 우선순위와 취소를 통해 이 task
들을 제어할수 있고 동적으로 task
를 생성하여 추가할수도 있습니다.
task
는 계층 구조로 정렬되며 이러한 접근 구조를 structured concurrency
라고 부릅니다.
task group
내 각각의 task
들은 동일한 부모 task
를 가지며 모든 task
는 자식 task
들을 가질 수 있습니다.
task
간 명시적 부모-자식 관계를 통해 Swift는 취소 전파와 같은 일부 동작을 처리하고 컴파일 시간에 일부 오류를 감지할 수 있도록 합니다.
await withTaskGroup(of: Data.self) { taskGroup in
let photoNames = await listPhotos(inGallery: "Summer Vacation")
for name in photoNames {
taskGroup.addTask { await downlodPhoto(named: name) }
}
}
withTaskGroup(of:)
함수를 통해 시간이 소요되는 비동기 작업(task) 들을 모두 수행할때 까지 await
한 이후 그 결과물로 이어 작업을 할 수 있습니다.
Unstructured Concurrency
Swift는 위에서 살펴본 structured concurrency
방식 외에도 unstructured concurrency
방식 또한 지원합니다.
unstructured concurrency
의 경우 부모 task
를 가지지 않습니다.
이를 생성하기 위해서는 Task.init(priority:operation:)
생성자를 사용하며 아래와 같이 사용합니다.
let newPhoto = // some photo data
let handle = Task {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
Actors
task
는 서로 격리되어 있기에 동시 실행시에도 안전한 작업 수행이 가능합니다.
그러나 개발을 하다 보면 종종 task
간 데이터 공유가 필요한 경우가 있으며 이러한 경우 Actor
를 통해 동시성 코드 내에서 안전한 데이터 공유를 수행합니다.
class
와 같이 actor
또한 참조 타입이지만 actor
의 경우 한번에 하나의 task
만이 가변 상태에 접근이 가능하도록 합니다.
이를 통해 하나의 액터 인스턴스와 여러 task
간의 안전한 인터랙션을 가능하도록 합니다.
예시 코드는 아래와 같습니다.
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
}
}
TemperatureLogger
액터에는 액터 외부의 다른 코드가 접근할 수 없는 속성들이 있으며 액터 내부에서만 max
값을 업데이트 할 수 있도록 프로퍼티의 속성을 제한합니다.
let logger = TemperatureLogger(label: "Outdoors", measurements: 25)
print(await logger.max)
actor
는 한번에 하나의 task
만이 가변 상태에 접근할 수 있도록 제어하기에 await
키워드를 통해 접근합니다.
logger.max
에 대한 접근의 경우 await
키워드를 통해 접근하며 이는 잠재적으로 일시 중단이 될 수 있습니다.
반대로 actor
에서 actor
내 속성에 접근할때에는 await
을 통하지 않습니다.
아래 예시는 TemperatureLogger
를 업데이트 하는 함수입니다.
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
update(with:)
메소드는 actor
위에서 동작하기에 await
키워드를 통해 max 프로퍼티에 접근하지 않습니다.
멀티 스레드 상황에서 read / write 가 동시에 수행되어 Data Race가 발생할 수 있는 상황을 actor
를 통해 해결할 수 있습니다.
Sendable Types
task
또는 actor
의 인스턴스 내부에서 변수 및 속성과 같은 변경 가능한 상태를 포함하는 부분을 동시성 도메인 이라고 부르며 actor
를 통해 동시성 도메인에서 안전하게 작업이 가능합니다.
하나의 동시성 도메인에서 다른 동시성 도메인으로 공유할 수 있는 유형을 sendable type
이라고 부릅니다.
예를 들어, actor
의 메서드 호출시 전달하는 인자 또는 task
의 결과로 반환되는 결과등이 있습니다.
모든 actor
는 가변 상태에 대한 isolation
을 보장하기 때문에 암묵적으로 Sendable
을 채택하고 있습니다.
동시성 도메인들간에 안전하게 데이터를 공유하기 위해서는 여러 task
간 Data Race 발생 없이 안전한 접근 및 변경 보장이 필요하며 Swift에서는 Sendable
프로토콜, @Sendable
속성을 활용합니다.
참조
'iOS' 카테고리의 다른 글
[iOS] @inlinable (0) | 2023.08.08 |
---|---|
[iOS] Compositional Layout 활용 예시 (0) | 2023.05.28 |
[iOS] Singleton 사용시 주의점과 의존성 주입 (0) | 2023.05.24 |
[iOS] Protocol Composition과 Default Implementation 활용하기 (0) | 2023.05.22 |
[iOS] RunLoop (0) | 2023.05.20 |