Swift Digest
SE-0304 | Swift Evolution

Structured concurrency

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

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

SE-0296async/await は、非同期な処理を自然な構文で書くための仕組みでした。ただし、async/await 自体は「処理を途中で中断・再開できる」というだけで、並行性 そのものを導入するものではありません。async 関数の中で await を順に呼んでいる限り、各ステップはひとつずつ順番に進みます。

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

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

この例は「野菜を切り終わってから肉を漬け、肉が漬かってからオーブンを予熱する」という完全に逐次的な処理です。実際には野菜を切る・肉を漬ける・オーブンを温めるは並行に進められるはずで、そうでないと待ち時間が無駄になってしまいます。

スレッドベースの並行性が抱える問題

従来、並行処理は「新しいスレッドを作って、必要に応じて待ち合わせる」というスレッドベースのモデルで書かれてきました。これは強力ですが低レベルで、タスクどうしの関係をシステムが何も知らない という問題があります。

  • 優先度を上げたくても、関連スレッドすべてをまとめて昇格させる仕組みがない。
  • タイムアウトやデッドラインを適用したくても、APIの各層で手作業で伝播させる必要がある。
  • キャンセルしたくても、APIごとにトークン型を設計して渡し回す必要があり、設計負荷が高い。
  • サーバで「現在処理中のリクエストに紐づく情報」などのコンテキストを持ち回したくても、すべての関数に引数として通す必要がある。

要するに、タスクの親子関係や階層構造が言語・ランタイムのレベルで表現されていないため、優先度・キャンセル・コンテキスト伝播といった 横断的な関心事を、その場その場でアドホックに実装せざるを得ない という状態でした。

並行性の単位、キャンセル、優先度の標準的なモデルが欲しい

Swiftの並行処理を安全かつ効率的に書くためには、

  • 並行な作業の単位を言語レベルの第一級概念として持ち、
  • 作業の親子関係を追跡でき、
  • キャンセル・優先度・コンテキストが階層に沿って自然に伝わり、
  • 「親が終わるときには子も必ず終わっている」という構造的な保証が得られる、

という共通基盤が必要でした。この基盤がないと、async/await は「きれいに書ける直列実行」に留まり、並行処理を安全に書くための道具として成立しません。

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

structured concurrency(構造化された並行性)というモデルを導入します。並行な作業の単位を タスク と呼ばれる第一級の概念として扱い、タスクを親子関係のある階層として組み立てることで、キャンセル・優先度・コンテキストを階層に沿って自然に伝播させます。

タスクとは何か

タスクは、async 関数における「スレッドに相当するもの」です。

  • すべての async 関数は、なんらかのタスクの一部として実行されます。
  • ひとつのタスクの中では、関数はひとつずつ順番に走ります。タスク内に並行性はありません。
  • await で別の async 関数を呼び出しても、呼び出した側と呼び出された側は同じタスクの上で実行されます。

タスクは次のような情報を担います。

  • 優先度などのスケジューリング情報
  • キャンセル・問い合わせ・操作のためのハンドル
  • task-local な値(別Proposal SE-0311 で詳細が定義されます)

child task と親子関係

async 関数は、そこから child task を起こすことができます。child task には次の性質があります。

  • 親タスクの優先度を引き継ぐ。
  • 親と並行に実行できる。
  • ただし child task は、それを起こしたスコープを超えて生き残れない。スコープを抜けるときには必ず完了を待つ、あるいはキャンセルされる。

この「親は子より長生きする」という構造的な制約こそが structured concurrency の核で、これがあるおかげで、タスクの優先度昇格・キャンセル・コンテキスト伝播といった性質を、その場で閉じた形で静的に推論できるようになっています。

child task を起こす2つの方法

本Proposalでは child task を作る手段として、主に task group を提供します。async let 構文による child task の作成は SE-0317 で別途提案されています。

withTaskGroup / withThrowingTaskGroup

task group は、動的な数の child task を起こせるスコープです。withTaskGroup または withThrowingTaskGroupbody クロージャが task group のスコープになり、この中で group.addTask { ... } を呼ぶと child task が起動します。

