Swift 는 5.5 부터는 built in 방식으로 비동기 코드 병렬 코드를 지원합니다.
비동기 프로그램이 한번에 실행하는 코드 모음이라고 해도, 실행이 연기되고 나중에재개 될 수 있습니다.
비동기로 작성된 코드는 프로그램으로 하여금 네트워킹이나, 파일의 파싱같이 오래걸리는 작업의 수행 도중 UI 를 업데이트하는 단기 작업을 수행할 수 있도록 해줍니다.
병렬코드는 동시에 실행되는 코드 조각들을 의미합니다. 예를 들면 네개의 코어 프로세서를 보유한 컴퓨터는 동시에 4개의 코드를 실행할 수 있습니다. 병렬, 비동기 코드를 사용하는 프로그램은 동시에 여러개의 작업을 수행합니다. 이러한 프로그램은 외부 시스템을 기다리도록 동작을 지연시키며, 이러한 코드를 memory safe 한 방식으로 작성할 수 있게 도와줍니다..
비동기 코드로 인해 필요한 추가적인 스케줄링은 복잡성을 증가시킵니다.
Swift 언어로 비동기 코드를 작성하면 개발자의 의도를 표현할 수 있고 이에 따라 Swift 가 컴파일 타임에서 이러한 의도를 파악할 수 있게 합니다.
예를 들어 Actor 를 사용하면 변경가능한 상태에 안전하게 접근할 수 있습니다.
그러나, 느리고 버그가 많은 코드를 비동기화 하는 것이 빠르고 정확함을 보장하는 것은 아닙니다.
사실 비동기 코드는 코드를 디버깅하기 어렵게 만듭니다.
그러나 Swift 의 언어 수준의 지원을 활용하면 Swift 가 컴파일 타임에 문제를 잡아줍니다.
비동기, 병렬 코드를 동시성이라는 용어로 대체하여 설명하겠습니다.
Swift 5.5 이전에 동시성 코드를 작성한 경험이 있다면 스레드 작업에 익숙할 텐데요, Swift 의 동시성 모들은 스레드 위에 구축되지만 직접 상호 작용 하는 것은 아닙니다. Swift 의 비동기 함수는 실행 중인 스레드를 보류할 수도 있습니다. 그렇게 되면 첫번째 함수가 보류되는 동안 해당 스레드에서 다른 비동기 함수를 실행할 수 있게 됩니다.
비록 Swift 의 언어수준의 지원을 사용하지 않고 동시성 코드를 작성할 수 있지만, 가독성이 떨어집니다.
아래는 사진의 이름 목록을 다운로드하는 코드입니다.
리스트의 첫번째 사진을 다운로드하고, 그 사진을 사용자에게 보여줍니다.
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in
show(photo)
}
}
Swift
복사
이와 같이 단순한 경우에도, completion 핸들러의 연속사용에 의해 결국 중첩 클로져를 사용하게 됩니다.
이 방식이라면, 코드가 더 복잡해진다면 중첩은 더 심해지고 가독성은 더 떨어질 것입니다.
Defiinig and Calling Asynchronous Functions
1. 네트워킹 관련 코드 > 비동기 실행 필요
2. 비동기적으로 실행하고 나니 다음 단계를 진행하려면 앞의 결과가 필요
그렇다고 동기 실행하기에는 스레드가 블락됨. 비동기 실행을 하기는 해야함.
3. completion 핸들러를 마구잡이로 사용하게 됨
4. 코드의 가독성이 매우 떨어짐
5. async await 등장
즉, async 함수, 메서드와 await 은 completion 핸들러의 대체품이다.
비동기 함수 또는 비동기 메서드는 특별히 실행 도중 보류될 수 잇습니다.
동기 함수와 메서드는 실행 완료, 에러 발생, return 않음의 상태로만 분류 됩니다만,
비동기 함수와 메서드의 경우에는 실행도중 무언가를 기다리고자 할 때 “일시정지” 할 수 있습니다
비동기 함수, 메서드 내부에, 실행이 일시정지 될 지점을 표시하면 됩니다.
함수 또는 메서드가 비동기적이라는 것을 나타내기 위해서는 async 키워드를 파라미터 뒤에 작성합니다.
이는 throws 키워드의 작성법과 동일합니다.
함수 또는 메서드가 값을 리턴한다면, async 키워드를 → 화살표 이전에 작성하면 됩니다.
아래는 갤러리 내부의 사진들의 이름을 불러오는 비동기 함수입니다.
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
Swift
복사
비동기 메서드를 호출할 때 실행은 메서드가 리턴할 때 까지 연기됩니다.
await 을 일시정지가 발생할 수 있는 모든 지점에 표시해놓으면 됩니다.
이는 try 문으로 throwing 함수를 호출하는 것과 비슷합니다.
비동기 메서드의 내부에서의 실행의 흐름은 오직 다른 비동기 메서드가 호출 되어을 경우에만 일시정지 됩니다.
이러한 일시정지는 절대 암시적이거나, 선점적(양보하지 않음)이지 않습니다.
선점적이다 : 어떤 스레드가 실행하고 있다면 완료될 때 까지 다른 스레드가 실행되지 않음.
아래 코드는 갤러리 내부의 사진의 모든 이름을 불러와 첫번째 사진을 보여주는 코드입니다.
let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)
Swift
복사
listPhotos(inGallery:) 와 downloadPhoto(named:) 함수 둘 다 네트워크 요청을 필요로하기 때문에, 완수되는데 비교적 긴시간이 소요된다. async 를 사용해 두 함수를 모두 비동기적으로 만드는 것은 사진이 사용준비가 될 때 까지 앱의 다른 코드 부분이 실행되도록 허가한다.
위 예시의 동시성을 이해하기위해서 실행 순서를 살펴보자
1.
코드는 첫번째 await 을 만날 때 까지 실행된다. listPhotos(inGallery:) 함수를 호출하고 그 함수가 리턴할 때까지 실행이 연기된다.
2.
이 실행이 연기된 동안, 같은 프로그램내의 다른 동시성 코드가 실행된다. 예 : 새 사진 갤러리의 리스트를 업데이트하는 장시간 백그라운드 작업. 이러한 코드는 다음 suspension 지점까지 실행된다.
3.
listPhotos(inGallery: ) 의 리턴 후, 연기되었던 지점 부터 다시 실행된다. photoNames 에 리턴된 값을 할당한다.
4.
sortedNames 와 name 는 보통의 동기 코드이다. 이러한 코드에는 await 키워드가 없기 때문에 가능한 suspension 지점이 없다
5.
다음 await은 downloadPhoto(named:) 를 호출한다. 함수가 리턴될 때까지 실행이 일시정지되며 다른 동시성 코드에게 실행될 기회를 준다.
6.
downloadPhoto(named:) 가 리턴하고 나면, 리턴된 값은 photo 에 할당되고 이는 show() 의 인자로 전달된다.
await 으로 표시된 suspension 포인트는 비동기 함수 또는 메서드가 리턴될 때까지 현재 코드의 실행이 일시정지 될 수 있다는 것을 의미한다. 이를 yeilding the thread 라고 부른다. Swift 는 현 스레드에서의 코드 실행을 suspend 하고 그 스레드에서 다른 코드를 실행한다. await 이 달린 코드는 실행이 suspend 될 수 있어야하므로, 프로그램내의 특정 지점에서만 비동기 함수 또는 메서드를 호출할 수 있다.
•
비동기 함수, 메서드, 프로퍼티의 body
•
클래스, 구조체, 열거형의 @main 이 표시된 static main() 메서드 내부
•
이번 글의 이후에 나올 Unstructed Concurrency에서 보게 될 하위 작업의 코드
Note : Task.sleep(_:) 메서드는 동시성 작동방식을 배우기 위해 간단한 코드를 작성할 때 유용합니다. 이 메서드는 아무 작업도 수행하지 않고 그냥 시간을 기다리는 메서드입니다. 네트워크 작업을 시뮬레이션하는 등의 실험을 해보고 싶을 때 이 메서드를 사용하면 됩니다.
func listPhotos(inGallery name: String) async throws -> [String] {
try await Task.sleep(nanoseconds: 2 * 1_000_000_000) // Two seconds
return ["IMG001", "IMG99", "IMG0404"]
}
Swift
복사
Asynchronous Sequence
이전 섹션의 listPhotos(inGallery:) 함수는 비동기적으로 전체 어레이를 한번에 리턴했습니다. 그 후 모든 어레이의 요소가 사용준비가 완료됩니다. asynchronous sequence 를 사용해 비동기 작업 도중 컬렉션내의 하나의 요소에 접근하는 방식도 있습니다.
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
Swift
복사
일반적인 for 문을 사용하는 대신, 예제에서는 for 을 await 과 함께 작성하였습니다.
비동기 함수와 메서드를 호출할때 처럼, await 을 for 과 함께사용하는 것은 suspension 이 가능한 지점임을 나타냅니다. for-await-in 루프는 다음 요소가 사용 가능해질 때까지, 각 순회의 첫번째 지점에서 실행이 잠재적으로 suspend 됩니다.
이러한 방식을 Sequence 프로토콜을 준수하는 커스텀 타입에 대해 for in 루프를 사용할 수 있는 것 처럼, 커스텀 타입에 AsyncSequenceprotocol 을 채택함으로써 for-await-in 루프를 사용할 수 있습니다.
Calling Asynchronous Functions in Parallel
await 과 함께 비동기 함수를 실행하는 것은 한번에 하나의 코드만을 실행합니다. 비동기 코드가 실행되는 동안, caller 는 해당 코드가 끝날때까지 기다립니다. 예시로 갤러리로 부터 3개의 사진을 불러오려면, 세 번의 await 과 함께 downloadPhoto(named:) 함수를 할 수 있겠습니다.
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)
Swift
복사
이러한 접근에는 중요한 문제점이 존재합니다.
다운로드 자체는 비동기적으로 실행되여 다른 작업이 실행될 수 있도록 허가하지만, 한번에 하나의 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)
Swift
복사
downloadPhoto(named:) 함수는 여타 이전의 호출된 건의 완수여부와는 상관없이 호출됩니다.
충분한 시스템 자원이 있을 경우, 호출된 함수들은 동시에 실행될 수 있습니다.
모든 함수의 호출에 await 을 작성하지 않았기에 코드의 실행이 suspend 될 수 있는 지점이 없습니다.
대신, photos 가 정의된 line 까지 실행을 계속하며, 이 line 에서는 앞선 비동기적 호출들의 결과를 필요로 합니다. 따라서 await 을 작성해 세개의 사진이 모두 다운로드 될 때까지 실행을 일시정지합니다.
await
비동기 함수의 결과가 다음 코드의 실행에 필요한 경우 사용
async - let
비동기 함수의 결과가 다음 코드의 실행에 필요하지 않는 경우 사용.
→ 병렬 작업 형성
•
await, async-let 모두 코드의 실행이 suspend 될 수 있음을 의미
•
두 경우 모두, await 을 사용해 비동기 함수가 리턴될 때까지 실행을 일시정지될 수 있는 지점을 표시
Task and Task Groups
작업을 그룹단위로 묶어서 부모 자식 관계를 명확히 하면
- 작업들 간의 우선순위 처리가 쉬워지고
- Swift 가 오류를 감지하기 쉽게 해준다.
task(작업)이란 프로그램의 한 부분으로서 비동기적으로 실행될 수 있는 작업의 단위이다.
모든 비동기 코드는 task 의 일부분으로서 실행된다.
async - let 구문은 child task 를 생성한다.
또한 task group 을 생성하여 child task 를 group 에 추가할 수 있다.
task group을 사용하면 우선순위 및 취소를 더 쉽게 제어할 수 있으며, 동적인 수의 task 를 생성할 수 있다.
Task 들은 계층구조로 정렬된다. task 그룹의 각각의 task 들은 같은 parent task 를 가지며, 각각은 child task 들을 갖는다. task 와 task 그룹간의 관계 때문에, 이러한 접근을 structured concurrency ( 구조화된 동시성 ) 이라고 부른다. 개발자는 정확성에 대한 책임을 일부 떠안게 되지만, task 들의 분명한 부모 자식 관계는 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) }
}
}
Swift
복사
Unstructured Concurrency
앞선 파트에서의 동시성에 대한 구조화된 접근에 추가적으로, Swift 는 unstructured Concurrency(비구조화된 동시성) 또한 지원합니다.
task 그룹에 속한 task 와는 달리, 비구조화된 task는 부모 task 를 갖지 않습니다.
비구조화된 task 는 프로그램에 필요한대로 완전 유연하게 관리할 수 있습니다.
하지만 개발자가 모든 작업의 정확성에 대한 책임을 떠안게됩니다.
두 작업 모두 task 와 상호작용이 가능한 task handle 을 리턴합니다.
let newPhoto = // ... some photo data ...
let handle = Task {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
Swift
복사
Task Cancellation
Swift 의 동시성은 cooperative cancellation 모델을 사용합니다. 각각의 task 들은 자신의 실행이 적절한 지점에서 취소되었는지를 체크하여, 적절한 방식으로 취소에 대응합니다.
•
CanceallationError 와 같은 에러의 throw
•
nil 또는 빈 컬렉션의 반환
•
부분적으로 완료된 작업을 반환
취소를 체크하기위해 Task.checkCancellation() 를 호출하거나 Task.isCancelled 의 값을 점검하고, 코드에서 적절하게 처리하세요. 예시로, 갤러리로 부터 사진을 다운받는 작업은 부분적으로 다운로드된 파일을 제거해야하거나, 네트워그 연결을 종료해야할 필요가 있을 수 있습니다.
Actors
Actor : 한번에 하나의 task 에서만 접근이 가능한 Reference type
•
코드의 안정성 up
클래스 처럼 actor 도 참조 타입입니다.
클래스와 다르게 한번에 하나의 작업에게만 mutable state 로의 접근을 허가합니다.
한번에 하나의 작업에게만 접근을 허가하는 것은 여러 작업이 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
}
}
Swift
복사
•
actor 키워드를 사용해 정의한다.
•
생성자 사용방식이 class 와동일하다.
•
코드의 다른부분이 해당 actor 에 접근 중이면, 해당 actor 에 대한 다른 접근들은 모두 suspend 된다.
•
따라서 접근시 await 키워드를 사용해야한다.
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
Swift
복사
위 예시에서 logger.max 는 suspend 될 수 있는 지점이다.
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
Swift
복사
update(with:) 메서드는 이미 actor 에서 실행중이다. 따라서 max 프로퍼티의 접근에 await 을 사용하지 않는다.
위 메서드가 바로 actor 가 필요한 이유를 설명해준다.
아래의 상황을 생각해보자.
1.
코드의 한 부분에서 update(with:) 메서드가 호출되어 mesaurements 어레이를 업데이트한다.
2.
max 프로퍼티가 업데이트 되기전 코드의 다른 부분에서 max 프로퍼티를 읽어간다.
3.
update(with:) 의 메서드 마지막 부분에서 max 프로퍼티가 업데이트된다
이 경우, 잘못된 maximum 정보를 읽어가게된다
위와 같은 경우 다른 곳에서 실행 중인 코드는 부정확한 정보에 접근하게 됩니다. Swift Actor를 사용하면 이러한 문제를 방지할 수 있습니다. Actor는 한 시점에 하나의 작업만 허용하고 해당 코드는 await로 정지 가능한 지점을 표시하기 때문입니다. update(with:)는 중단 지점을 포함하지 않기 때문에 다른 코드는 업데이트 도중 데이터에 접근할 수 없습니다.
클래스의 인스턴스에서와 같이 Actor 외부에서 이러한 프로퍼티에 접근하려고 하면 아래와 같이 컴파일 오류가 발생합니다.
print(logger.max) // Error
Swift
복사
logger.max에 await 없이 접근하면 오류가 발생하는 이유는 actor의 프로퍼티는 actor의 독립 상태의 일부이기 때문입니다. Swift는 actor 내분의 코드만 actor의 로컬 상태에 접근 할 수 있도록 보장합니다. 이러한 보장을 actor isolation이라고 합니다.
추가 설명
일반적인 함수를 호출하게 되면 그 스레드는 선점되며 양보하지 않음.
즉 네트워킹을 하는 동안 이 스레드는 네트워킹이 끝날 때까지 다른 작업을 수행하지 못함
반면 async 함수는 함수의 실행의 통제권을 시스템에게 넘겨주는 것임
즉, 중간에 시스템에 의해 일시정지(suspend) 될 수 있음.
쓰레드에게 컨트롤을 넘겨주게되고 async 함수보다 더 중요한 작업이 생기면 async 함수를 suspend 하고 더 중요한일을 먼저한 후 다시 async 함수를 resume 함.
async 함수가 finish 되면 컨트롤이 시스템에서 다시 해당 function 으로 넘어옴.
즉 await 키워드란 이 함수를 실행하긴 할 건데, 더 급한일 있으면 그거 도중에 잠깐 하고 와도 된다는 의미로 받아들여집니다.