
(※ hackingWithSwift의 글을 번역한 것으로 아래 출처를 남겨두었습니다. 약간의 오역이 있을 수 있으니 지적해주시면 감사드리겠습니다.)
async let을 사용해서 child task를 생성하고 완료될 때까지 기다리며, 리턴 타입이 다른 task group을 다룰 때 유용하다.
struct UserData {
let username: String
let friends: [String]
let highScores: [Int]
}
func getUser() async -> String {
"Taylor Swift"
}
func getHighScores() async -> [Int] {
[42, 23, 16, 15, 8, 4]
}
func getFriends() async -> [String] {
["Eric", "Maeve", "Otis"]
}
func printUserDetails() async {
async let username = getUser()
async let scores = getHighScores()
async let friends = getFriends()
let user = await UserData(name: username, friends: friends, highScores: scores)
print("Hello, my name is \(user.name), and I have \(user.friends.count) friends!")
}
예제 코드를 보면 각 함수의 리턴 타입이 다르다. 세 함수는 동시에 수행되며, 모두 완료될 때까지 기다린 후에 객체를 생성한다.
중요한 것은, 이미 비동기 상황에 있을 때만 async let을 사용할 수 있다는 것이며, await를 명시해주지 않으면 swift는 암묵적으로 결과를 계속 기다릴 것이다! 즉, 비동기일 때만 async let을 사용해야 하며 await를 반드시 명시해줄 것
async let을 사용하면 자동으로 우리가 결과를 기다리고 있는 지점으로 돌아가기 때문에, try를 사용할 필요가 없다고 한다. await가 내재돼 있어서 async let을 사용할 때에는
try await someFunction()을 안쓰고 someFunction()으로만 써도 된다는 것이다.
enum NumberError: Error {
case outOfRange
}
func fibonacci(of number: Int) async throws -> Int {
if number < 0 || number > 22 {
throw NumberError.outOfRange
}
if number < 2 { return number }
async let first = fibonacci(of: number - 2)
async let second = fibonacci(of: number - 1)
return try await first + second
}
위 코드를 보면 async let first = fibonacci(of: number - 2)의 fibonacci 앞에 try await가 생략된 것을 볼 수 있다.
우리는 앞에서 completion handler라는 복잡한 구문을 async/await 단어로 간단하게 표현했었다. 하지만 항상 가능한 것은 아니다. ex. 외부라이브러리부터 오는 경우
Continuations는 completion handler와 비동기 함수 사이에 shim을 생성하는 것을 가능하게 해서 우리는 좀 더 모던한 API에 낡은 코드를 감쌀 수 있는 것이다.
withCheckedContinuation()로 어디에서든 새로운 continuation을 생성하고 resume(returning:)로 준비가 되면 언제든 값을 받을 수 있다. 심지어 completion handler 안에서도 사용 가능하다.
func fetchLatestNews() async -> [String] {
await withCheckedContinuation { continuation in
fetchLatestNews { items in
continuation.resume(returning: items)
}
}
}
위에서 언급했듯이 swift가 우리의 continuation을 체크하기 위해 런타임 성능 면에서 비용이 든다. 이번엔 반대로 런타임 체크를 수행하지 않는 withUnsafeContinuation()가 있다. 따라서, 우리가 resume()을 부르지 않든 2번 부르든 경고를 주지 않는다.
따라서, 상황에 따라 withCheckedContinuation()와 withUnsafeContinuation()를 번갈아가면서 쓰기 쉽다
정리
withCheckedContinuation(): 우리가 함수를 작성할 때 continuation이 올바르지 않으면 경고를 나타냄
withUnsafeContinuation() : continuataion을 체크하는 런타임 성능 소모가 영향을 미치는 경우
동시성 환경에서 사용하기 안전한 클래스와 개념적으로 유사하다. 동시성 환경에서 안전한 이유는 swift가 actor내에 변할 수 있는 state가 오로지 주어진 시간 내에 싱글스레드에 의해 접근되기 때문이다. 이것은 컴파일 레벨에서 여러 심각한 버그들을 제거하는 데 좋다.
예시를 통해 Actor가 문제를 어떻게 해결하는지 살펴보자.
class RiskyCollector {
var deck: Set<String>
init(deck: Set<String>) {
self.deck = deck
}
func send(card selected: String, to person: RiskyCollector) -> Bool {
guard deck.contains(selected) else { return false }
deck.remove(selected)
person.transfer(card: selected)
return true
}
func transfer(card: String) {
deck.insert(card)
}
}
이 코드가 싱글스레드에서는 안전하다. 상대방이 물어본 카드가 나에게 있으면 상대방에게 넘겨주면 된다. 하지만 멀티스레드 환경에서 race condition(경쟁 상태)의 위험성이 존재한다.
코드가 둘로 분리되어 나란히 실행될 경우 코드의 결과는 달라지는 문제가 생긴다. 예를 들면, send(card:to:) 함수가 2개의 스레드에서 동시에 실행되고 있다면
1. 스레드1에서 해당 카드가 있는지 확인한다. -> 있다.
2. 스레드2에서 해당 카드가 있는지 확인한다 -> 있다.
3. 스레드1에서 카드를 상대방에게 넘겨준다. -> 상대방 카드+1
4. 스레드2에서 카드를 상대방에게 넘겨준다. -> 상대방 카드+2
결과적으로, 나는 카드는 1개를 잃고 상대방은 2개의 카드를 얻게 된다.
이 문제를 actor isolation을 통해 해결할 수 있다!!
어떻게 해결하는가??
1. 저장된 프로퍼티와 메소드가 비동기적으로 수행되지 않는 한, actor 객체 밖에서 읽을 수 없게 한다.
2. 그리고 저장된 프로퍼티는 actor 객체 밖에서 절대로 수정될 수 없게 한다.
문제 해결에는 비동기가 쓰이지 않았음에도, swift는 race condition을 피하기 위해 자동으로 위의 요청들을 큐에 넣고 순차적으로 수행한다.
따라서, 위에 함수를 actor + async/await로 다시 쓰면 다음과 같다.
actor SafeCollector {
var deck: Set<String>
init(deck: Set<String>) {
self.deck = deck
}
func send(card selected: String, to person: SafeCollector) async -> Bool {
guard deck.contains(selected) else { return false }
deck.remove(selected)
await person.transfer(card: selected)
return true
}
func transfer(card: String) {
deck.insert(card)
}
}
따라서, actor-isolated state는 멀티스레드 환경에서 동시에 접근불가능하게 만들 뿐만 아니라 컴파일 시간에 완료되기 때문에 멀티스레드 환경에서 좀 더 안전하게 만들어주는 타입이다. 비동기든 아니든 actor내의 프로퍼티와 메소드를 자유롭게 사용할 수 있지만, 다른 actor와 엮이게 되면 반드시 비동기적으로 수행해야 한다.
둘의 공통점으로는,
중요한 차이점은,
1. Actor는 현재 상속을 지원하고 있지 않다.
상속을 지원하지 않는다는 것은 그들의 생성자를 좀 더 간단하게 만들어준다. convenience initializer(편의생성자), overriding, final이라는 키워드 등이 필요없다. (추후에 바뀔지도)
2. 모든 actor는 암묵적으로 새로운 Actor 프로토콜을 따른다.
바로 init할 수 있는 다른 concrete type은 이렇지 않기 때문에 actor는 actor끼리만 쓸 수 있어서 코드가 제한적이게 된다.
Global Actors를 사용함으로써 전역 상태를 data race로부터 고립/분리시킬 수 있다. 음.. 현재로서 활용 될 수 있는 것은 @MainActor를 써서 오로지 메인스레드에서만 접근해야하는 프로퍼티와 메소드를 표시한다.
class OldDataController {
func save() -> Bool {
guard Thread.isMainThread else {
return false
}
print("Saving data…")
return true
}
}
class NewDataController {
@MainActor func save() {
print("Saving data…")
}
}
Xcode의 설명을 보면 MainActor = DispatchQueue.main와 같아서 대체할 수 있다.


(출처: https://www.youtube.com/watch?v=z77Zmp9hm-c 영상)
우리는 안전상의 이유로 메인스레드에 있지 않는 한 영구적인 저장소에 수정하고 싶지 않다. 이 때 @MainActor를 써서 save() 메소드가 무조건 메인스레드에서만 호출될 수 있도록 보장하는 것이다. DispathQueue.main을 사용했던 맥락과 동일하다.
주의할 점은 우리가 actor를 통해 작업하고 있기 때문에 save()를 호출할 때에는 await나 async let 등을 사용해야 한다.
class와 비슷하지만 멀티스레드 환경에서 안전하게 처리할 수 있는 Actor와 continuation, async let 등 새로운 개념이 쏟아진다..
(출처 https://www.hackingwithswift.com/articles/233/whats-new-in-swift-5-5)