Swift の「async let ...」と「TaskGroup」の使い分け早見表
この記事は Swift Advent Calendar 2022 の22日目の記事です。
この記事は弊社社内勉強会 SwiftWednesday の LT 会(2023年1月18日実施)で資料として使用されました。
async let ...
」と「TaskGroup
」の使い分け早見表
「
async なモノの数 |
1つもエラーの可能性がない場合 | 1つ以上エラーの可能性がある場合(throws がある場合) |
---|---|---|
0 | let ... = ... |
let ... = try ... |
1つ | let ... = await ... |
let ... = try await ... |
2つ以上(静的) |
async let ... = ... または TaskGroup
|
ThrowingTaskGroup |
2つ以上(動的) | TaskGroup |
ThrowingTaskGroup |
具体例
- 並列に実行した処理のうち、終わるのが速かった処理の結果だけを用いたい場合
👉TaskGroup
・ThrowingTaskGroup
/// `TaskGroup` を使って実装された `race(lhs:rhs:)` /// 本文中に実装を掲載しています let result = await race( lhs: { try? await Task.sleep(for: .seconds(100)) return 100 }, rhs: { try? await Task.sleep(for: .seconds(1)) return 1 } ) print(result) // 1
async
なモノ(関数・メソッド・プロパティ・イニシャライザ)
この記事では、共通して以下の関数を用いることとします。
/// 同期的に `Int` が返ります(処理に0秒かかるとする、同期関数)
func num0() -> Int {
return 0
}
/// 非同期的に `Int` が返ります(処理に1秒かかるとする、非同期関数)
func num1() async -> Int {
try! await Task.sleep(for: .seconds(1))
return 1
}
/// 非同期的に `Int` が返ります(処理に2秒かかるとする、非同期関数)
func num2() async -> Int {
try! await Task.sleep(for: .seconds(2))
return 2
}
/// 非同期的に `Int` が返ることになっているがエラーが返ります(処理に3秒かかるとする、エラーの可能性もある非同期関数)
func num3() async throws -> Int {
try await Task.sleep(for: .seconds(3))
throw NSError()
}
/// 非同期的に `Int` が返ります(処理に4秒かかるとする、エラーの可能性もある非同期関数)
func num4() async throws -> Int {
try await Task.sleep(for: .seconds(4))
return 4
}
let ... = await ...
の基本
同期関数はこのように呼んでいました。
let resultNum0 = num0()
対して、非同期関数はこのように呼びます。
let resultNum1 = await num1()
では、num1()
と num2()
をこのように記述したとき、どのような挙動となるでしょうか。
let resultNum1 = await num1()
print("print No.1")
let resultNum2 = await num2()
print("print No.2")
print(resultNum1, resultNum2)
これは以下のようなイメージになります。
時間軸はイメージです
値が返ってくるまでそれぞれ1秒、2秒を要する num1()
・num2()
の実行を順番に行います。この場合、実行完了までにかかる時間は約3秒となります。
非同期関数の並列呼び出し
前項の例では num1()
と num2()
を直列で実行したことで、実行完了までにかかる時間が約3秒となっていましたが、num1()
と num2()
を並列で実行できれば、トータルで見たときの処理時間の短縮が見込まれます。
この記事では、async let ...
構文と TaskGroup
(ThrowingTaskGroup
) を使った方法を見てみます。
async let ...
の基本
まずは async let ...
の構文です。
async let resultNum1 = num1()
print("print No.1")
async let resultNum2 = num2()
print("print No.2")
let results = await (resultNum1, resultNum2)
print(results)
時間軸はイメージです
こちらの例を実行すると、コンソールには "print No.1"
と "print No.2"
がすぐに出力され、約2秒後に 1, 2
が出力されます。実行完了までにかかる時間は先ほどと異なり約2秒となります。num1()
と num2()
が並列に実行されるようになったためです。
TaskGroup
の基本
「async let ...
の基本」で示したものとほぼ同じ挙動を示すコードを、TaskGroup
を用いて示します。挙動をわかりやすくするために新しく「print No.3
」を追加しています。
let results = await withTaskGroup(of: (Int, Int).self) { group in
var results: [Int : Int] = [:]
group.addTask {
print("print No.1")
return (1, await num1())
}
group.addTask {
print("print No.2")
return (2, await num2())
}
for await (index, result) in group {
print("print No.3:", index)
results[index] = result
}
return results
}
print(results[1]!, results[2]!)
時間軸はイメージです
async let ...
のときよりも記述量が多くなりましたが、挙動はほぼ変わっていません。withTaskGroup(of:returning:body:)
の body
(クロージャ)で得られる TaskGroup
に、addTask(priority:operation:)
を使って並列に実行したい処理を追加していきます。
body
(クロージャ)で得られる TaskGroup
は AsyncSequence
に適合しており[1]、今回であれば group
に追加した処理が終了した順に、for await ... in ...
などでその結果を取り出すことができます。今回は num1()
と num2()
とでは、num1()
の方が早く処理が終わることがわかっていますが、実例ではそれが逆転する場合もあるでしょう。つまり for await (index, result) in group
で得られる順番は group
に処理を追加した順番と一致するとは限りません。
// 先に記述した処理
group.addTask {
try! await Task.sleep(for: .seconds(10)) // 10秒かかる
return 10 // "先に記述した処理"の結果
}
// 後に記述した処理
group.addTask {
try! await Task.sleep(for: .seconds(5)) // 5秒かかる
return 5 // "後に記述した処理"の結果
}
for await ... in group {
// ここにくるのは「"後に記述した処理"の結果」→「"先に記述した処理"の結果」の順番となる
}
そのため、今回は withTaskGroup(of:returning:body:)
の body
(クロージャ)内で addTask(priority:operation:)
を使うよりも前に results: [Int : Int]
という Dictionary
を定義しておき、Key
に処理を記述した順番、Value
にその処理の結果を一時的に収めるようにしました。そして、いちばん最後にそれらを出力しています。
async let ...
ではなく TaskGroup
を使う例
ここまでを見ると、ほぼ同じ処理を行うのに記述量が多い TaskGroup
よりも、もっとシンプルに記述できる async let ...
を用いた方が簡便に思えます。しかし、async let ...
では実現できないパターンもあり、それを行いたい場合は TaskGroup
を使うことになります。
並列に実行したい処理の数が動的に変わる可能性がある場合
以下のような total
の回数だけ並列に実行したい処理がある場合、total
の値は一意である保証がありません。
var total = 50
var results: [Int : Int] = [:]
for index in 0..<total {
async let resultNum1 = num1()
results[index] = await resultNum1 // `await` をつけなければ「Expression is 'async' but is not marked with 'await'」とエラーになる
}
print(results) // 出力されるまで約 (total = 50) 秒かかる
ここで async let ...
の構文を用いようとしても、results
に値を一旦収めようとする際に await
が必要になるため、結果として処理を並列に行うことができず、最後の出力が行われるまで total
秒かかってしまいます。
var total = 50
let results = await withTaskGroup(of: (Int, Int).self) { group in
var results: [Int : Int] = [:]
for index in 0..<total {
group.addTask {
(index, await num1())
}
}
for await (index, result) in group {
results[index] = result
}
return results
}
print(results) // 出力されるまで約 (num1()にかかる時間 = 1) 秒だけかかる
しかし、TaskGroup
を使うことによって num1()
をただひたすらに子タスクに追加していく処理(Task.addTask(priority:operation:)
)を行い、その子タスクたちが終わった順に結果を for await ... in ...
で取り出す… という書き方が簡単に行えます。上記のコードであれば、
for index in 0..<total {
group.addTask {
(index, await num1())
}
}
の部分で、処理を並列に行うようにしています。
並列に実行した処理のうち、終わるのが速かった処理の結果だけを用いたい場合
以下のような、2つの非同期の処理をほぼ同時に並列で実行させ、終わるのが速かった一方の結果だけを返す race(lhs:rhs:)
の挙動を見てみます。
func race<Result>(
lhs: @escaping () async -> Result,
rhs: @escaping () async -> Result
) async -> Result {
await withTaskGroup(of: Result.self) { group in
group.addTask { await lhs() }
group.addTask { await rhs() }
let result = await group.next()!
group.cancelAll()
return result
}
}
// 使い方
let result = await race(
lhs: {
// 実行したい処理1
},
rhs: {
// 実行したい処理2
}
)
print(result) // 「実行したい処理1」と「実行したい処理2」の速かった方の結果が `result` に入る
lhs
と rhs
に渡された非同期処理を実行するクロージャを、(TaskGroup.addTask(priority:operation:)
) を用いて子タスクに追加します。
これを TaskGroup.next()
を使って、先に処理が終わった方の結果を1つ、受け取ります。
今回は「終わるのが速かった処理の結果だけ」が欲しいので、TaskGroup.cancelAll()
を呼び出して、それが確定した時点で他の子タスクはキャンセルしてあげましょう。
このような処理は async let ...
の構文では実現できないため、TaskGroup
を用います。
async let ...
ではなく ThrowingTaskGroup
を使う
ここまでのコード例では TaskGroup
を用いてきましたが、エラーが発生する可能性のある場合は ThrowingTaskGroup
を使います。
エラーが発生する可能性のある場合でも、async let ...
というように記述することはできますが、エラー発生時の取り扱いの観点から、私個人としては async let ...
の使用は避け、ThrowingTaskGroup
を使いたいと考えています。
並列に実行している処理のうち、いずれか1つがエラーになったらその時点でエラーを返してほしい場合
func exec1() async throws -> (Int, Int) {
/// 処理に3秒かかってエラーが投げられる
async let result3 = try num3()
/// 処理に4秒かかって `Int` が返る
async let result4 = try num4()
/// タプルの要素の順番は「3」、「4」
return try await (result3, result4)
}
この exec1()
を実行すると、async let ...
によって num3()
・num4()
は並列に実行されるため、3秒経過した時点で num3()
がエラーを投げます。これにより result3
がエラーとなるため、全体の処理もエラーとなって3秒で終わります。このとき実行中だった num4()
はキャンセルされます。
このコード例では、num3()
・num4()
のそれぞれ処理にかかる時間がわかっているので問題にはなりませんが、実際の場合、非同期処理は処理時間が予測できないパターンが多いでしょう。では、最後の return
のタプルにある result3
・result4
の順番を入れ替えた exec2()
を見てみましょう。
func exec2() async throws -> (Int, Int) {
/// 処理に3秒かかってエラーが投げられる
async let result3 = try num3()
/// 処理に4秒かかって `Int` が返る
async let result4 = try num4()
/// タプルの要素の順番は「4」、「3」
return try await (result4, result3)
}
async let ...
によって num3()
・num4()
は並列に実行されるため、3秒経過した時点で num3()
がエラーを投げます。そこでこの exec2()
もエラーになることが確定します。しかし、その時点では num4()
の処理が終わっていない(かつキャンセルされない)ために return
のタプルの最初の要素である result4
の結果を待つこととなり、4秒経過した時点でやっと num3()
側のエラーが exec2()
のエラーとして投げられて処理が終了します。
この「全体の結果としてはエラーであることがわかっているが、一部の並列実行中の処理が終わるまで待たなければならないことがある」を避けるために、ThrowingTaskGroup
を使う方法を取ります。
func exec3() async throws -> (Int, Int) {
try await withThrowingTaskGroup(of: (Int, Int).self) { group in
var results: [Int : Int] = [:]
group.addTask {
/// 処理に3秒かかってエラーが投げられる
(1, try await num3()) // 「1」は最終的に返すタプルの要素の順番を示す
}
group.addTask {
/// 処理に4秒かかって `Int` が返る
(2, try await num4()) // 「2」は最終的に返すタプルの要素の順番を示す
}
for try await (index, num) in group {
results[index] = num
}
return (results[1]!, results[2]!)
}
}
こうすることで、num3()
・num4()
はそれぞれ子タスクで並列に実行されつつ、その処理が終わった順に for try await ... in ...
に結果としてやってくるため、ここの try
でエラーをキャッチすることにより、exec3()
全体としてもエラーを投げて処理が終了します。
この記事執筆のきっかけ
参考文献
- Concurrency — The Swift Programming Language (Swift 5.7)
- swift-evolution/0317-async-let.md at main · apple/swift-evolution
Discussion
TaskGroup 使ったことないマンだからありがたい記事…!
1つ気になったのが、最初の早見表の「2つ以上(静的)」「1つ以上エラーの可能性がある場合(throws がある場合)」の値が「
ThrowingTaskGroup
」になっているけど、「let ... = try await ...
またはThrowingTaskGroup
」になっていないのは、記事の通りエラーを個別に扱いたくないから?理論上はできるはずだから気になりました!
質問の的を得ているかわかりませんが、
withThrowingTaskGroup
はどれかが失敗すると、全てのタスクを実行中でも終了させてくれるけど、async let
だとすでに始まってしまったタスクは止められませんでした。なのでどの場面においても有効な手段にはならないはずです(多分)
コードで示すと以下になると思います。
コメントありがとうございます!
おっしゃる通り、早見表の「2つ以上(静的)」「1つ以上エラーの可能性がある場合(throws がある場合)」において、
ThrowingTaskGroup
のほかにasync let ... = try await ...
(またはasync let ... = await ...
)構文を用いることができます。しかし、「
async
なモノの数」が2つ以上の場合で、かついずれのエラーを無視できない場合、それらを取り扱っている場所からすると、さっさと処理を中断してエラーを投げてあげるのが求められる実装かなと思っています。exec1()
とexec2()
のそれぞれ return 文の部分に注目します。Swift のタプルは前方から順番に処理が行われます。まず、
exec1()
です。こちらはresult3
→result4
の順に処理されます。num3()
の実行から3秒後にエラーが投げられるので、result3
は「エラー」となります。この時点でnum4()
は実行中ですが「キャンセル」されます。これで return 文のところでサスペンドしているものが無くなり、結果として「
exec1()
は3秒経過の後、num3()
のエラーを投げる」挙動となります。つづいて
exec2()
です。こちらはresult4
→result3
の順に処理されます。num4()
の実行から4秒後にInt
が返ってきます。この時点で(すでに4秒経過しているので)num3()
はすでにエラーが投げられています。これで return 文のところでサスペンドしているものが無くなったので、結果として「
exec2()
は4秒経過の後、num3()
のエラーを投げる」挙動となります。exec1()
とexec2()
は、結果としてどちらも「num3()
のエラーを投げる」のですが、それにかかる時間が「3秒」と「4秒」の違いがあります。これを「exec2()
は無駄に1秒も時間をかけてしまっている」という問題があると私は考えます。実務等で使用する場面を想像すると、num3()
とnum4()
のいずれかがエラーだった場合、もうタプルで返すことは不可能なので、もう一方のメソッドの結果は無視してとにかく早くエラーを投げたい と私は思うからです。もう少し表現を変えると、このようになるかなと思います。
num3()
とnum4()
の片方がエラーだった場合、もう一方の実行はキャンセルした上で、exec2()
としてはエラーを投げるこの「もう一方の実行はキャンセルした上で」が
async let ... = try await ...
(またはasync let ... = await ...
)構文では実現できない場合[1]があります。しかし、
ThrowingTaskGroup
を用いる方法ではこの問題が発生しないため[2]、早見表に示した私のおすすめとしてはThrowingTaskGroup
のみを掲載させていただいていました。本文中の
このコード例では、num3()・num4() のそれぞれ処理にかかる時間がわかっているので問題にはなりませんが、実際の場合、非同期処理は処理時間が予測できないパターンが多いでしょう。
の部分です。 ↩︎ThrowingTaskGroup
が適合しているAsyncSequence
を用いて、非同期に結果を受け取るためです。 ↩︎