🏎️

Swift の「async let ...」と「TaskGroup」の使い分け早見表

に公開
3
DeNA Engineers

Discussion

uhooiuhooi

TaskGroup 使ったことないマンだからありがたい記事…!

1つ気になったのが、最初の早見表の「2つ以上(静的)」「1つ以上エラーの可能性がある場合(throws がある場合)」の値が「 ThrowingTaskGroup 」になっているけど、「 let ... = try await ... または ThrowingTaskGroup 」になっていないのは、記事の通りエラーを個別に扱いたくないから?
理論上はできるはずだから気になりました!

zundazunda

質問の的を得ているかわかりませんが、withThrowingTaskGroupはどれかが失敗すると、全てのタスクを実行中でも終了させてくれるけど、async letだとすでに始まってしまったタスクは止められませんでした。

なのでどの場面においても有効な手段にはならないはずです(多分)

コードで示すと以下になると思います。

  • dangerFetch()はgetValue(2)の時にthrowされるが、1がprintされる(getValue(1)のタスクが残っているため)
  • safeFetch()はgetValue(2)の時にthrowされて、先に実行していたgetValue(1)のタスクもキャンセルされるため1がprintされない
  func getValue(_ value: Int) async throws -> Int {
    if value == 2 { throw NSError(domain: "Some Error", code: 1) }
    try await Task.sleep(for: .seconds(value))
    print(value)
    return value
  }

  func safeFetch() async throws -> Int {
    try await withThrowingTaskGroup(of: Int.self) { group in
      group.addTask { try await getValue(1) }
      group.addTask { try await getValue(2) }
      group.addTask { try await getValue(3) }
      
      var values: [Int] = []
      
      for try await value in group {
        values.append(value)
      }
      
      return values.reduce(into: 0) { $0 = $0 + $1 }
    }
  }
  
  func dangerFetch() async throws -> Int {
    async let value1 = try await getValue(1)
    async let value2 = try await getValue(2)
    async let value3 = try await getValue(3)
    
    return try await value1 + value2 + value3
  }
treastrain / Tanaka Ryogatreastrain / Tanaka Ryoga

1つ気になったのが、最初の早見表の「2つ以上(静的)」「1つ以上エラーの可能性がある場合(throws がある場合)」の値が「 ThrowingTaskGroup 」になっているけど、「 let ... = try await ... または ThrowingTaskGroup 」になっていないのは、記事の通りエラーを個別に扱いたくないから?
理論上はできるはずだから気になりました!

コメントありがとうございます!
おっしゃる通り、早見表の「2つ以上(静的)」「1つ以上エラーの可能性がある場合(throws がある場合)」において、ThrowingTaskGroup のほかに async let ... = try await ...(または async let ... = await ...)構文を用いることができます。

しかし、「async なモノの数」が2つ以上の場合で、かついずれのエラーを無視できない場合、それらを取り扱っている場所からすると、さっさと処理を中断してエラーを投げてあげるのが求められる実装かなと思っています。


exec1()exec2() のそれぞれ return 文の部分に注目します。Swift のタプルは前方から順番に処理が行われます。

func exec1() async throws -> (Int, Int) {
    async let result3 = try num3()
    async let result4 = try num4()
    /// タプルの要素の順番は「3」、「4」
    return try await (result3, result4)
}

func exec2() async throws -> (Int, Int) {
    async let result3 = try num3()
    async let result4 = try num4()
    /// タプルの要素の順番は「4」、「3」
    return try await (result4, result3)
}

まず、exec1() です。こちらは result3result4 の順に処理されます。
num3() の実行から3秒後にエラーが投げられるので、result3 は「エラー」となります。この時点で num4() は実行中ですが「キャンセル」されます。
これで return 文のところでサスペンドしているものが無くなり、結果として「exec1() は3秒経過の後、num3() のエラーを投げる」挙動となります。

つづいて exec2() です。こちらは result4result3 の順に処理されます。
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 のみを掲載させていただいていました。

脚注
  1. 本文中の このコード例では、num3()・num4() のそれぞれ処理にかかる時間がわかっているので問題にはなりませんが、実際の場合、非同期処理は処理時間が予測できないパターンが多いでしょう。 の部分です。 ↩︎

  2. ThrowingTaskGroup が適合している AsyncSequence を用いて、非同期に結果を受け取るためです。 ↩︎