はじめに
複数のasync throwsなメソッドを並列実行させるときに、一部はエラーを無視する、一部はエラーをキャッチしたい、というような制御を行うにはどうすればいいだろう?と疑問に思ったのがきっかけで、async letでtryを使う様々なパターンを試してみました。
実験コードの全体
import Foundation
struct TestError: Error {}
let start = Date()
func main() {
Task {
do {
async let num1: Void = log("num1", duration: .seconds(1))
async let num3: Void = log("num3", duration: .seconds(3))
async let num5: Void = log("num5", duration: .seconds(5))
// ここでどうawaitするか?
} catch {
print("\(time()), \(error)")
}
}
Thread.sleep(forTimeInterval: 7)
print("\(time()), finish")
}
func log(_ x: String, duration: Duration) async throws {
print("\(x) start")
if x == "num1" {
try? await Task.sleep(for: .seconds(1))
throw TestError()
}
do {
try await Task.sleep(for: duration)
print("\(time()), \(x) end")
} catch {
print("\(time()), \(x) \(error)")
throw error
}
}
func time() -> String {
String(format: "%.2f", start.distance(to: Date()))
}
main()
ここでは、main
メソッド直下のTaskを親タスク、各async letの式を子タスクと表現することにします。
前提知識
いずれかの子タスクでエラーがスローされると、同階層の子タスクは自動的にキャンセルされます。
ただし、キャンセルされるといってもそこで子タスクの処理が即終了するのではなく、Task.checkCancellation()
等でキャンセル状態のチェックを行い、キャンセルされていたら処理を終了するという仕組みが必要です。
Task.sleep(for:)
はこの仕組みを備えています。指定された時間スリープしますが、タスクがキャンセルされると即座にCancellationError
をスローして処理を終了します。
try awaitを1個ずつ書く
_ = try await num1
_ = try await num3
_ = try await num5
1秒後にnum1
がエラーをスローした時点で他の子タスクがキャンセルされます。
num1 start
num3 start
num5 start
1.05, num5 CancellationError()
1.05, num3 CancellationError()
1.05, TestError()
7.01, finish
_ = try await num3
_ = try await num1
_ = try await num5
num1
は1秒後にエラーをスローしますが、num3
が終了するまで待つようです。
3秒後にnum3
が正常に終了し、直後にnum1
がエラーをスローするので、その時点でnum5
はキャンセルされます。
num1 start
num3 start
num5 start
3.20, num3 end
3.20, num5 CancellationError()
3.20, TestError()
7.00, finish
try? awaitでエラーを無視する
_ = try? await num1
_ = try await num3
_ = try await num5
try? awaitしているのでnum1
のエラーは無視され、num3
とnum5
が最後まで実行されます。
num1 start
num3 start
num5 start
3.20, num3 end
5.13, num5 end
7.01, finish
_ = try await num3
_ = try? await num1
_ = try await num5
順番を変えても結果は同じです。
num1 start
num3 start
num5 start
3.20, num3 end
5.11, num5 end
7.01, finish
タプルをtry awaitする
タプルで子タスクを1つの式にまとめてみます。
_ = try await (num1, num3)
_ = try await num5
1秒後にnum1
がエラーをスローし、その時点でnum3
がキャンセルされるようです。
さらに別の子タスクであるnum5
も同時にキャンセルされます。
num1 start
num3 start
num5 start
1.06, num5 CancellationError()
1.06, num3 CancellationError()
1.06, TestError()
7.00, finish
_ = try await (num3, num1)
_ = try await num5
num1
は1秒後にエラーをスローしますが、num3
が終了するまで待ちます。
num3
が終了した後にnum1
の結果が評価され、(表現合ってるか微妙ですが)タプルの式としてエラーをスローしたことになるようです。
3秒後にエラーがスローされたので、その時点でnum5
もキャンセルされます。
num1 start
num3 start
num5 start
3.09, num3 end
3.09, num5 CancellationError()
3.09, TestError()
7.00, finish
_ = try await (num5, num1)
_ = try await num3
1つ前のケースとパターンは同じですが、1行目がエラーをスローするのが5秒後なので、num3
は最後まで実行されています(「num3 end」が出力されている)。
num5 start
num3 start
num1 start
3.16, num3 end
5.05, num5 end
5.05, TestError()
7.00, finish
_ = try await (num3, num5)
_ = try await num1
num1
は1秒後にエラーをスローしますが、タプルの結果を待つので、TestError
は5秒後に出力されています。
num1 start
num5 start
num3 start
3.16, num3 end
5.33, num5 end
5.34, TestError()
7.01, finish
_ = try await (num5, num3)
_ = try await num1
1つ前のケースとパターンは同じです。
慣れないとうっかり「num5 end」が先に出力されると思ってしまいますが、3つのタスクはあくまで同時並列で実行されているので、3秒後に「num3 end」、5秒後に「num5 end」が出力されます。
num1 start
num3 start
num5 start
3.01, num3 end
5.33, num5 end
5.33, TestError()
7.00, finish
タプルをtry? awaitする
タプルで子タスクを1つの式にまとめ、try? awaitしてみます。
_ = try? await (num1, num5)
_ = try await num3
このパターンは僕が思っていた結果と違いました。
1秒後にnum1
がエラーをスローしますが、try?してるためかnum5
はキャンセルされず処理を継続します。
しかし、タプルの式としては評価が完了しているようで、1秒後にnum3
に移ります。
num3
は3秒後に正常終了し、この時点で子タスクの処理は全て完了したとみなされてスコープを抜けます。
スコープを抜けたのでその時点でnum5
はキャンセルされます。
その結果、3秒後にnum5
がCancellationError
をスローします。
num1
のエラーは無視して、num5
とnum3
が最後まで実行される、もしくはnum5
は1秒後にキャンセルされるのかなと予想してましたが、違う結果になりました。
num1 start
num3 start
num5 start
3.20, num3 end
3.20, num5 CancellationError()
7.01, finish
_ = try? await (num1, num3)
_ = try await num5
num3
とnum5
が正常終了しているように見えますが、1つ前のケースで見たように、タプルは1秒後にnum1
でエラーがスローされ結果がnil
としてすでに評価されています。
後続のnum5
が実行に5秒かかるためたまたま「num3 end」が出力されていますが、num3
は最後まで実行されることが保証されていないので要注意です。
num5 start
num3 start
num1 start
3.20, num3 end
5.22, num5 end
7.01, finish
なお、以下のように戻り値を出力してみると、タプルのどちらの値もnil
であることがわかります。
let n = try? await (num1, num3)
let n5 = try await num5
print(n?.0, n?.1, n5)
nil nil ()
let n = try? await (num5, num1)
let n3 = try await num3
print(n?.0, n?.1, n)
num5
の実行に5秒かかったあと、num1
が評価されタプルの結果が(nil, nil)
になります。
num5
自体は正常終了しますが、返される値はnil
になるようです。
num1 start
num3 start
num5 start
3.20, num3 end
5.33, num5 end
nil nil ()
7.01, finish
「一部はエラーを無視する、一部はエラーをキャッチしたい」をどう実装するか?
ここで最初の問いに戻りましょう。
一部はエラーを無視する、一部はエラーをキャッチしたいという要件をどう実装すればいいでしょうか?
シンプルに、try awaitを1個ずつ書くというのが正解だと思います。
num1
、num7
はエラーを無視する、num3
、num5
はエラーをキャッチしたいとします。
num1
だけ実際にエラーをスローします。
_ = try? await num1
_ = try? await num7
_ = try await num3
_ = try await num5
num1 start
num3 start
num5 start
num7 start
3.20, num3 end
5.08, num5 end
7.34, num7 end
期待通りの結果ですね。
エラーを無視するものとキャッチしたいものとをタプルで分けるとしましょう。
_ = try? await (num1, num7)
_ = try await (num3, num5)
num1 start
num7 start
num5 start
num3 start
3.20, num3 end
5.06, num5 end
5.06, num7 CancellationError()
8.00, finish
こうすると、num7
の実行がキャンセルされてしまいます。
エラーは無視して良いが、エラーがスローされないなら最後まで実行されてほしいので、タプルを使うと想定外の結果になってしまいます。
おわりに
戻り値の型がVoidであるメソッドを題材にしたのがちょっとわかりづらかったかもしれません。
今回の実験をするに至った背景ですが、async letでログ送信とビジネスロジックを並列に実行するようなコードを書いていて、ログ送信はエラーになっても無視して良いけど、ビジネスロジックはエラーハンドリングしたいというケースがあり、そのときにタプルを使ってまとめても良いのかどうか気になったといのがきっかけです。
本記事が少しでも読んだ方の参考になれば幸いです。