15
7

More than 1 year has passed since last update.

async letでtryを使う様々なパターンを実験してみた

Posted at

はじめに

複数の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のエラーは無視され、num3num5が最後まで実行されます。

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秒後にnum5CancellationErrorをスローします。

num1のエラーは無視して、num5num3が最後まで実行される、もしくは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

num3num5が正常終了しているように見えますが、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個ずつ書くというのが正解だと思います。

num1num7はエラーを無視する、num3num5はエラーをキャッチしたいとします。
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でログ送信とビジネスロジックを並列に実行するようなコードを書いていて、ログ送信はエラーになっても無視して良いけど、ビジネスロジックはエラーハンドリングしたいというケースがあり、そのときにタプルを使ってまとめても良いのかどうか気になったといのがきっかけです。

本記事が少しでも読んだ方の参考になれば幸いです。

15
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
7