🏎️

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

具体例

  • 並列に実行した処理のうち、終わるのが速かった処理の結果だけを用いたい場合
    👉 TaskGroupThrowingTaskGroup
    /// `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 ... 構文と TaskGroupThrowingTaskGroup) を使った方法を見てみます。

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(クロージャ)で得られる TaskGroupAsyncSequence に適合しており[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` に入る

lhsrhs に渡された非同期処理を実行するクロージャを、(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 のタプルにある result3result4 の順番を入れ替えた 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
脚注
  1. ChildTaskResultSendableに適合している場合 ↩︎

DeNA Engineers

DeNAに所属するエンジニアの個人記事を集めています。 記事内容はあくまでも個人の見解であり、会社としてレビュー等はしておりません。あらかじめご了承ください。

Discussion

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

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

質問の的を得ているかわかりませんが、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
  }

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 を用いて、非同期に結果を受け取るためです。 ↩︎

ログインするとコメントできます