概要
Task Groups は複数のタスクを並列に実行するための仕組みです。この記事では Task Groups の基本的な使い方から、キャンセル周りも含めた細かい振る舞いまで理解していきます。
記事中の動作検証は Xcode 14 Beta 5 で行っています。
TL;DR
- Task Groups は子タスクを並列に実行したいときに使う。とくに、同じ型を返す動的な数の子タスクの実行に適している
- Task Group は
withTaskGroup
かwithThrowingTaskGroup
で生成できる。子タスクがエラーを投げない場合は前者、投げる場合は後者を使う - 基本的な流れとして
with(Throwing)TaskGroup
に渡すクロージャの中で子タスクを生成する処理とそれを受け取る処理を書く- 子タスクの生成は
addTask(UnlessCancelled)
で行う。典型的にはfor
文で Collection の要素を回してそれをもとにタスクを生成することが多い - 受け取り処理は
for try await ... in
で行うことが多いが、TaskGroup
がAsyncSequence
であることを利用して柔軟に処理が書ける
- 子タスクの生成は
- Task Groups は処理を抜ける際に明示的に
await
されていない子タスクが残っていたらawait
する。async let
はスコープを抜ける際にawait
されていない子タスクをキャンセルするので振る舞いが異なる - Task Group は渡されたクロージャがエラーを投げたときや
cancelAll
が呼ばれたときに、残っている子タスクをすべてキャンセルする- 個々の子タスクがエラーを投げても、その子タスクが結果を返さないだけでキャンセルは発生しないことに注意
Task Groups の使いどころ
まず、 Task Groups がどういう場面で有用なのかを見ていきます。Todo アプリで Done になっていない Todo を表示することを考えます。 API から Todo の情報を取得しますが、以下のように
- Done になっていない Todo の ID 一覧を取得する API
- ID から Todo の詳細を取得する API
の2つがあるとします。
enum TodoAPI {
static func getUndoneTodoIDs() async throws -> [String] { /* ... */ }
static func getTodoDetail(id: String) async throws -> Todo { /* ... */ }
}
API の構成を考えると、まず Todo の ID 一覧を getUndoneTodoIDs
で取得し、そのレスポンスの中に含まれる ID それぞれに対して getTodoDetail
を呼び、返ってきた Todo
をまとめる必要があることがわかります。以下のようなコードになるでしょう。
func getUndoneTodos() async throws -> [Todo] {
let todoIDs = try await TodoAPI.getUndoneTodoIDs()
var todos: [Todo] = []
for id in todoIDs {
let todo = try await TodoAPI.getTodoDetail(id: id)
todos.append(todo)
}
return todos
}
このコードには明らかな問題があります。 getTodoDetail
を1つ目のリクエストが完了してから2つ目のリクエストを実行するというように直列に呼んでいるため getUndoneTodoIDs
で返ってくる ID の数が増えれば増えるほど完了に時間がかかるということです。仮に getTodoDetail
のレスポンスが1秒程度で getUndoneTodoIDs
から ID が 10 個返ってくると getUndoneTodos
全体の実行が完了するまでに 10 秒以上はかかることになります。
2つ目のリクエストが1つ目のリクエストの結果が返ってくるのを待っている必要はないので、 getTodoDetail
を ID の個数だけ並列に呼び出すことでこの問題は解決します。このようなときに役立つのが Task Groups です。細かい部分についてはこの記事の後程見ていきますが、大まかな使い方は以下のようになります。
func getUndoneTodos() async throws -> [Todo] {
let todoIDs = try await TodoAPI.getUndoneTodoIDs()
// 1
return try await withThrowingTaskGroup(of: Todo.self, returning: [Todo].self) { group in
// 2
for id in todoIDs {
group.addTask {
try await TodoAPI.getTodoDetail(id: id)
}
}
// 3
var todos: [Todo] = []
for try await todo in group {
todos.append(todo)
}
return todos
}
}
まず、 1
で withThrowingTaskGroup
を呼び出して Task Group を生成しています。3つ目の引数のクロージャに実際に行いたい処理を書きますが、そのクロージャの引数に TaskGroup
のインスタンスが渡ってきます。 2
では for
文で子タスクを起動しています。 Task Groups を使わない例では for
文の中で await
していたので1つ目の Todo の取得が終わってからでないと2つ目の Todo の取得を開始することができませんでしたが、 TaskGroup
の addTask
はタスクを生成するだけブロックしないので、結果待たずにすべての Todo の取得を並行に開始することができます。最後に 3
で TaskGroup
に対して for try await ... in
することで返ってきた結果を得ることができます。
以上のように並行に Todo の取得を行うことで、 getTodoDetail
のレスポンスが1秒で完了するとき、最もうまくいく状況であれば ID の数が何個であってもおおよそ1秒ですべての Todo の取得が完了します。
注意点として、 for try await ... in
に渡ってくる Todo の順序は子タスクの生成順ではなく完了順になります。そのため、 todoIDs
の順番と getUndoneTodos
の返り値の Todo
の順番は一般に異なります。もしこれらの順番を揃えたい場合は自分でコードを書いて保証する必要があります。
また、Task Groups には並列実行数の制限がありません。極端な例だと getUndoneTodoIDs
が 1000 個の id を返してきたら 1000 個の getTodoDetail
が並行に走ってしまうことになります。並列実行数を一定以下に抑えたい場合もその処理を自分で書く必要があります。
Task Groups の利用場面としては、今回の例のように
- 実行時に決まる個数の処理を並行に行いたい
- それぞれの処理の結果の型がすべて同じ
という場合が典型的です 1。このような場合に並行に行いたい処理を for
文で addTask
で立ち上げ、 for try await ... in
で結果を受け取るという使い方をします。
逆に、並列に行いたい処理の個数がコンパイル時に決まっていたり、それらの処理の結果の型が異なる場合は、Swift Concurrency において子タスクを生成するもう一つの方法である async let
を使うことが多いです。この記事では深入りしませんが、例えば Todo に画像を添付することができ、 Todo と画像を別の API エンドポイントから取得する必要がある場合を考えると、以下のように async let
が使えます。これにより Todo と画像を並行して取得することができます。
enum TodoAPI {
static func getTodoDetail(id: String) async throws -> Todo { /* ... */ }
static func getTodoImage(id: String) async throws -> UIImage? { /* ... */ }
}
func getTodoWithImage(id: String) async throws -> (Todo, UIImage?) {
async let todo = TodoAPI.getTodoDetail(id: id)
async let image = TodoAPI.getTodoImage(id: id)
return try await (todo, image)
}
Task Groups の使い方
Task Groups の概要がわかったところで、使い方をもう少し詳しく見ていきます。
2つの生成メソッド
前の項では Task Group の生成に withThrowingTaskGroup
を使いましたが、もう一つ withTaskGroup
という関数もあります。それぞれのシグネチャを見ておきます。
func withTaskGroup<ChildTaskResult, GroupResult>(
of childTaskResultType: ChildTaskResult.Type,
returning returnType: GroupResult.Type = GroupResult.self,
body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult where ChildTaskResult : Sendable
func withThrowingTaskGroup<ChildTaskResult, GroupResult>(
of childTaskResultType: ChildTaskResult.Type,
returning returnType: GroupResult.Type = GroupResult.self,
body: (inout ThrowingTaskGroup<ChildTaskResult, Error>) async throws -> GroupResult
) async rethrows -> GroupResult where ChildTaskResult : Sendable
- https://developer.apple.com/documentation/swift/withtaskgroup(of:returning:body:)
- https://developer.apple.com/documentation/swift/withthrowingtaskgroup(of:returning:body:)
この2つの関数の違いは、 withTaskGroup
の方はエラーを投げないのに対して withThrowingTaskGroup
の方では主な処理を書く body
が throws
、関数全体も rethrows
になっているところです。ちょっと複雑に思えますが、並列に実行したい子タスクがエラーを投げるならば withThrowingTaskGroup
、投げないならば withTaskGroup
を使うと覚えておけば大丈夫だと思います。
withTaskGroup
の使い方として、エラーを投げない geenerateRandomNumber
という関数を並行に呼んで乱数の列を取得する例を考えます。
func generateRandomNumber() async -> Int { /* ... */ }
func generateRandomNumbers(count: Int) async -> [Int] {
await withTaskGroup(of: Int.self, returning: [Int].self) { group in
for _ in 0..<count {
group.addTask {
await generateRandomNumber()
}
}
var randomNumbers: [Int] = []
for await number in group {
randomNumbers.append(number)
}
return randomNumbers
}
}
結果をまとめるところで for try await ... in
ではなく for await ... in
を使っているところや、 withTaskGroup
自体の呼び出しに try
が必要ないところが withThrowingTaskGroup
と違いますが、逆にそれ以外の部分については同じです。 Swift Concurrency はエラーを使ってキャンセルの仕組みを実現しているので、プロダクションコードを書く上では withThrowingTaskGroup
を使うケースの方が多いと思います。
以下、 withTaskGroup
と withThrowingTaskGroup
に共通する話においては2つをまとめて with(Throwing)TaskGroup
と書くことにします。
with(Throwing)TaskGroup
の引数について見ていきます。引数は3つ取りますが、 3つ目の body
は (Throwing)TaskGroup
を受け取って子タスクの生成と結果の加工を行うクロージャです。 1つ目の of
には子タスクの結果の型を、2つ目の returning
には body
の返り値の型、すなわち with(Throwing)TaskGroup
自体の返り値の型を渡します。前項の Todo アプリの例では、子タスクは Todo
、 body
は Todo
を詰めた Array
を返していたので of
には Todo.self
、 returning
には [Todo].self
を渡していました。このように returning
が of
の Collection
になるケースもありますが、必ずしもそうなるわけではありません。例えば、 Done になっていない Todo のタイトルをカンマで区切って繋げた文字列がほしいとすると、以下のように returning
には String.self
を指定することになります。
func getUndoneTodosTitleString() async throws -> String {
let todoIDs = try await TodoAPI.getUndoneTodoIDs()
return try await withThrowingTaskGroup(of: Todo.self, returning: String.self) { group in
for id in todoIDs {
group.addTask {
try await TodoAPI.getTodoDetail(id: id)
}
}
var todoTitles: [String] = []
for try await todo in group {
todoTitles.append(todo.title)
}
return todoTitles.joined(separator: ",")
}
}
ここまでは returning
を明示的に書いてきましたが、シグネチャを見ると分かるように returning
にはデフォルト引数が設定されており、これが body
や with(Throwing)TaskGroup
の返り値と同じ型になっています。型推論がうまく効いていれば書く必要はないので、基本的には自分で明示的に書くケースの方が少ないでしょう。
func getUndoneTodosTitleString() async throws -> String {
let todoIDs = try await TodoAPI.getUndoneTodoIDs()
// ✅ returning を省略してもコンパイルが通る
return try await withThrowingTaskGroup(of: Todo.self) { group in
// ...
}
}
続いて、 with(Throwing)TaskGroup
に渡す主な処理である body
の書き方について詳しく見ていきます。Todo アプリの例もそうでしたが、 body
の処理は基本的に
- 並行に実行する子タスクを生成する
- 子タスクの結果を受け取り加工して返す
の2つの段階に分けられるので、それぞれについて整理します。
子タスクの生成
子タスクの生成は TaskGroup
インスタンスの addTask
メソッドで行います。 Task Groups の使い道として、何らかの Collection の要素に対してそれぞれ子タスクを生成するというケースがよくあるので、 Todo アプリの例のように for
文の中で addTask
することになる場合が多いでしょう。もちろん、以下のように for
文の中でなくとも addTask
することはできます。
func getTwoTodos() async throws -> [Todo] {
try await withThrowingTaskGroup(of: Todo.self, returning: [Todo].self) { group in
group.addTask {
try await TodoAPI.getTodoDetail(id: "id1")
}
group.addTask {
try await TodoAPI.getTodoDetail(id: "id2")
}
var todos: [Todo] = []
for try await todo in group {
todos.append(todo)
}
return todos
}
}
ここまでは省略していましたが、 addTask
のシグネチャを見ると Task.init
や Task.detached
と同じように priority
が渡せることがわかります。
mutating func addTask(
priority: TaskPriority? = nil,
operation: @escaping () async -> ChildTaskResult
)
priority
を明示的に渡す場合は以下のようになります。
// ...
group.addTask(priority: .background) {
try await TodoAPI.getTodoDetail(id: "id1")
}
// ...
priority
を省略した場合は Task Group を生成した Task
の priority
が使われるため、とくに事情がない限りは省略しておけばよいと思います。
addTask
と似たメソッドに addTaskUnlessCancelled
があります。
mutating func addTaskUnlessCancelled(
priority: TaskPriority? = nil,
operation: @escaping () async -> ChildTaskResult
) -> Bool
addTask
との違いは
- すでに Task Group がキャンセルされている場合は子タスクを生成しない
- 子タスクを生成したかどうかを
Bool
で返す
ということです。キャンセルされている場合に子タスクの処理を開始すらしたくないという場合は addTaskUnlessCancelled
を使うことになりますが、そのような状況はあまりないように思います。子タスクが適切にキャンセルをハンドリングするように実装されていれば、すでに Task Group がキャンセルした場合に addTask
で子タスクが開始されてしまってもすぐキャンセルに対応して処理を抜け、無駄にリソースを使うことにはならないためです。ただ、もし子タスクがキャンセルに対応していない場合や Task Group がキャンセルされるまで子タスクを追加し続けたいという場合などは addTaskUnlessCancelled
が使えると思うので、存在を覚えておいてもよいでしょう。
子タスクの結果の受け取り
Todo アプリの例では addTask
で起動した子タスクの結果の受け取りを for try await ... in
で行っていましたが、これができるのは TaskGroup
が AsyncSequence
に準拠しているためです。 AsyncSequence
の使い方について詳しくはドキュメントを参照ください。
TaskGroup
が AsyncSequence
であることを利用すると、 for try await ... in
以外の結果の受け取り方もできます。例えば、 Todo アプリの例では子タスクが返してきた Todo
を事前に作っておいた Array
に append
していましたが、 AsyncSequence
の reduce
メソッドを使えば同じことがより簡潔に書けます。
func getUndoneTodos() async throws -> [Todo] {
let todoIDs = try await TodoAPI.getUndoneTodoIDs()
return try await withThrowingTaskGroup(of: Todo.self, returning: [Todo].self) { group in
for id in todoIDs {
group.addTask {
try await TodoAPI.getTodoDetail(id: id)
}
}
- var todos: [Todo] = []
- for try await todo in group {
- todos.append(todo)
- }
- return todos
+ return try await group.reduce(into: []) { result, todo in result.append(todo) }
}
}
ここではこれ以上の例は挙げませんが、他にも TaskGroup
インスタンスに対して map
や filter
など AsyncSequence
に対するメソッドを使うことで、処理が簡潔かつわかりやすく書けることがあります。
また、 TaskGroup
には next
メソッドが生えていて、 AsyncIteratorProtocol
と同じようなイメージで値を1つずつ取り出すことができます。例えば、以下のように書くことで for try await ... in
と同じ意味になります。
func getUndoneTodos() async throws -> [Todo] {
let todoIDs = try await TodoAPI.getUndoneTodoIDs()
return try await withThrowingTaskGroup(of: Todo.self, returning: [Todo].self) { group in
for id in todoIDs {
group.addTask {
try await TodoAPI.getTodoDetail(id: id)
}
}
var todos: [Todo] = []
- for try await todo in group {
- todos.append(todo)
- }
+ while let todo = try await group.next() {
+ todos.append(todo)
+ }
return todos
}
}
これだけだと for try await ... in
を使えばいいだけなのでメリットが感じられませんが、例えばいくつか子タスクを起動して一番速く返ってきた結果を使いたいということを考えると、以下のように書けます。 next
を1回しか読んでいないので for try await ... in
と異なりすべての子タスクを待つことなく結果を返せます。
func getFirstNumber() async throws -> Int {
try await withThrowingTaskGroup(of: Int.self) { group in
for _ in 0..<10 {
group.addTask {
try await getNumber()
}
}
let number = try await group.next()!
group.cancelAll()
return number
}
}
また、 next
から派生した nextResult
も有用です。 next
や for try await ... in
は子タスクがエラーを投げたときにそのままエラーを投げ直すので、子タスクがエラーを投げると Swift Concurrency のキャンセルの仕組みに従って他の子タスクがすべてキャンセルされます。 nextResult
は子タスクがエラーを投げてもそれをそのまま投げるのではなく Swift の Result
型の failure
で受け取るので、子タスクが失敗した際のキャンセルが働かなくなります。
これを利用して、例えば失敗しやすい非同期処理を N 個投げて成功した最初の M 個の値を返すというようなことができます。
func getResultFromFailureProneAPI() async throws -> Int { /* ... */ }
func getSomeResultsFromFailureProneAPI(n: Int, m: Int) async throws -> [Int] {
await withThrowingTaskGroup(of: Int.self) { group in
for _ in 0..<n {
group.addTask {
try await getResultFromFailureProneAPI()
}
}
var results: [Int] = []
while let result = await group.nextResult() {
switch result {
case .success(let value):
results.append(value)
if results.count >= m {
group.cancelAll()
return results
}
case .failure:
break
}
}
return results
}
}
Task Groups における結果の受け取りは for try await ... in
で行うことが多いと思いますが、ここで紹介したいくつかのパターンのように要件に合わせて柔軟に行うことができます。
Task Groups と子タスクの寿命
Swift の Structured Concurrency の根幹を成すのは「タスクツリー上の子タスクは親タスクよりも長生きできない」という原則です。これにより並行処理の状態が把握しやすくなり、また実行中の並行処理を漏れなくキャンセルすることも可能になっています。
Task Groups では、 body
内で await
されなかった子タスクは body
を抜ける前に暗黙的に await
することでこの原則を守っています。子タスクを await
しないまま body
を抜けてしまうと子タスクが Task Group 自体のタスクよりも長生きしてしまうためです。
具体例で見てみます。以下の例では、 addTask
した子タスクを for try await ... in
で待たないまま body
を抜けようとしています。
func double(number: Int) async throws -> Int {
try await Task.sleep(nanoseconds: 1_000_000_000)
let doubled = number * 2
print("returning \(doubled)")
return doubled
}
func execTaskGroup() async throws {
await withThrowingTaskGroup(of: Int.self) { group in
for n in 1...3 {
group.addTask {
try await double(number: n)
}
}
print("finishing body")
}
print("finishing execTaskGroup")
}
execTaskGroup
を実行すると、以下のような出力が得られます。
finishing body
-- ここで1秒経過 --
returning 6
returning 2
returning 4
finishing execTaskGroup
body
の最後の print
文が実行されてから、明示的に await
していないにも関わらず double
が終わりまで実行され、その後に execTaskGroup
が完了していることがわかります。これにより、Task Groups は自身を完了させる前に await
されない子タスクを待つことがわかります。
これは当たり前のように思えるかもしれませんが、実は Swift Concurrency において子タスクを生成するもう一つの方法である async let
は別の方針で「子タスクは親タスクよりも長生きできない」原則を実現しています。スコープを抜ける前に await
されない子タスクを暗黙的にキャンセルする、という方針です。
async let
の振る舞いも実際に見てみます。以下のコードでも Task Groups と同じように double
を3回呼んで、 await
しないままスコープを抜けようとしています。
func execAsyncLets() async throws {
async let n1 = double(number: 1)
async let n2 = double(number: 2)
async let n3 = double(number: 3)
print("finishing execAsyncLets")
}
execAsyncLets
を実行してみると以下の出力が得られます。
finishing execAsyncLets
Task Groups の場合と異なり、 returning [n]
の出力は行われません。これは、execAsyncLets
のスコープを抜けるときに double
の呼び出しが await
されずにキャンセルされるためです。キャンセルが実際に発生していることは、例えば以下の print
デバッグで確認することができます。
func double(number: Int) async throws -> Int {
+ do {
try await Task.sleep(nanoseconds: 1_000_000_000)
+ } catch {
+ if Task.isCancelled {
+ print("double cancelled with error: \(error)")
+ throw error
+ }
+ }
let doubled = number * 2
print("returning \(doubled)")
return doubled
}
上記の print
を足して再度 execAsyncLets
を実行すると出力は以下のように変わります。
finishing execAsyncLets
double cancelled with error: CancellationError()
double cancelled with error: CancellationError()
double cancelled with error: CancellationError()
execAsyncLets
のスコープを抜ける際に3つの子タスクがすべてキャンセルされていることがわかります。
以上のように、
-
async let
はスコープを抜ける際にawait
されていない子タスクをキャンセルする - Task Groups はスコープを抜ける際に
await
されていない子タスクをawait
する
という違いがあることは認識しておいた方がいいと思います。この違いのため、 Task Groups では Collection に対する値を返さない処理をやりやすくなっています。例えば、以下のように Array
の要素に対して書き込み処理を行いたいとき、個々の子タスクが値を返さないので addTask
する処理を返すのが自然です。何も書かなくても暗黙的にすべての子タスクを await
してくれることによりすべての save
が完了してからスコープを抜けることが保証されています。
func saveEntities(entities: [Entity]) async throws {
await withTaskGroup(of: Void.self) { group in
for entity in entities {
group.addTask {
await Database.save(entity)
}
}
// ✅ 何も書かなくてもすべての save が完了するのを待ってくれる
}
}
このような場合にすべての子タスクが終了してから何かをやりたいという場合は waitForAll
を使うこともできます。
func saveEntities(entities: [Entity]) async throws {
await withTaskGroup(of: Void.self) { group in
for entity in entities {
group.addTask {
await Database.save(entity)
}
}
+ await group.waitForAll()
+ print("finished all tasks")
}
}
for await _ in group {}
と書いても同じ動作になりますが、 waitForAll
を使った方がより明確かつ簡潔に書くことができます。
Task Groups のキャンセル
記事のここまでの例では、子タスクが失敗したり Task Group を生成した親タスクがキャンセルされたりといった可能性にはあまり触れてきませんでした。ここで Task Group のキャンセル周りの話を見ていきます。
Task Groups がキャンセルされるのは以下の3つの場合です。
-
withThrowingTaskGroup
のbody
がエラーを投げた場合 - Task Group を生成したタスクがキャンセルされた場合
-
cancelAll
が呼ばれた場合
withThrowingTaskGroup
の body
がエラーを投げた場合
Structured Concurrency において、タスクツリーの中のどこかでエラーが投げられるとツリー内のすべてのタスクがキャンセルされるまでこのエラーが伝播します。 Task Groups でも同様で、 withThrowingTaskGroup
の body
内でエラーが投げられた場合は Task Group のまだ完了していないすべての子タスクがキャンセルされます。
例として以下のコードを見てみます。
- 1秒後に
1
を返す - 2秒後に
CancellationError
を投げる - 3秒後に
3
を返す
という3つの子タスクを生成して、結果を for try await ... in
で待ち受けています。
func printNumbers() async throws {
do {
let result = try await withThrowingTaskGroup(of: Int.self) { group in
group.addTask {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("returning 1")
return 1
}
group.addTask {
try await Task.sleep(nanoseconds: 2_000_000_000)
print("throwing error")
throw CancellationError()
}
group.addTask {
try await Task.sleep(nanoseconds: 3_000_000_000)
print("returning 3")
return 3
}
var result: [Int] = []
for try await value in group {
result.append(value)
}
return result
}
print("task group completed: \(result)")
} catch {
print("task group cancelled")
}
}
実行してみると以下の出力が得られます。
returning 1
throwing error
task group cancelled
1
が返された後にエラーが投げられて Task Group がキャンセルされています。Task Group のすべての子タスクもキャンセルされるため returning 3
は出力されていないことがわかります。
注意すべき点として、キャンセルが発生するのはあくまで body
の中でエラーが投げられた場合のみです。特定の子タスクがキャンセルされただけではそのタスクが結果を返さないだけで、 Task Group 全体に影響を及ぼすことはありません。上記の例で Task Group がキャンセルされているのは子タスクが投げたエラーが body
直下の for try await ... in
で再び投げられることが原因であって、子タスクがエラーを投げたこと自体はキャンセルにつながらないということです。これを確認するため、前の項でも紹介した nextResult
を使って子タスクが投げたエラーをハンドリングしながら結果を待ち受けてみます。
func printNumbers() async throws {
do {
let result = try await withThrowingTaskGroup(of: Int.self) { group in
group.addTask {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("returning 1")
return 1
}
group.addTask {
try await Task.sleep(nanoseconds: 2_000_000_000)
print("throwing error")
throw CancellationError()
}
group.addTask {
try await Task.sleep(nanoseconds: 3_000_000_000)
print("returning 3")
return 3
}
var result: [Int] = []
- for try await value in group {
- result.append(value)
- }
+ while let nextResult = await group.nextResult() {
+ switch nextResult {
+ case .success(let value):
+ result.append(value)
+ case .failure(let error):
+ print("ignoring error: \(error)")
+ }
+ }
return result
}
print("task group completed: \(result)")
} catch {
print("task group cancelled")
}
}
nextResult
の結果を switch
して、子タスクが投げたエラーを無視するようになっています。実行してみると、以下の出力が得られます。
returning 1
throwing error
ignoring error: CancellationError()
returning 3
task group completed: [1, 3]
エラーを投げた子タスクは無視されて Task Group の実行が続き、最終的に [1, 3]
というエラーを無視したすべての子タスクの結果が返されています。このようにエラーに対して柔軟に対応できる場合もあるので、子タスクがエラーを投げたことは直接キャンセルにつながらないことは重要です。
Task Group を生成したタスクがキャンセルされた場合
Structured Concurrency の仕組みに従って、 Task Group を生成したタスクがキャンセルされた場合は Task Group もキャンセルされます。
例として、前項で使った printNumbers
からエラーを投げる処理を消したものを使います。
func printNumbers() async throws {
do {
let result = try await withThrowingTaskGroup(of: Int.self) { group in
group.addTask {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("returning 1")
return 1
}
group.addTask {
try await Task.sleep(nanoseconds: 2_000_000_000)
print("returning 2")
return 2
}
group.addTask {
try await Task.sleep(nanoseconds: 3_000_000_000)
print("returning 3")
return 3
}
var result: [Int] = []
for try await value in group {
result.append(value)
}
return result
}
print("task group completed: \(result)")
} catch {
print("task group cancelled")
}
}
この printNumbers
を実行開始 2.5 秒後にキャンセルするとどうなるかを見てみます。
func f() async throws {
let targetTask = Task {
try await printNumbers()
}
let cancellingTask = Task {
try await Task.sleep(nanoseconds: 2_500_000_000)
targetTask.cancel()
}
try await (targetTask.value, cancellingTask.value)
}
得られる出力は以下です。
returning 1
returning 2
task group cancelled
Task Group の3つ目の子タスクが 3
を返す前に Task Group を生成したタスクがキャンセルされているため、 Task Group もキャンセルされていることがわかります。
関連して、 withTaskGroup
とキャンセルの関係についても整理しておきます。 withTaskGroup
は withThrowingTaskGroup
と違って body
でエラーを投げることができないので一見キャンセルに何も対応できないように思えるかもしれません。もちろんエラーを投げることができないので自分自身がキャンセルの起点になることはできませんが、タスクツリー上の他のタスクがエラーを投げたときにそれに対応して実行を終了したり、部分的な結果を返したりするという形で対応することができます。その例として、 printNumbers
で子タスクのシグネチャがエラーを投げないよう、以下のように変更します。
func printNumbersWithoutThrowing() async throws {
let result = await withTaskGroup(of: Int?.self) { group in
group.addTask {
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("returning 1")
return 1
} catch {
print("receiving error, returning nil")
return nil
}
}
group.addTask {
do {
try await Task.sleep(nanoseconds: 2_000_000_000)
print("returning 2")
return 2
} catch {
print("receiving error, returning nil")
return nil
}
}
group.addTask {
do {
try await Task.sleep(nanoseconds: 3_000_000_000)
print("returning 3")
return 3
} catch {
print("receiving error, returning nil")
return nil
}
}
var result: [Int?] = []
for await value in group {
result.append(value)
}
return result
}
print("task group completed: \(result)")
}
addTask
で追加する子タスクの中でエラーを catch
して nil
を返すことで、子タスク自体はエラーを投げなくなります。これにより withThrowingTaskGroup
ではなく withTaskGroup
を使うことができるようになり、関連する try
も外せています。
この printNumbersWithoutThrowing
を 2.5 秒後にキャンセルするとどうなるか見てみます。
func f() async throws {
let targetTask = Task {
try await printNumbersWithoutThrowing()
}
let cancellingTask = Task {
try await Task.sleep(nanoseconds: 2_500_000_000)
targetTask.cancel()
}
try await (targetTask.value, cancellingTask.value)
}
実行結果は以下のようになります。
returning 1
returning 2
receiving error, returning nil
task group completed: [Optional(1), Optional(2), nil]
キャンセルを受けた時点で完了していない Task Group の子タスクは3つ目のみですが、これはキャンセルを無視することなく即座に nil
を返しています。結果として、 printNumbersWithoutThrowing
はすべての子タスクをまとめた完全な結果を得ることはできてはいませんが、キャンセルを受けた時点でできる限りの結果を print
しています。
以上のように、 withTaskGroup
でエラーを投げない子タスクを生成した場合もそれ自身がキャンセルを発生させないだけで親タスクから伝播してきたキャンセルを適切にハンドリングすることが可能です。とはいえ、キャンセルを無視して実行を続ける子タスクも考えることはできるので、キャンセル時の振る舞いは子タスクの実装次第ではあります。
cancelAll
が呼ばれた場合
子タスクでエラーが発生したわけではないが Task Group からキャンセルを実行したい場合に body
内で TaskGroup
の cancelAll
メソッドを呼びます。
cancelAll
が有用な例として、タイムアウトを設定しつつ非同期処理を実行する場合があります。
func getNumber() async throws -> Int { /* ... */ }
func getNumberWithTimeout(timeoutNanoseconds: UInt64) async throws -> Int {
try await withThrowingTaskGroup(of: Int.self) { group in
group.addTask {
try await getNumber()
}
group.addTask {
try await Task.sleep(nanoseconds: timeoutNanoseconds)
throw CancellationError()
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
Task Group は子タスクを2つ生成します。1つは本来実行したい処理である getNumber
で、もう1つはタイムアウト用のタスクです。タイムアウト用のタスクは指定された時間だけ待ってから CancellationError
を投げることで、 getNumber
に想定外に時間がかかったときに getNumberWithTimeout
の呼び出し元を待たせることなく処理をキャンセルすることができます。
もし getNumber
が無事タイムアウト前に終わった場合は try await group.next()!
で結果を得られます。注意点として、その直後に group.cancelAll()
で未完了の子タスクをキャンセルしています。この場合の未完了の子タスクというのはタイムアウト用のタスクのことです。
cancelAll
の呼び出しがない場合にどうなるか考えてみます。すでに説明したように、 Task Groups は body
を抜ける時点で await
されていない子タスクを暗黙的に await
します。 cancelAll
でキャンセルしない限りタイムアウト用の子タスクは await
されないまま残るので、例えば getNumber
が1秒で完了する場合でもタイムアウトに 10 秒が設定されていた場合に getNumberWithTimeout
の完了には 10 秒かかってしまうことになります。これを避けるために cancelAll
が使われています。
参考
- SE-0304 Structured concurrency
- Modern Concurrency in Swift
- Running tasks in parallel with Swift Concurrency’s task groups
-
もちろんこれは典型的に Task Groups が有効な場面であって、固定の数の処理や、異なる型を返す処理に対しても Task Groups を使いたくなる場面はあります。 ↩