(Deep Dive) 구조적 동시성은 무엇이 구조적인가
시작하게 된 이유
이전글에서 Udemy 강의를 따라가며 async let, Task Group, Task { }, Task.detached를 한 번씩 써봤다. 그다음 글에서는 랜덤 이미지 앱에 async let과 Task Group을 적용해봤다. 그때는 강의 흐름대로 “이렇게 쓰면 동시에 돈다” 정도로 정리하고 넘어갔다.
그런데 이번 Deep Dive를 하면서 Task { }와 async let을 계속 쓰다 보니, 그때 적어둔 걸 다시 보게 됐다. (7)편 첫머리에 구조적 동시성을 async let, Task Group, Unstructured Task, Detached Task 네 가지로 정리해뒀는데, 이름부터 Unstructured(구조적이지 않은)인 게 구조적 동시성 목록에 들어가 있었다. 그러고 보니 “구조적”이 정확히 뭘 말하는지 모르고 썼던 것이다. 그래서 이번엔 정의부터 확인하고, 직접 돌려봤다.
이번에도 AI와 의논하면서 자료를 찾고 정리했다. 먼저 AI에게 Async/Await 시리즈 전체를 훑어보게 해서, 그때 이미 다룬 것과 이번에 제대로 파볼 것을 나눴다. 그다음 제안서와 공식 문서, SDK에 들어 있는 선언까지 같이 찾아보면서 “이렇게 동작할 것 같다”는 가설을 세우고, 하나씩 직접 돌려서 확인했다. AI가 말한 걸 그대로 받아 적지 않고, 결과가 예상과 다르면 왜 다른지 다시 찾아보는 식이었다.
그러다 보니 예상과 다른 결과도 나왔다. 무엇을 물려받는지 확인하는 코드를 처음 돌렸을 땐 전부 “메인 스레드가 아니다”라고 나왔는데, 확인하는 함수를 async로 만든 게 원인이었다. TaskGroup에서 자식 하나가 에러를 던지면 나머지가 다 취소될 거라고 예상했는데, 실제로는 기다리는 방법에 따라 결과가 달랐다.
1. 구조적 동시성???
SE-0304(“Structured concurrency”, Swift 5.5에서 구현)에서 생긴 개념이다. 제안서의 핵심 문장은 이거다.
A child task cannot live longer than the parent task (or scope) in which it was created.
자식 작업은 자기를 만든 부모 작업(또는 범위)보다 오래 살 수 없다는 뜻이다. 제안서는 바로 이어서, 자식 작업을 만든 함수는 그 자식이 끝날 때까지 기다린 뒤에야 돌아올 수 있다고 설명한다. 그래서 함수 하나만 보고도 지금 돌고 있는 작업이 뭔지 다 알 수 있다는 것이다.
“구조적”은 여기서 나온 말이다. 코드의 중괄호 모양과 작업의 모양이 같다. 함수 안에서 만든 자식 작업은 그 함수의 중괄호가 닫히기 전에 반드시 끝난다. 반대로 Task { }로 만든 작업은 만든 함수가 끝나도 혼자 계속 돈다. 중괄호 밖으로 삐져나가는 것이다.
그래서 (7)편의 네 가지는 이렇게 나뉜다.
| 구조적 (자식 작업) | 구조적이지 않음 |
|---|---|
async let | Task { } |
Task Group의 addTask | Task.detached |
The Swift Programming Language Docs도 Task { }와 Task.detached로 만든 작업은 Task Group의 작업과 달리 부모 작업이 없다고 설명한다. 이름 그대로 구조 밖에 있는 작업이다.
부모와 자식으로 이어져 있으면 따라오는 게 하나 더 있다. 취소다. 부모가 취소되면 자식들도 취소 표시를 받는다. 다만 SE-0304는 취소가 “그만하라”는 표시일 뿐이라고 분명히 적어둔다.
cancellation has no effect at all unless something checks for cancellation.
누군가 취소됐는지 확인하지 않으면, 취소는 아무 효과가 없다는 뜻이다. 작업이 강제로 멈춰지는 게 아니라, 작업 스스로 확인하고 멈춰야 한다.
네 가지를 하나씩 보면 이렇다.
async let
보통의 await는 그 자리에서 결과가 올 때까지 기다린다. async let은 작업을 먼저 출발시켜두고 기다리지 않은 채 다음 줄로 넘어간 뒤, 결과가 필요할 때 await로 받는다. 그래서 여러 개를 연달아 적으면 동시에 돈다. let 하나에 작업 하나라서, 작업 개수가 코드에 정해져 있을 때 쓴다.
두 작업의 시간을 일부러 다르게 했다. fetchUser는 150ms, fetchPosts는 100ms가 걸린다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import Foundation
func now() -> Double { Date().timeIntervalSince1970 * 1000 }
func fetchUser(_ start: Double) async -> String {
try? await Task.sleep(for: .milliseconds(150))
print(" fetchUser 도착: \(Int(now() - start))ms")
return "harold"
}
func fetchPosts(_ start: Double) async -> [String] {
try? await Task.sleep(for: .milliseconds(100))
print(" fetchPosts 도착: \(Int(now() - start))ms")
return ["첫 글", "두 번째 글"]
}
// 차례로 기다림: 하나가 끝나야 다음을 시작한다
func loadOneByOne(_ start: Double) async {
let user = await fetchUser(start)
let posts = await fetchPosts(start)
print(" \(user) \(posts)")
}
// async let: 두 작업을 먼저 출발시켜두고, 필요할 때 받는다
func loadTogether(_ start: Double) async {
async let user = fetchUser(start) // 여기서 바로 출발
async let posts = fetchPosts(start) // 이것도 바로 출발
print(" \(await user) \(await posts)") // 둘 다 도착할 때까지 기다림
}
@main struct Main {
static func main() async {
var start = now()
print("차례로 기다림")
await loadOneByOne(start)
print(" → \(Int(now() - start))ms")
start = now()
print("async let")
await loadTogether(start)
print(" → \(Int(now() - start))ms")
}
}
1
2
3
4
5
6
7
8
9
10
차례로 기다림
fetchUser 도착: 159ms
fetchPosts 도착: 266ms
harold ["첫 글", "두 번째 글"]
→ 266ms
async let
fetchPosts 도착: 108ms
fetchUser 도착: 159ms
harold ["첫 글", "두 번째 글"]
→ 159ms
차례로 기다리면 두 시간을 더한 266ms가 걸렸고, async let은 159ms가 걸렸다. 둘이 동시에 출발했으니 늦게 도착하는 쪽의 시간만큼만 기다린 것이다.
동시에 출발했다고 도착까지 같이 하는 건 아니다. 코드에는 user를 먼저 적었지만, 더 빨리 끝나는 posts가 먼저 도착했다. 어느 쪽이 먼저 도착할지는 정해져 있지 않고, 작업마다 걸리는 시간에 따라 달라진다.
실제로 쓴 곳은 HealthKit 글이다. 걸음 수, 체중, 체중 변화량 세 가지를 try await 세 번으로 차례로 가져오던 걸, async let 세 줄로 바꿔서 동시에 출발시켰다.
1
2
3
4
5
6
7
async let steps = hkManager.fetchStepCount()
async let weightsForLineChart = hkManager.fetchWeights(daysBack: 28)
async let weightsForDiffBarChart = hkManager.fetchWeights(daysBack: 29)
hkManager.stepData = try await steps
hkManager.weightData = try await weightsForLineChart
hkManager.weightDiffData = try await weightsForDiffBarChart
가져올 게 세 가지로 정해져 있으니 async let이 맞았다. 세 작업 중 어느 게 먼저 도착할지는 모르지만, 결과는 steps, weightsForLineChart처럼 이름에 담겨 있어서 받을 때 헷갈릴 일이 없다.
TaskGroup
작업 개수가 그때그때 달라지면 async let으로는 적을 수 없다. 이럴 때 TaskGroup을 쓴다. withTaskGroup 안에서 addTask로 작업을 필요한 만큼 넣고, for await로 끝나는 순서대로 받는다. 에러를 던지는 작업이면 withThrowingTaskGroup을 쓴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func fetchProfile(_ name: String, ms: Int) async -> String {
try? await Task.sleep(for: .milliseconds(ms))
return name
}
func loadProfiles() async -> [String] {
// (이름, 걸리는 시간)
let people = [("apple", 300), ("swiftlang", 100), ("harold", 200)]
return await withTaskGroup(of: String.self) { group in
for (name, ms) in people { // 사람 수만큼 작업을 넣는다
group.addTask { await fetchProfile(name, ms: ms) }
}
var result = [String]()
for await profile in group { // 끝나는 순서대로 받는다
result.append(profile)
}
return result
}
}
@main struct Main {
static func main() async {
print(await loadProfiles())
}
}
1
["swiftlang", "harold", "apple"]
넣은 순서는 apple, swiftlang, harold였지만, 받은 순서는 먼저 끝난 swiftlang, harold, apple이었다.
실제로 쓴 곳은 부트캠프 팀 프로젝트였던 떡볶이 지도 앱(TteoPpoKki4U)의 추천 화면이다. Firestore에서 받은 문서 수만큼 addTask로 작업을 넣고, 문서마다 이미지 주소를 바꾸는 일을 동시에 돌렸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let cards = try await withThrowingTaskGroup(of: Card?.self) { taskGroup in
for document in querySnapshot.documents {
taskGroup.addTask {
let data = document.data()
// 생략 (문서에서 제목, 설명, 이미지 주소 등을 꺼냄)
let imageURL = try await self.convertGSURLToHTTPURL(gsURL: imageURLString)
// 생략
return Card(title: title, /* 생략 */ order: order)
}
}
for try await card in taskGroup {
if let card = card {
fetchedCards.append(card)
}
}
return fetchedCards
}
DispatchQueue.main.async {
self.cards = fetchedCards.sorted(by: { $0.order < $1.order })
}
문서 수가 그때그때 달라서 TaskGroup이 맞았다. 그리고 마지막에 order로 다시 정렬하고 있는데, 지금 보면 이유가 보인다. TaskGroup은 끝나는 순서대로 받으니, 모인 카드의 순서가 매번 달라진다. 화면에 정해진 순서로 보여주려면 다시 정렬할 수밖에 없었던 것이다.
Task { }
await는 async 함수 안에서만 쓸 수 있다. 버튼 액션처럼 async가 아닌 함수에서 비동기 작업을 하려면 Task { }로 새 작업을 시작한다. 이전글에서 정리한 것처럼, Task는 기다릴 수 있는 공간을 만들어준다.
Task { }는 만든 곳의 actor를 따라간다. The Swift Programming Language Docs에도 Task로 만든 작업은 기본적으로 지금 작업과 같은 actor 격리, 우선순위, task-local 값(작업에 붙여 다니는 값)으로 돈다고 적혀 있다. SE-0304의 “Actor context propagation”도 Task에 넘긴 클로저가 만들어진 곳의 actor를 저절로 물려받는다고 설명한다. @MainActor 뷰모델 안에서 만든 Task는 MainActor에서 돌기 때문에, await 뒤에 name을 바로 바꿀 수 있다. 다만 부모가 없어서, 만든 함수가 끝나도 기다려주지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func fetchUser() async -> String {
try? await Task.sleep(for: .milliseconds(100))
return "harold"
}
@MainActor
final class ProfileViewModel {
var name = ""
func buttonTapped() { // 버튼 액션: 동기 함수라 여기서는 await를 못 쓴다
Task { // 새 작업을 시작한다 (MainActor를 따라간다)
name = await fetchUser() // 그래서 await 없이 name을 바로 바꿀 수 있다
print("받아옴: \(name)")
}
print("buttonTapped는 바로 끝남")
}
}
@main struct Main {
@MainActor static func main() async {
let vm = ProfileViewModel()
vm.buttonTapped()
try? await Task.sleep(for: .milliseconds(200))
}
}
1
2
buttonTapped는 바로 끝남
받아옴: harold
실제로 쓴 곳은 앞의 HealthKit 코드다. 뷰의 동기 함수에서 async let을 쓰려고 Task { }로 감쌌다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private func fetchHealthData() {
Task {
do {
async let steps = hkManager.fetchStepCount()
async let weightsForLineChart = hkManager.fetchWeights(daysBack: 28)
async let weightsForDiffBarChart = hkManager.fetchWeights(daysBack: 29)
hkManager.stepData = try await steps
hkManager.weightData = try await weightsForLineChart
hkManager.weightDiffData = try await weightsForDiffBarChart
} catch {
// 생략 (에러 종류에 따라 알림을 띄움)
}
}
}
fetchHealthData()는 async가 아니라서 await를 바로 쓸 수 없고, Task { }가 기다릴 수 있는 공간을 만들어준다. RunWay에서도 버튼 액션이나 onAppear에서 비동기 작업을 시작할 때 거의 다 이 모양이었다. 3번 섹션에서 볼 워치 햅틱도 Task { }다.
Task.detached
Task.detached도 새 작업을 시작하지만, 만든 곳에서 아무것도 물려받지 않는다. 같은 TSPL 문서도 detached 작업은 actor 격리 없이 돌고, 지금 작업의 우선순위나 task-local 값을 물려받지 않는다고 적어두었다. 같은 뷰모델에서 Task { }를 Task.detached로 바꾸면 MainActor 밖에서 돌기 때문에, name을 바로 바꾸려 하면 에러가 난다.
1
error: main actor-isolated property 'name' can not be mutated from a nonisolated context
바꾸려면 await MainActor.run { }으로 MainActor로 돌아가서 바꿔야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func fetchUser() async -> String {
try? await Task.sleep(for: .milliseconds(100))
return "harold"
}
@MainActor
final class ProfileViewModel {
var name = ""
func buttonTapped() {
Task.detached { // MainActor를 따라가지 않는다
let user = await fetchUser()
// self.name = user // 이렇게 바로 바꾸면 에러
await MainActor.run { // MainActor로 돌아가서 바꿔야 한다
self.name = user
print("받아옴: \(self.name)")
}
}
print("buttonTapped는 바로 끝남")
}
}
@main struct Main {
@MainActor static func main() async {
let vm = ProfileViewModel()
vm.buttonTapped()
try? await Task.sleep(for: .milliseconds(200))
}
}
1
2
buttonTapped는 바로 끝남
받아옴: harold
Task.detached Docs는 자식 작업 같은 구조적인 방법으로 할 수 있는 일이면 detached를 쓰지 말라고 적어두었다. 자식 작업은 부모의 우선순위를 물려받고 부모가 취소되면 같이 취소되는데, detached는 이걸 전부 직접 챙겨야 하기 때문이다.
정리하면 Task.detached는 만든 곳이 쓰던 스레드가 아니라, Swift가 쓰는 공용 스레드 중 하나에서 돈다. 원래 자리에서 떨어져 나가면서 따라오던 것들을 잃는데, 그중 일부는 Task { }도 마찬가지다. 둘 다 구조적이지 않은 작업이라서다.
Task { } | Task.detached | |
|---|---|---|
| 원래 소속 (MainActor) | 따라감 | 안 따라감. 상태를 바로 못 바꾸고, Swift 6에서는 컴파일러가 에러로 막는다 |
| 우선순위 | 따라감 (high) | 안 따라감 (medium) |
| 바깥 취소 | 안 받음 | 안 받음 |
| 에러 | value를 기다려야 나옴 | value를 기다려야 나옴 |
취소와 에러는 부모가 없는 작업이면 둘 다 같다. 변수에 담아두고 직접 cancel()하고, 에러를 받으려면 value를 기다려야 한다. detached만의 차이는 소속과 우선순위까지 놓는다는 것이다. 취소, 소속, 우선순위는 2번 섹션에서 확인했고, 에러는 따로 돌려봤다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Boom: Error {}
@main struct Main {
static func main() async {
// 1) value를 기다리지 않음
Task.detached { throw Boom() }
try? await Task.sleep(for: .milliseconds(50))
print("1) value를 안 기다림: 아무 일도 없이 지나감")
// 2) value를 기다림
let task = Task.detached { throw Boom() }
do {
try await task.value
} catch {
print("2) value를 기다림: \(error)를 받음")
}
}
}
1
2
1) value를 안 기다림: 아무 일도 없이 지나감
2) value를 기다림: Boom()를 받음
기다리지 않으면 에러가 그냥 사라졌다. 그래도 컴파일러가 1)의 Task.detached 줄에 “던진 에러를 무시할 수 있다”는 경고를 띄워줬다. Task { }로 바꿔도 같은 경고가 뜨고 에러가 사라졌다.
1
warning: unstructured throwing task created by 'detached(name:priority:operation:)' is not used, which may accidentally ignore errors thrown inside the task
그래서 detached는 잘 쓰지 않는다. 쓰는 경우는 원래 작업과 운명을 같이하지 않아야 하는 일이다. WWDC21 Explore structured concurrency in Swift는 서버에서 받아온 썸네일을 디스크 캐시에 저장하는 예를 든다. 메인에서 할 필요가 없고, 썸네일 가져오기가 취소되더라도 이미 받은 건 캐시에 남겨두는 게 좋은 일이라서 detached로 떼어낸다.
원래 소속에서 떨어져 나가니, actor처럼 따로 격리된 영역이 생기는 것 같은 느낌도 든다. 절반만 맞았다. MainActor와 detached 사이에 경계가 생기는 건 맞다. Sendable이 아닌 값을 두 detached에 같이 넘기면, 이전글에서 본 sending 에러가 난다.
1
2
3
4
5
6
7
final class Counter { var n = 0 } // Sendable 아님
func f() {
let c = Counter()
Task.detached { c.n += 1 }
Task.detached { c.n += 1 } // 같은 c를 또 넘김
}
1
error: sending value of non-Sendable type '() async -> ()' risks causing data races
하지만 detached가 actor처럼 안쪽을 한 번에 하나씩 지켜주는 건 아니다. 200ms 동안 붙잡는 일을 actor 메서드로 두 번 부른 것과, detached 두 개로 돌린 것을 비교했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Foundation
func now() -> Double { Date().timeIntervalSince1970 * 1000 }
actor Worker {
func busy() { usleep(200_000) } // 200ms 동안 붙잡는 일
}
func busyAnywhere() { usleep(200_000) }
@main struct Main {
static func main() async {
// 1) actor 메서드 두 번: 한 번에 하나씩
let w = Worker()
var start = now()
async let a: () = w.busy()
async let b: () = w.busy()
_ = await (a, b)
print("actor 메서드 두 번: \(Int(now() - start))ms")
// 2) Task.detached 두 개: 서로 막지 않음
start = now()
let t1 = Task.detached { busyAnywhere() }
let t2 = Task.detached { busyAnywhere() }
_ = await (t1.value, t2.value)
print("Task.detached 두 개: \(Int(now() - start))ms")
}
}
1
2
actor 메서드 두 번: 408ms
Task.detached 두 개: 204ms
actor는 한 번에 하나씩이라 두 배가 걸렸고, detached 두 개는 서로 막지 않고 같이 돌았다. detached는 새 소속을 만드는 게 아니라 소속이 없는 상태로 도는 것이다. 안전하게 쓸 수 있는 건 detached가 지켜줘서가 아니라, 값을 넘길 때 컴파일러가 검사해주기 때문이다.
실제로 쓴 곳은 찾아보니 없었다. RunWay, GitExplorer, 떡볶이 앱 어디에도 Task.detached는 없고, Udemy 강의 실습에서만 써봤다. 잘 안 쓴다는 게 내 코드에서도 그대로 드러났다.
Task { }와 Task.detached가 어느 스레드에서 도는지, detached 두 개와 actor 메서드 두 번이 어떻게 다른지를 한 번에 따라가 볼 수 있게 시뮬레이터로 만들었다.
2. 예시 코드로 확인하기
정의대로 동작하는지 세 가지를 돌려봤다. 함수가 언제 돌아오는지, 바깥이 취소되면 누가 받는지, 무엇을 물려받는지다. Swift 6 모드, Swift 6.3.3이다.
함수는 자식이 끝나야 돌아온다
async let으로 자식 작업을 만들고 await 없이 함수를 끝내봤다. 자식은 두 종류로 만들었다. 하나는 Task.sleep으로 기다려서 취소되면 바로 멈추고, 하나는 취소를 전혀 확인하지 않고 300ms 동안 그냥 돈다. 비교로 Task { }도 돌렸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import Foundation
func now() -> Double { Date().timeIntervalSince1970 * 1000 }
// 취소를 확인하는 자식 (Task.sleep은 취소되면 바로 에러를 던진다)
func polite(_ tag: String) async -> Int {
do { try await Task.sleep(for: .milliseconds(300)); print("[\(tag)] 끝까지 돎") }
catch { print("[\(tag)] 취소돼서 바로 멈춤") }
return 1
}
// 취소를 확인하지 않는 자식 (300ms 동안 그냥 돈다)
func stubborn(_ tag: String) async -> Int {
for _ in 0..<30 { usleep(10_000) }
print("[\(tag)] 끝까지 돎 (취소 표시: \(Task.isCancelled))")
return 1
}
func asyncLetPolite() async {
async let _ = polite("async let, 취소 확인함")
print("함수 마지막 줄 (await 안 함)")
}
func asyncLetStubborn() async {
async let _ = stubborn("async let, 취소 확인 안 함")
print("함수 마지막 줄 (await 안 함)")
}
func unstructured() async {
Task { _ = await polite("Task { }") }
print("함수 마지막 줄")
}
@main struct Main {
static func main() async {
let cases: [(String, () async -> Void)] = [
("1) async let + 취소 확인하는 자식", asyncLetPolite),
("2) async let + 취소 확인 안 하는 자식", asyncLetStubborn),
("3) Task { }", unstructured),
]
for (name, run) in cases {
print(name)
let start = now()
await run()
print("함수가 돌아옴: \(Int(now() - start))ms")
try? await Task.sleep(for: .milliseconds(400)) // 남은 출력 기다리기
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
1) async let + 취소 확인하는 자식
함수 마지막 줄 (await 안 함)
[async let, 취소 확인함] 취소돼서 바로 멈춤
함수가 돌아옴: 0ms
2) async let + 취소 확인 안 하는 자식
함수 마지막 줄 (await 안 함)
[async let, 취소 확인 안 함] 끝까지 돎 (취소 표시: true)
함수가 돌아옴: 374ms
3) Task { }
함수 마지막 줄
함수가 돌아옴: 0ms
[Task { }] 끝까지 돎
1번과 2번 모두 “함수 마지막 줄”이 찍힌 뒤 자식의 출력이 나오고, 그다음에야 함수가 돌아왔다. await를 안 썼는데도 함수가 끝나는 순간 자식을 취소하고 끝날 때까지 기다린 것이다. SE-0317(“async let bindings”, Swift 5.5에서 구현)에도 await 안 한 async let은 범위가 끝날 때 취소한 뒤 기다린다고 적혀 있다.
차이는 자식이 취소를 확인하느냐였다. 1번은 취소 표시를 보자마자 멈춰서 함수가 바로 돌아왔다. 2번은 취소 표시가 true로 찍혔는데도 끝까지 돌았고, 함수는 그걸 기다리느라 374ms 뒤에야 돌아왔다. SE-0304의 “확인하지 않으면 취소는 아무 효과가 없다”가 그대로 나왔다.
3번 Task { }는 반대다. 함수가 0ms에 먼저 돌아오고, 작업은 그 뒤에 혼자 끝까지 돌았다. 만든 함수가 끝나도 기다려주지 않는다.
누가 무엇을 물려받나
바깥 작업을 취소했을 때 누가 취소를 받는지, 그리고 MainActor와 우선순위를 누가 물려받는지 돌려봤다. 부모는 MainActor에서 도는 우선순위 high 작업으로 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import Foundation
// async가 아닌 함수로 만들어야 부른 쪽에서 그대로 돈다
func report(_ tag: String) {
print("[\(tag)] 메인 스레드: \(pthread_main_np() == 1), 우선순위: \(Task.currentPriority)")
}
func work(_ tag: String) async {
do { try await Task.sleep(for: .milliseconds(300)); print("[\(tag)] 취소 안 됨, 끝까지 돎") }
catch { print("[\(tag)] 취소됨") }
}
@main struct Main {
static func main() async {
// 1) 무엇을 물려받나
let parent = Task(priority: .high) { @MainActor in
report("부모 (MainActor, high)")
let t1 = Task { report("Task { }") }
let t2 = Task.detached { report("Task.detached") }
async let c: () = report("async let 자식")
await withTaskGroup(of: Void.self) { g in g.addTask { report("TaskGroup 자식") } }
await c
_ = await (t1.value, t2.value)
}
await parent.value
// 2) 바깥이 취소되면 누가 받나
let outer = Task {
async let a: () = work("async let 자식")
let inner = Task { await work("안쪽 Task { }") }
let detached = Task.detached { await work("안쪽 Task.detached") }
await withTaskGroup(of: Void.self) { g in
g.addTask { await work("TaskGroup 자식") }
}
await a
_ = (inner, detached)
}
try? await Task.sleep(for: .milliseconds(50))
print("바깥 Task를 취소")
outer.cancel()
try? await Task.sleep(for: .milliseconds(400))
}
}
report()는 지금 메인 스레드인지와 우선순위를 출력한다. 이 함수는 일부러 async로 만들지 않았다. 이전글에서 본 것처럼 nonisolated인 async 함수는 부른 쪽을 떠나서 돌기 때문에, 처음에 async로 만들었더니 전부 메인 스레드가 아니라고 나왔다.
부모를 만들 때 쓴 Task(priority: .high)의 priority는 작업의 우선순위다. TaskPriority Docs를 보면, 우선순위는 여러 작업 중 무엇을 먼저 돌릴지 정할 때 참고하는 값이다. 보통은 높은 쪽을 먼저 돌리려고 하지만, 실제로 어떻게 다룰지는 플랫폼마다 다르다고 적혀 있다. 반드시 먼저 돈다는 약속이라기보다 참고용 표시에 가깝다. 값은 이렇게 있다.
| 값 | 크기 | 같은 값 |
|---|---|---|
.high | 25 | .userInitiated |
.medium | 21 | |
.low | 17 | .utility |
.background | 9 |
여기서 굳이 .high를 준 건 물려받았는지 구분하기 위해서다. 우선순위를 따로 주지 않으면 main()도, 그 안에서 만든 Task { }도, Task.detached도 전부 medium으로 나왔다. 그러면 자식이 부모 걸 물려받은 건지, 원래 기본값인 건지 알 수가 없다. 다만 이건 Mac 커맨드라인으로 돌린 결과다. SE-0304에는 우선순위를 주지 않은 Task를 앱의 메인(UI) 스레드에서 만들면 userInitiated가 기본이 된다고 적혀 있어서, 앱에서는 기본값이 다를 수 있다. 그래서 부모만 high로 올려두고, 자식이 high로 나오는지 medium으로 나오는지를 봤다.
자식 작업 (async let, TaskGroup) | Task { } | Task.detached | |
|---|---|---|---|
| 만든 함수가 끝날 때 | 끝날 때까지 기다려줌 | 안 기다림 | 안 기다림 |
| 바깥이 취소되면 | 같이 취소 표시를 받음 | 안 받음, 끝까지 돎 | 안 받음, 끝까지 돎 |
| MainActor | 안 물려받음 (메인 스레드 아님) | 물려받음 (메인 스레드) | 안 물려받음 |
| 우선순위 | 물려받음 (high) | 물려받음 (high) | 안 물려받음 (medium) |
취소는 부모와 자식으로 이어진 쪽만 받았다. 안쪽에서 만든 Task { }와 Task.detached는 바깥이 취소돼도 모른 채 끝까지 돌았다. 부모가 없으니 취소가 전달될 길이 없다.
MainActor는 반대로 Task { }만 물려받았다. 이전글들에서 본 “Task는 만든 곳의 격리를 따라간다”가 이거다. 자식 작업은 부모와 구조로 묶여 있어도 MainActor를 물려받지 않고 따로 돌았다. SE-0317도 async let의 자식은 기본적으로 Swift가 쓰는 공용 스레드들에서 돈다고 설명한다. 동시에 돌리려고 만드는 작업이니 당연하다. 부모가 있는 MainActor에서 같이 돌면 동시에 돌 수가 없다.
여기서 헷갈리기 쉬운 게 있다. 결과만 보면 Task { }가 만든 곳의 스레드를 그대로 이어받는 것처럼 보이지만, 물려받는 건 스레드가 아니라 소속(actor)이다. 부모의 소속이 MainActor였고, MainActor는 메인 스레드에서만 돌도록 정해져 있어서 결과적으로 메인 스레드에서 돈 것이다. 소속이 없는 곳에서 만들면 어떻게 되는지, 그리고 MainActor가 아닌 actor는 스레드에 묶여 있는지 돌려봤다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import Foundation
// 지금 코드가 도는 스레드의 번호
func threadID() -> UInt64 {
var id: UInt64 = 0
pthread_threadid_np(nil, &id)
return id
}
// 1) 소속이 없는 곳에서 Task { }를 만들고, 만든 쪽은 20ms 동안 스레드를 붙잡고 있는다
func spawnFromNonisolated() async -> Bool {
let creator = threadID()
let child = Task { threadID() }
usleep(20_000)
return creator == (await child.value)
}
// 2) actor 메서드를 여러 곳에서 불렀을 때, actor 안의 코드가 실제로 돈 스레드들
actor Worker {
var threads = Set<UInt64>()
func work() {
threads.insert(threadID())
usleep(1_000)
}
}
@main struct Main {
static func main() async {
var same = 0
for _ in 0..<50 where await spawnFromNonisolated() { same += 1 }
print("소속 없는 곳에서 만든 Task { }: 50번 중 만든 스레드와 같은 스레드에서 돈 횟수 \(same)")
let worker = Worker()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask {
usleep(500)
await worker.work()
}
}
}
print("actor 메서드 100번이 실제로 돈 스레드 수: \(await worker.threads.count)")
}
}
1
2
소속 없는 곳에서 만든 Task { }: 50번 중 만든 스레드와 같은 스레드에서 돈 횟수 0
actor 메서드 100번이 실제로 돈 스레드 수: 2
소속이 없는 곳에서 만든 Task { }는 만든 쪽이 스레드를 붙잡고 있는 동안 50번 모두 다른 스레드에서 돌았다. 스레드를 이어받는다면 나올 수 없는 결과다. 그리고 actor 메서드 100번은 스레드 두 개에서 번갈아 돌았다. actor가 보장하는 건 “한 번에 하나씩”이지 특정 스레드가 아니다. 메인 스레드에 묶여 있는 건 MainActor뿐이다.
Task.detached는 이름 그대로 아무것도 물려받지 않았다. SE-0304도 detached 작업은 우선순위도, actor도 물려받지 않는다고 적어뒀다.
정리하면
| 구조적 (자식 작업) | 구조적이지 않음 | |
|---|---|---|
| 해당 | async let, TaskGroup의 addTask | Task { }, Task.detached |
| 부모 | 있음 | 없음 |
| 만든 함수는 | 자식이 끝나야 돌아온다 | 바로 돌아온다 |
| 취소 | 부모가 취소되면 같이 표시를 받는다 | 따로 챙겨야 한다 |
구조적 동시성의 “구조”는 부모와 자식으로 이어진 관계다. 이어져 있으면 기다려주고, 취소도 같이 받는다. 대신 취소는 표시일 뿐이라, 작업 스스로 확인하지 않으면 부모는 그만큼 더 기다려야 한다.
3. 에러가 나면 어떻게 되나
이전글의 “Cancelling a Task”에서는 짝수 id면 에러를 던지게 해놓고, 반복문이 두 번째에서 멈추는 걸 봤다. 그다음 Task.checkCancellation()과 do/catch를 넣었더니 1번부터 5번까지 다 돌았다. 그때는 checkCancellation()이 에러를 체크해준 덕분인 줄 알았다. 이번에 그 부분부터 다시 봤다.
취소는 확인하는 곳에서만 멈춘다
(7)편 코드를 옮겨서, 경우마다 Task를 하나씩 따로 만들었다. a는 do/catch가 없고, b는 do/catch만 있고, c는 (7)편 코드 그대로다. d는 c와 같은 코드를 돌리다가 바깥에서 cancel()한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
enum NetworkError: Error { case invalidId }
func getAPR(userId: Int) async throws -> Double {
try? await Task.sleep(for: .milliseconds(20))
if userId % 2 == 0 { throw NetworkError.invalidId } // 짝수 id면 에러
return 6.0
}
@main struct Main {
static func main() async {
// a) do/catch 없음: 에러가 나면 반복문을 빠져나간다
print("a) do/catch 없음")
let a = Task {
for id in 1...5 {
let apr = try await getAPR(userId: id)
print(" id \(id): \(apr)")
}
}
do { try await a.value } catch { print(" → \(error)에서 반복문이 끝남") }
// b) do/catch만
print("b) do/catch만")
let b = Task {
for id in 1...5 {
do {
let apr = try await getAPR(userId: id)
print(" id \(id): \(apr)")
} catch {
print(" id \(id): \(error)")
}
}
}
await b.value
// c) do/catch + checkCancellation() ((7)편 코드)
print("c) do/catch + checkCancellation()")
let c = Task {
for id in 1...5 {
do {
try Task.checkCancellation()
let apr = try await getAPR(userId: id)
print(" id \(id): \(apr)")
} catch {
print(" id \(id): \(error)")
}
}
}
await c.value
// d) c와 같은 코드를 돌리다가 30ms에 바깥에서 취소
print("d) c와 같은 코드 + 30ms에 cancel()")
let d = Task {
for id in 1...5 {
do {
try Task.checkCancellation()
let apr = try await getAPR(userId: id)
print(" id \(id): \(apr)")
} catch {
print(" id \(id): \(error)")
}
}
}
try? await Task.sleep(for: .milliseconds(30))
d.cancel()
await d.value
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
a) do/catch 없음
id 1: 6.0
→ invalidId에서 반복문이 끝남
b) do/catch만
id 1: 6.0
id 2: invalidId
id 3: 6.0
id 4: invalidId
id 5: 6.0
c) do/catch + checkCancellation()
id 1: 6.0
id 2: invalidId
id 3: 6.0
id 4: invalidId
id 5: 6.0
d) c와 같은 코드 + 30ms에 cancel()
id 1: 6.0
id 2: invalidId
id 3: CancellationError()
id 4: CancellationError()
id 5: CancellationError()
| Task | 결과 |
|---|---|
a: do/catch 없음 | id 2의 에러에서 반복문이 끝남 |
b: do/catch만 | 1~5 모두 돎 (2, 4는 에러 출력) |
c: do/catch + checkCancellation() ((7)편 코드) | 1~5 모두 돎, b와 똑같음 |
d: c와 같은 코드 + d.cancel() | 3, 4, 5는 CancellationError |
b와 c의 결과가 똑같았다. 반복문이 끝까지 돈 건 do/catch가 에러를 잡아줬기 때문이고, c의 checkCancellation()은 아무 일도 하지 않았다. 아무도 c를 취소하지 않았기 때문이다.
checkCancellation()은 에러를 체크하는 게 아니라 “지금 취소된 상태인가”를 확인해서, 취소됐을 때만 CancellationError를 던진다.
d처럼 실제로 취소했을 때에야 나머지가 CancellationError로 바뀌었다. 30ms에 취소했으니 이미 돌고 있던 id 2까지는 원래대로 끝났고, 다음 반복의 checkCancellation()부터 에러를 던졌다.
Task를 만들자마자 취소하면 id 1부터 전부 CancellationError였다.
1번 섹션에서 본 것처럼 취소는 표시일 뿐이라, 작업 안에 확인하는 곳이 있어야 멈춘다. 확인하는 방법은 세 가지를 썼다.
| 확인하는 곳 | 취소된 상태면 |
|---|---|
Task.isCancelled | true를 돌려줄 뿐, 멈추는 건 직접 해야 함 |
try Task.checkCancellation() | CancellationError를 던짐 |
try await Task.sleep(...) | 기다리다가 CancellationError를 던짐 |
RunWay 워치의 경고 햅틱이 이 세 가지를 같이 쓰고 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private func playHapticPattern() {
hapticTask?.cancel()
hapticTask = Task {
while !Task.isCancelled {
for i in 0..<pattern.repeatCount {
guard !Task.isCancelled else { return }
WKInterfaceDevice.current().play(pattern.type)
// 생략
try? await Task.sleep(for: .seconds(pattern.interval))
}
guard !Task.isCancelled else { return }
try? await Task.sleep(for: .seconds(2.0))
}
}
}
// .onDisappear { hapticTask?.cancel() }
Task { }는 구조적이지 않아서 뷰가 사라져도 저절로 멈추지 않는다. 그래서 hapticTask에 담아두고 onDisappear에서 직접 cancel()한다. 그리고 Task.sleep을 try?로 부르고 있어서, 취소돼도 에러가 삼켜지고 바로 다음 줄로 넘어간다. 직접 돌려보니 try?로 삼킨 뒤에도 Task.isCancelled는 true였다. 그래서 Task.sleep 뒤마다 guard !Task.isCancelled로 한 번 더 확인해야 반복이 멈춘다.
guard가 없으면 어떻게 되는지 같은 모양으로 돌려봤다. 햅틱 대신 시각을 찍고, 450ms에 화면을 떠나게 했다. guard가 있으면 잠에서 깨자마자 멈췄고, 없으면 화면을 떠난 직후에 햅틱이 한 번 더 울렸다.
.task는 뷰가 사라지면 취소해준다
RunWay 햅틱은 onAppear에서 Task { }를 띄우고, 그 작업을 변수에 담아뒀다가 onDisappear에서 직접 cancel()했다. SwiftUI에는 이 일을 대신 해주는 .task Modifier가 있다. task Docs에는 이 작업의 수명이 뷰의 수명과 같고, 작업이 끝나기 전에 뷰가 사라지면 SwiftUI가 취소한다고 되어 있다.
그렇다고 .task가 구조적인 건 아니다. 부모 작업의 자식이 아니라, SwiftUI가 뷰에 묶어두고 있다가 뷰가 사라질 때 대신 cancel()을 불러주는 것이다. 참고로 기본 우선순위는 .userInitiated였다.
macOS에서 작은 SwiftUI 앱을 만들어 세 가지를 같이 띄우고, 372ms에 세 뷰를 한꺼번에 화면에서 뺐다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import SwiftUI
let start = Date()
func ms() -> Int { Int(Date().timeIntervalSince(start) * 1000) }
// A) onAppear에서 Task { }를 띄우고, onDisappear에서 취소하지 않음
struct OnAppearView: View {
var body: some View {
Text("A").onAppear {
Task {
while !Task.isCancelled {
print("[A onAppear + Task] \(ms())ms")
try? await Task.sleep(for: .milliseconds(100))
}
}
}
}
}
// B) .task: 뷰가 사라지면 SwiftUI가 취소해준다
struct TaskModifierView: View {
var body: some View {
Text("B").task {
while !Task.isCancelled {
print("[B .task] \(ms())ms")
try? await Task.sleep(for: .milliseconds(100))
}
print("[B .task] 취소돼서 멈춤 \(ms())ms")
}
}
}
// C) .task인데 취소를 확인하지 않음
struct TaskNoCheckView: View {
var body: some View {
Text("C").task {
for _ in 0..<6 {
print("[C .task, 확인 안 함] \(ms())ms, 취소 표시: \(Task.isCancelled)")
try? await Task.sleep(for: .milliseconds(100))
}
}
}
}
struct RootView: View {
@State private var show = true
var body: some View {
VStack {
if show {
OnAppearView()
TaskModifierView()
TaskNoCheckView()
}
}
.frame(width: 200, height: 100)
.task {
try? await Task.sleep(for: .milliseconds(350))
print("---- \(ms())ms: 세 뷰를 화면에서 뺌 ----")
show = false
try? await Task.sleep(for: .milliseconds(450))
exit(0)
}
}
}
@main struct Lab: App {
var body: some Scene { WindowGroup { RootView() } }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[C .task, 확인 안 함] 0ms, 취소 표시: false
[B .task] 0ms
[A onAppear + Task] 21ms
[C .task, 확인 안 함] 101ms, 취소 표시: false
[B .task] 101ms
[A onAppear + Task] 125ms
[C .task, 확인 안 함] 206ms, 취소 표시: false
[B .task] 206ms
[A onAppear + Task] 232ms
[C .task, 확인 안 함] 313ms, 취소 표시: false
[B .task] 313ms
[A onAppear + Task] 334ms
---- 372ms: 세 뷰를 화면에서 뺌 ----
[B .task] 취소돼서 멈춤 373ms
[C .task, 확인 안 함] 373ms, 취소 표시: true
[C .task, 확인 안 함] 373ms, 취소 표시: true
[A onAppear + Task] 440ms
[A onAppear + Task] 545ms
[A onAppear + Task] 647ms
[A onAppear + Task] 752ms
| 뷰 | 뷰를 뺀 뒤 |
|---|---|
A: onAppear + Task { } | 계속 돎, 프로그램이 끝날 때까지 찍힘 |
B: .task + Task.isCancelled 확인 | 373ms에 바로 멈춤 |
C: .task, 확인 안 함 | 취소 표시는 받았지만 남은 반복을 다 돎 |
A는 뷰가 없어졌는데도 계속 돌았다. Task { }는 누가 cancel()을 불러주지 않으면 멈추지 않는다. RunWay가 onDisappear에서 직접 취소한 이유가 이거다.
C는 앞에서 본 것과 같다. .task가 해주는 건 취소 표시까지고, 멈추는 건 여전히 작업이 확인해야 한다. 게다가 try?로 부른 Task.sleep이 취소되자마자 바로 끝나버려서, 남은 두 번이 기다림 없이 한꺼번에 돌았다. 햅틱이었다면 남은 횟수만큼 한 번에 울렸을 것이다.
RunWay 햅틱도 onAppear에서 시작하고 onDisappear에서 멈추는 모양이라, .task로 바꾸면 hapticTask 변수와 onDisappear가 필요 없어진다. 대신 guard !Task.isCancelled는 그대로 있어야 한다. RunWay 코드는 아직 바꾸지 않았다.
TaskGroup은 기다리는 방법에 따라 달라진다
TaskGroup에 자식 세 개를 넣고, 첫 번째 자식만 50ms에 에러를 던지게 했다. 두 번째는 Task.sleep으로 300ms를 기다리고(취소를 확인함), 세 번째는 usleep으로 300ms를 그냥 돈다(취소를 확인 안 함). 그리고 그룹이 결과를 기다리는 방법만 바꿔봤다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import Foundation
func now() -> Double { Date().timeIntervalSince1970 * 1000 }
struct Boom: Error {}
@main struct Main {
static func main() async {
let start = now()
do {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { // 첫 번째: 50ms 뒤 에러
try await Task.sleep(for: .milliseconds(50))
print("[자식1] 50ms에 에러를 던짐")
throw Boom()
}
group.addTask { // 두 번째: 취소를 확인함
do {
try await Task.sleep(for: .milliseconds(300))
print("[자식2] 끝까지 돎 (\(Int(now() - start))ms)")
} catch {
print("[자식2] 취소돼서 멈춤 (\(Int(now() - start))ms)")
}
}
group.addTask { // 세 번째: 취소를 확인 안 함
for _ in 0..<30 { usleep(10_000) }
print("[자식3] 끝까지 돎 (취소 표시: \(Task.isCancelled), \(Int(now() - start))ms)")
}
try await group.waitForAll() // 1) 모두 기다리기
// while try await group.next() != nil {} // 2) 하나씩 받기
// // 3) 안 기다림 (위 두 줄 모두 지움)
}
print("그룹이 에러 없이 끝남: \(Int(now() - start))ms")
} catch {
print("그룹 밖으로 에러가 나옴: \(Int(now() - start))ms")
}
}
}
| 기다리는 방법 | 두 번째 자식 (취소 확인함) | 세 번째 자식 (확인 안 함) | 그룹 밖으로 |
|---|---|---|---|
waitForAll() | 취소 안 됨, 300ms까지 돎 | 취소 표시 없음 | 다 끝난 뒤 에러 (369ms) |
next()로 하나씩 | 52ms에 취소됨 | 취소 표시는 받았지만 끝까지 돎 | 세 번째를 기다린 뒤 에러 (358ms) |
| 안 기다림 | 취소 안 됨, 300ms까지 돎 | 취소 표시 없음 | 에러가 사라짐 (359ms) |
자식 하나가 에러를 던진다고 나머지가 바로 취소되지는 않았다. 에러가 그룹 밖으로 던져질 때만 나머지가 취소됐다. withThrowingTaskGroup Docs에 이 내용이 그대로 적혀 있다. 자식의 에러는 next()로 받는 순간 그룹 안으로 올라오고, 그걸 밖으로 던지면 그때 그룹 전체가 취소된다. 문서에는 자식이 에러를 던져도 아무도 기다리지 않으면 아무것도 취소되지 않고 그룹도 에러를 던지지 않는다는 예시까지 있다. 세 번째 줄의 “에러가 사라짐”이 그거다.
waitForAll()은 조금 헷갈렸다. 이름만 보면 에러가 나면 바로 던질 것 같은데, waitForAll Docs를 보면 첫 번째 에러를 담아뒀다가 나머지가 다 끝난 뒤에 던지고, 그동안 그룹은 취소하지 않는다고 되어 있다. 에러가 나면 나머지를 멈추고 싶다면 next()로 받다가 던지는 쪽을 써야 한다.
그리고 세 방법 모두 그룹이 끝나는 시각은 300ms 넘어서였다. 취소를 확인하지 않는 세 번째 자식을 기다렸기 때문이다. 에러가 나도 구조는 그대로다. 그룹은 자식이 다 끝나야 끝난다.
GitExplorer에서 쓴 코드는 for try await user in group으로 결과를 모았는데, 이건 next()로 하나씩 받는 모양이다. 그래서 한 명이라도 실패하면 나머지 요청이 취소되고 목록 전체가 실패한다. 같은 모양으로 세 명 중 한 명만 실패하게 해서 돌려봤다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct NotFound: Error {}
func fetchUser(_ name: String, ms: Int, fail: Bool) async throws -> String {
do { try await Task.sleep(for: .milliseconds(ms)) }
catch { print("[\(name)] 취소됨"); throw error }
if fail { throw NotFound() }
return name
}
@main struct Main {
static func main() async {
// (이름, 걸리는 시간, 실패 여부)
let plan = [("apple", 100, false), ("swiftlang", 30, true), ("harold", 200, false)]
// 1) 그대로 던짐 (GitExplorer 코드와 같은 모양)
var users = [String]()
do {
try await withThrowingTaskGroup(of: String.self) { group in
for (name, ms, fail) in plan {
group.addTask { try await fetchUser(name, ms: ms, fail: fail) }
}
for try await user in group { users.append(user) }
}
print("결과: \(users)")
} catch {
print("그룹 밖으로 에러: \(error), 모은 결과: \(users)")
}
// 2) 자식 안에서 에러를 잡아서 nil로 돌려줌
var partial = [String]()
await withTaskGroup(of: String?.self) { group in
for (name, ms, fail) in plan {
group.addTask { try? await fetchUser(name, ms: ms, fail: fail) }
}
for await user in group { if let user { partial.append(user) } }
}
print("결과: \(partial)")
}
}
| 자식이 에러를 | 결과 |
|---|---|
| 그대로 던짐 (GitExplorer 코드) | 나머지 두 요청은 취소되고, 목록은 비어 있음 |
자식 안에서 잡아서 nil로 돌려줌 | 성공한 두 명만 목록에 남음 |
하나라도 실패하면 전부 실패로 볼지, 성공한 것만이라도 보여줄지는 화면에 따라 정하면 된다. 즐겨찾기 목록처럼 일부만 보여줘도 되는 화면이라면 자식 안에서 에러를 잡는 쪽이 더 맞았을 것 같다.
DiscardingTaskGroup은 에러가 나면 바로 취소한다
앞에서 일반 TaskGroup은 결과를 안 기다리면 자식의 에러가 사라졌다. 결과를 아예 모으지 않는 그룹도 있다. withDiscardingTaskGroup과 withThrowingDiscardingTaskGroup이고, SE-0381(“DiscardingTaskGroups”, Swift 5.9에서 구현)로 들어왔다. iOS 17부터 쓸 수 있다.
이름처럼 자식이 끝나면 결과를 바로 버린다. 그래서 자식은 값을 돌려줄 수 없고(Void만), next()나 for await로 결과를 받을 수도 없다. 제안서는 서버처럼 요청마다 자식을 만들고 끝없이 도는 경우를 예로 든다. 일반 TaskGroup이면 아무도 꺼내지 않는 결과가 계속 쌓이기 때문이다.
그럼 결과를 받을 수 없으니 에러도 사라질까 궁금했다. 앞의 실험과 같은 자식 세 개를 넣고, 기다리는 코드 없이 돌렸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import Foundation
func now() -> Double { Date().timeIntervalSince1970 * 1000 }
struct Boom: Error {}
@main struct Main {
static func main() async {
let start = now()
do {
try await withThrowingDiscardingTaskGroup { group in
group.addTask { // 첫 번째: 50ms 뒤 에러
try await Task.sleep(for: .milliseconds(50))
print("[자식1] 50ms에 에러를 던짐")
throw Boom()
}
group.addTask { // 두 번째: 취소를 확인함
do {
try await Task.sleep(for: .milliseconds(300))
print("[자식2] 끝까지 돎 (\(Int(now() - start))ms)")
} catch {
print("[자식2] 취소돼서 멈춤 (\(Int(now() - start))ms)")
}
}
group.addTask { // 세 번째: 취소를 확인 안 함
for _ in 0..<30 { usleep(10_000) }
print("[자식3] 끝까지 돎 (취소 표시: \(Task.isCancelled), \(Int(now() - start))ms)")
}
// 결과를 기다리는 코드가 없다
}
print("그룹이 에러 없이 끝남: \(Int(now() - start))ms")
} catch {
print("그룹 밖으로 에러가 나옴: \(error) (\(Int(now() - start))ms)")
}
}
}
1
2
3
4
[자식1] 50ms에 에러를 던짐
[자식2] 취소돼서 멈춤 (50ms)
[자식3] 끝까지 돎 (취소 표시: true, 356ms)
그룹 밖으로 에러가 나옴: Boom() (356ms)
| 일반 TaskGroup (안 기다림) | DiscardingTaskGroup | |
|---|---|---|
| 두 번째 자식 (취소 확인함) | 취소 안 됨, 300ms까지 돎 | 50ms에 취소됨 |
| 세 번째 자식 (확인 안 함) | 취소 표시 없음 | 취소 표시는 받았지만 끝까지 돎 |
| 그룹 밖으로 | 에러가 사라짐 | 다 끝난 뒤 첫 에러를 던짐 (356ms) |
기다리는 코드가 없는데도 자식1이 에러를 던지자마자 나머지가 취소됐고, 에러도 그룹 밖으로 나왔다. withThrowingDiscardingTaskGroup Docs에 그대로 적혀 있다. next()가 없어서 에러를 꺼내 던질 방법이 없으니, 자식이 에러를 던지면 그룹이 스스로 취소한다. 그리고 첫 번째 에러를 담아뒀다가 그룹이 끝날 때 던진다.
그래도 구조는 같다. 세 번째 자식을 기다리느라 그룹은 356ms에야 끝났다. 특정 에러로 그룹 전체가 취소되는 게 싫으면, 문서에 나온 대로 자식 안에서 그 에러를 잡으면 된다. GitExplorer의 즐겨찾기처럼 결과를 모아야 하는 곳에는 맞지 않고, 여러 곳에 로그를 보내거나 파일 여러 개를 저장하는 것처럼 결과가 필요 없는 일에 맞는다.
정리하면
| 결과 | |
|---|---|
checkCancellation() | 취소된 상태일 때만 CancellationError를 던진다. 에러를 체크해주는 게 아니다 |
try? await Task.sleep | 취소 에러까지 삼키니, 뒤에서 Task.isCancelled를 다시 확인해야 한다 |
| TaskGroup 자식의 에러 | 그룹 밖으로 던져질 때만 나머지가 취소된다 |
waitForAll() | 나머지를 취소하지 않고 다 기다린 뒤 첫 에러를 던진다 |
| 안 기다리면 | 자식의 에러가 사라진다 |
.task | 뷰가 사라지면 SwiftUI가 취소 표시를 해준다. 멈추는 건 여전히 작업이 확인해야 한다 |
| DiscardingTaskGroup | 자식이 에러를 던지면 바로 나머지를 취소하고, 다 끝난 뒤 첫 에러를 던진다 |
에러가 나도 구조는 그대로라, 그룹은 자식이 다 끝나야 끝난다. 취소는 표시일 뿐이니 확인하지 않는 자식이 있으면 그만큼 기다린다.
정리
- 구조적 동시성은 SE-0304에서 생긴 개념이다. “구조”는 부모와 자식으로 이어진 관계이고, 자식은 부모보다 오래 살 수 없다
async let과 TaskGroup의addTask는 자식을 만든다. 둘 다 같이 출발하지만 도착 순서는 정해져 있지 않다. 개수가 정해져 있으면async let, 그때그때 달라지면 TaskGroup을 쓴다Task { }와Task.detached는 부모가 없다. 만든 함수는 기다리지 않고 바로 돌아오고, 취소도 직접 챙겨야 한다Task { }는 만든 곳의 actor, 우선순위, task-local 값을 물려받는다. 물려받는 건 스레드가 아니라 소속이다. MainActor는 메인 스레드와 묶여 있지만, 다른 actor는 매번 같은 스레드에서 돌지 않았다Task.detached는 아무것도 물려받지 않아서 actor 밖의 공용 스레드에서 돈다. 두 개를 띄우면 actor 메서드 두 번보다 빨리 끝났지만, actor처럼 격리해주는 영역은 아니라서 값을 넘길 때 검사는 그대로 받는다- 함수를 벗어날 때 기다리지 않은 자식은 취소 표시를 받고, 함수는 그 자식이 끝날 때까지 기다린다
- 취소는 표시일 뿐이다.
Task.isCancelled,Task.checkCancellation(),Task.sleep처럼 확인하는 곳이 있어야 멈춘다. (7)편에서 반복문이 끝까지 돈 건checkCancellation()이 아니라do/catch덕분이었다 try?로Task.sleep을 부르면 취소 에러까지 삼킨다. 뒤에서Task.isCancelled를 다시 확인하지 않으면 남은 일이 한꺼번에 돈다- SwiftUI
.task는 뷰가 사라지면 대신 취소 표시를 해준다.onAppear에서 띄운Task { }는 직접cancel()하지 않으면 뷰가 사라져도 계속 돈다 - TaskGroup에서 자식 하나의 에러는 나머지를 바로 취소하지 않는다.
next()로 받아서 밖으로 던질 때 취소되고,waitForAll()은 다 기다린 뒤 던지고, 안 기다리면 에러가 사라진다. DiscardingTaskGroup은 자식이 에러를 던지는 순간 스스로 취소한다 - 에러가 나도 구조는 그대로다. 그룹은 자식이 다 끝나야 끝나고, 취소를 확인하지 않는 자식이 있으면 그만큼 기다린다












