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

# はじめに
複数のasync throwsなメソッドを並列実行させるときに、一部はエラーを無視する、一部はエラーをキャッチしたい、というような制御を行うにはどうすればいいだろう？と疑問に思ったのがきっかけで、async letでtryを使う様々なパターンを試してみました。

# 実験コードの全体
```swift
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個ずつ書く
```swift
_ = 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

<br>

```swift
_ = 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でエラーを無視する
```swift
_ = 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

<br>

```swift
_ = 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つの式にまとめてみます。

<br>

```swift
_ = 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

<br>

```swift
_ = 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

<br>

```swift
_ = 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

<br>

```swift
_ = 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

<br>

```swift
_ = 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してみます。

<br>

```swift
_ = 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

<br>

```swift
_ = 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`であることがわかります。

```swift
let n = try? await (num1, num3)
let n5 = try await num5
print(n?.0, n?.1, n5)
```

> nil nil ()

<br>

```swift
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`だけ実際にエラーをスローします。

```swift
_ = 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

期待通りの結果ですね。

<br>

エラーを無視するものとキャッチしたいものとをタプルで分けるとしましょう。

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

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