Swift Digest
SE-0317 | Swift Evolution

async let bindings

Proposal
SE-0317
Authors
John McCall, Joe Groff, Doug Gregor, Konrad 'ktoso' Malawski
Review Manager
Ben Cohen
Status
Implemented (Swift 5.5)

01 何が問題だったのか

SE-0304 で導入された構造化された並行性(structured concurrency)では、タスクグループ(TaskGroup / withThrowingTaskGroup)を使って複数の子タスクを並列に走らせ、その結果をまとめることができます。タスクグループは、完了順に結果を受け取ったり、動的な数の子タスクを扱ったりといった高度なパターンを表現できる強力な仕組みです。

しかし、現実のコードで頻出する次のようなケースでは、タスクグループはかなり扱いづらいものでした。

  • 並列に走らせたい子タスクが コンパイル時に数が決まっている
  • 各子タスクの 戻り値の型がバラバラ(heterogeneous)
  • 全員の結果が揃ったところで、それらを組み合わせて次の処理に渡したい

たとえば、「野菜を切る」「肉をマリネする」「オーブンを予熱する」を並列に行い、すべて揃ったところで oven.cook(...) に渡す makeDinner をタスクグループで書くと、次のようにかなり冗長になります。

func makeDinner() async throws -> Meal {
  return try await withThrowingTaskGroup(of: CookingTask.self) { group in
    group.addTask { CookingTask.veggies(try await chopVegetables()) }
    group.addTask { CookingTask.meat(await marinateMeat()) }
    group.addTask { CookingTask.oven(await preheatOven(temperature: 350)) }

    var veggies: [Vegetable]? = nil
    var meat: Meat? = nil
    var oven: Oven? = nil

    for try await task in group {
      switch task {
      case .veggies(let v): veggies = v
      case .meat(let m):    meat = m
      case .oven(let o):    oven = o
      }
    }

    var ingredients: [Ingredient] = veggies!
    ingredients.append(meat!)
    let dish = Dish(ingredients: ingredients)
    return try await oven!.cook(dish, duration: .hours(3))
  }
}

子タスクごとに CookingTask のような共通の enum を用意し、Optional の変数に詰め直して最後に強制アンラップする、という流れが必要で、変数を 1 つ追加するだけでもボイラープレートが増え、! による実行時クラッシュのリスクも抱えます。

やりたいことの本質は「子タスクが 1 つの値を親に返し、親はそれを普通の変数として使う」だけなのに、その素朴なデータフローを表現する軽量な構文が欠けていました。この「少数の、型の異なる子タスクを並列に走らせて結果を集める」というパターンを、let に近い感覚で安全・簡潔に書けるようにすることが求められていました。

02 どのように解決されるのか

async let という新しい宣言を導入します。let と同様にローカル定数を宣言しますが、右辺の初期化式は 別の子タスク上で並行に評価 され、親タスクはその値を使うタイミングで await して受け取ります。

冒頭の makeDinner は次のように書けます。

// func chopVegetables() async throws -> [Vegetable]
// func marinateMeat() async -> Meat
// func preheatOven(temperature: Int) async -> Oven

func makeDinner() async throws -> Meal {
  async let veggies = chopVegetables()
  async let meat = marinateMeat()
  async let oven = preheatOven(temperature: 350)

  let dish = Dish(ingredients: await [try veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))
}

子タスクは async let の行に到達した時点で起動され、デフォルトではグローバルな並行エグゼキュータ上で動きます。子タスクが正常に完了すると、その結果が左辺の定数へ束ねられます。

宣言できる場所と書き方

async letasync なコンテキスト内でのみ 宣言できます(async 関数や async クロージャの内部)。トップレベルや同期関数・同期クロージャの内部では使えません。

右辺は概念的には @Sendable かつ nonisolated なクロージャとして実行されるため、外側の non-Sendable な状態を変更することはできません。

var localText: [String] = ...
async let w = localText.removeLast() // error: mutation of captured var 'localText' in concurrently-executing code

右辺が単一の式で async 関数の呼び出しになっている場合、awaittry は省略できます。省略されたエフェクトは左辺の定数に引き継がれ、await するタイミングで try / await を書くことになります。

func order() async throws -> Order { ... }