func makeDinner() async throws -> Meal {
  var veggies: [Vegetable]?
  var meat: Meat?
  var oven: Oven?

  enum CookingStep {
    case veggies([Vegetable])
    case meat(Meat)
    case oven(Oven)
  }

  try await withThrowingTaskGroup(of: CookingStep.self) { group in
    group.addTask {
      try await .veggies(chopVegetables())
    }
    group.addTask {
      await .meat(marinateMeat())
    }
    group.addTask {
      try await .oven(preheatOven(temperature: 350))
    }

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

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

task group のポイントは次の通りです。

  • body を抜けるときには、追加されたすべての child task が必ず完了している(未完了のものは暗黙的に await される)。
  • body からエラーが投げられた場合、未完了の child task はまず暗黙にキャンセルされたあと await され、その後エラーが伝播する。
  • child task の結果は group.next() で順に受け取れるほか、TaskGroupAsyncSequence に適合しているので for await / for try await で反復できる。
  • 結果の順序は 完了順 であり、addTask した順ではない。
  • child task はすべて同じ型(ChildTaskResult)の値を返す。異なる型の結果を受け渡したいときは、上記のように enum で包むか、async let(SE-0317)を使う。

addTask は、グループがすでにキャンセルされていても child task を追加します(その新しい child task はキャンセルされた状態で始まります)。キャンセル済みなら追加しない方がよい場合は addTaskUnlessCancelled を使います(追加できれば true、できなければ false を返します)。

child task からキャプチャした変数を書き換えることは、@Sendable クロージャ検査によって原則禁止されます。ローカル変数への結果の反映は上の例のように body 側で行ってください。

task groupのキャンセル

task group がキャンセルされるのは次の3通りです。

  1. body からエラーが投げられたとき
  2. task groupが乗っている外側のタスクがキャンセルされたとき
  3. 明示的に group.cancelAll() が呼ばれたとき

キャンセル状態は group.isCancelled で確認できます。キャンセル済み状態で addTask しても新しい child task は即座にキャンセルされた状態で起動します。

unstructured task: Task.initTask.detached

「スコープを超えて走らせたい」「同期コードから非同期処理を開始したい」など、child task では扱えない用途のために unstructured task も用意されます。unstructured task は child task と違ってライフタイムが呼び出し元のスコープに縛られないため、最適化の余地は少なくなりますが、既存APIや fire-and-forget 的なパターンとの橋渡しに不可欠な道具です。

unstructured task は Task<Success, Failure> 型の値として表現され、キャンセルしたり結果を受け取ったりできます。

Task.init: コンテキストを継承する unstructured task

Task { ... } で新しいタスクを起動すると、呼び出し元から次の情報を 継承 します。

  • 優先度
  • task-local な値(コピー)
  • アクター実行コンテキスト(アクター内から起動した場合、そのアクター上で実行され、actor-isolated なクロージャとして扱われる)
actor A {
    func f() {
        Task {
            g() // 同じアクター A の上で実行される
        }
    }

    func g() { }
}

Task { ... } に渡すクロージャは @Sendable ですが、非同期かつ起動時にアクターへ「ホップ」するため、アクター上から起動した場合は actor-isolated なクロージャとして扱われます。また、@escaping な通常のクロージャと違い、キャプチャする selfself. を明示する必要はありません(即時実行されるクロージャで、循環参照の警告としての意味が薄いため)。

結果を受け取る/キャンセルする場合は、返ってきた Task を使います。

let dinnerHandle = Task {
    try await makeDinner()
}

// 結果を待つ
let dinner = try await dinnerHandle.value

// あるいはキャンセル
dinnerHandle.cancel()

Task@discardableResult 付きで作られるため、ハンドルを受け取らなくても処理は最後まで走ります。

Task.detached: コンテキストを継承しない unstructured task

Task.detached { ... } は、呼び出し元のコンテキスト(優先度・task-local・アクター)を 一切継承しない 独立したタスクを作ります。

let dinnerHandle = Task.detached {
    try await makeDinner()
}

detached task は常にグローバルな並行エグゼキュータ上で、どのアクターにも紐づかない状態で実行されます。親コンテキストとの関係を明示的に切りたい場合にだけ使う、という位置付けです。通常の用途では Task { ... } の方が望ましく、Task.detached は「意図的にコンテキストを断ち切る」選択として使います。

structured と unstructured の使い分け

  • 並行に走らせたい複数の処理が 同じスコープの中で完結する なら task group(または async let)を使う。これが structured concurrency の基本形で、キャンセル・優先度・コンテキストの伝播が自動で機能し、コンパイラ/ランタイムの最適化も効く。
  • ライフタイムがスコープをはみ出す、あるいは同期コードから非同期処理を開始したい場合のみ、Task { ... } を使う。
  • 親のコンテキストを意図的に断ち切りたいときだけ Task.detached を使う。

キャンセルは協調的(cooperative)

タスクのキャンセルは 協調的 です。「誰かがキャンセルを発行しても、そのタスク内の処理がキャンセルを確認する仕組みを持たなければ何も起こらない」という設計になっています。

キャンセルの発行方法は次の通りです。

  • task.cancel() でそのタスクと その全ての子孫 がキャンセル扱いになる。親から子へ伝播するが、子から親へは伝播しない。
  • 親タスクがエラーを投げてスコープを抜けると、未完了の child task は暗黙にキャンセルされる。
  • task group の cancelAll() を呼ぶと、そのグループの全 child task がキャンセルされる。

キャンセルされたタスクの内部では、キャンセルに応答するために以下のAPIを使います。

func chop(_ vegetable: Vegetable) async throws -> Vegetable {
    try Task.checkCancellation() // キャンセルされていれば CancellationError を投げる
    // 重たい同期処理 ...

    guard !Task.isCancelled else {
        print("Cancelled mid-way through chopping of \(vegetable)!")
        throw CancellationError()
    }
    // 続きの処理 ...
}
  • Task.checkCancellation(): キャンセル済みなら CancellationError を投げる。throws 関数で使うのが簡単。
  • Task.isCancelled: キャンセル済みかを Bool で返す。静的プロパティ版なので、同期コードからでも呼べる(タスクがない場合は false)。
  • withTaskCancellationHandler(operation:onCancel:): キャンセルが発生した瞬間に onCancel ハンドラを同期的に実行する。URLSession のような、別途 cancel() を呼んで止める必要がある外部リソースを扱うときに使う。ハンドラはタスクと並行に呼ばれうるため、実装は @Sendable でスレッドセーフに書く必要がある。

キャンセルの基本方針は「できるだけ速やかに戻る/エラーを投げる」です。I/O系や Task.value の待ち合わせなど、低レベルAPI側で既にキャンセルを検査してくれるので、多くの関数ではそれに頼れば十分です。同期計算が重い関数では、ループの途中などで定期的に Task.checkCancellation() を差し込むとよい、という指針になっています。

なお、キャンセルには理由情報は乗りません。タスク間通信の代替ではなく、あくまで軽量な停止シグナルという位置付けです。

優先度の継承と昇格

タスクには TaskPriority が付き、エグゼキュータのスケジューリング判断に使われます。プラットフォーム中立な .high/.medium/.low/.background のほか、Apple プラットフォーム向けの別名(.userInitiated / .utility)も用意されています。

  • child task は親タスクの優先度を引き継ぐ。
  • Task.detached は親を持たないので継承しない。
  • 高優先度のタスクが低優先度のタスクの完了を待ったり、アクターに高優先度タスクがキューイングされたりすると、低優先度側のタスクの優先度が一時的または恒久的に 昇格(escalation) される。これも階層構造があるからこそ、child task まで含めて昇格を伝播できる。

現在実行中のタスクの優先度は Task.currentPriority で取得できます。

タスクを自発的に中断する

長時間動く同期処理などで、他のタスクに実行の機会を与えたい場合のために、以下のAPIがあります。

  • Task.yield(): 現在のタスクを明示的に中断し、他のタスクに実行機会を譲る(Proposal本文では suspend() と表記されていますが、実装では Task.yield() として提供されています)。
  • Task.sleep(nanoseconds:): 指定されたナノ秒だけタスクを休止する。休止中にキャンセルされた場合は CancellationError を投げる。将来的には Duration 型ベースのより扱いやすいオーバーロードが追加される見込みですが、本Proposalの時点ではナノ秒指定のみです。

非同期な @main とトップレベルコード

@main から asyncmain() を書けるようになり、スクリプトのトップレベルでも直接 await を書けるようになります。

@main
struct Eat {
    static func main() async throws {
        let meal = try await makeDinner()
        print(meal)
    }
}

Swiftはこの main() を実行するタスクを自動で作り、そのタスクが完了すればプログラムが終了する、というモデルです。

その他: UnsafeCurrentTask

withUnsafeCurrentTask { ... } を使うと、現在実行中のタスクへの参照を同期コードからも取り出せます。ただしこの参照はタスク外へ持ち出してはいけない(持ち出すと未定義動作)ため、Unsafe の名を冠して提供されています。通常の用途では Task.isCancelled のような静的APIで十分で、これは task-local 値の内部実装など特殊な場面のための低レベルAPIです。

03 今後の見通し

本Proposalでは、withTaskGroup などのスコープ付きAPIに加えていくつかの拡張が将来の方向性として挙げられています。いずれも構想の段階で、実現を約束するものではありません。

async let による child task の生成

task group は動的な数の child task を扱うのに適していますが、異なる型の値をいくつか並行に計算して親へ受け渡したいだけの場合には、enum で包んで for await で受け取るような書き方は冗長になります。これに対しては、各 child task の結果をローカル変数に束縛できる async let 構文が想定されており、別Proposalとして SE-0317 で具体化されています。

task group での @Sendable クロージャ検査の緩和

child task は withTaskGroupbody を抜けるまでに必ず完了するため、各 child task が互いに重ならないローカル変数を書き換えるだけで、body 側ではグループの完了後にしかその変数を読まないなら、理論上はデータ競合は起きません。それにもかかわらず、現状は @Sendable クロージャの一般的な規則によりキャプチャした変数の書き換えが禁止されます。task group のスコープ的な性質を型検査が理解し、こうしたパターンを安全に許可する方向が議論されています。

addTask の中断による back-pressure

当初は group.addTaskasync 関数として設計し、グループに溜まっている child task が多すぎる場合に呼び出し側を中断することで back-pressure をかける案も検討されていました。実装も具体的な API 形状も固まっていないため一旦見送られていますが、async let や一般的なタスク生成と整合する形で、タスク生成全体に back-pressure をかける仕組みは将来の検討課題とされています。