async let o = order()    // ここでは try/await を省略できる
let x = try await o      // 使うときに try await が要る

async var は認められません(外部から書き換えられると初期化の意味が崩れるため)。また、async let はあくまで let なので inout に渡すこともできません。

タプルなどのパターンも使えます。この場合、右辺全体が 1 つの子タスクとして扱われるため、タプル内のいずれかの呼び出しが throw するなら、そのタプルを構成する どの要素を参照するときにも try が必要になります。

async let (l, r) = (left(), right())
await l // この時点で r も完了しているとみなせる

値の受け取りと暗黙の await

async let で宣言された定数を参照するときは、常に await が必要です。初期化式が throw しうる場合は、加えて try(あるいは try? / try!)も必要です。

async let name = getName()
async let surname = getSurname()
await greet(name, surname) // まとめて await でも OK

async let ohNo = throwThings()
try await ohNo

宣言したが一度も await しないままスコープを抜ける場合、スコープ終了時に 暗黙に その子タスクがキャンセルされ、完了まで待機されます。これが構造化された並行性の保証の要で、子タスクがスコープより長生きすることはありません。

func go() async {
  async let f = fast()  // 300ms
  async let s = slow()  // 3s
  return "nevermind..."
  // 暗黙に f, s をキャンセルして await する
}

したがって、go() の実行時間は常に「いちばん遅い子タスク」の時間に引きずられます。値に興味がない場合でも、子タスクを「スコープより長生きさせない」ための待機が必ず入ります。

なお、初期化式で throw された例外は、その値を一度も await しなければ呼び出し元には伝搬しません(タスクグループで子タスクの結果を回収しないのと同じ扱い)。エラーを見たいなら try await で取り出す必要があります。

クロージャに渡す際の制約

async let の値は、@escaping クロージャへ渡すことはできません。子タスク用の構造がスタック上に確保される可能性があり、スコープを越えて生き延びさせると安全性が壊れるためです。エスケープしないクロージャ(および @autoclosure)への取り込みは可能で、その場合はクロージャ内部で await を書く必要があります。

func greet(_ f: () async -> String) async -> String { await f() }

async let name = "Alice"
await greet { await name } // OK

func escape(_ f: @escaping () async -> String) { ... }
escape { await name } // error: cannot escape 'async let' value

キャンセル

async let で生成される子タスクは親タスクのキャンセルに自動的に連動します。親がキャンセルされると、async let で起動された子タスクもキャンセル状態になります。また、すでにキャンセルされたコンテキストで async let を実行すると、子タスク自体は生成されますが直ちにキャンセル済みとしてマークされます(Swift のキャンセルは協調的なので、子タスク側で Task.isCancelled 等を見て応答する責任があります)。

タスクグループとの使い分け

async let は「コンパイル時に数が決まっている少数の子タスク」を簡潔に扱うための糖衣であり、タスクグループの完全な置き換えではありません。次のようなケースでは従来どおり withTaskGroup / withThrowingTaskGroup が必要です。

  • 実行時に決まる 動的な数 の子タスクを並列に走らせたい(例: 配列の各要素に対する並列 map)
  • 完了順 に結果を受け取りたい(race(left:right:) のような「先着の 1 件」パターン)

一方で、数が固定かつ型がバラバラな「少数並列」のケースでは、async let のほうが構造上の情報をコンパイラが把握しやすく、ヒープ割り当ての回避など追加の最適化も効きやすい、というメリットがあります。

今後の展望(Future Directions)

今回の提案のスコープ外ですが、次のような拡張が議論されています(実現が約束されているものではありません)。

  • キャプチャリストでの await: @escaping クロージャに async let の値を渡したいとき、[await alcatraz] のように「クロージャ生成時点で待機して値として取り込む」構文。
  • async let のエグゼキュータ指定: デフォルトのグローバル並行エグゼキュータではなく、呼び出し元と同じシリアルエグゼキュータや専用のブロッキング用エグゼキュータを選べるようにする仕組み。

これにより、async let は「少数・固定・型が異なる子タスクを並列実行して結果を集める」という、日常的によく現れる構造化された並行パターンを、普通の let と変わらない感覚で書けるようにする機能として位置づけられます。