Swift & Objective-C

[Swift詳解 #4] 構造化並行処理:Taskを安易に作ってはいけない理由

構造化並行処理は、非同期処理の終了を誰が担保するのかに答えます。子タスクが親スコープから抜けられない仕組み、キャンセルシグナルの伝播、Task.detachedで手動管理が必要になる保証を整理します。

読了 8 分
[Swift詳解 #4] 構造化並行処理:Taskを安易に作ってはいけない理由のカバー画像

Concurrency連載を締めくくる最後の問いが残っています。これまでasync関数(第1回)、actor(第2回)、Sendable(第3回)を見てきました。

前回の記事 Swift詳解 #3 の続きです。

非同期の世界への入口であるTaskを、まだきちんと扱っていませんでした。そしてTaskを扱うと、この連載で最も実用的な概念が登場します。

構造化並行処理です。

大げさな名前ですが、問いは単純です。非同期処理を開始したあと、その完了を誰が担保するのでしょうか。

「構造化」とは何か — 処理にもスコープがある

構造化プログラミングという古い用語に由来する名前です。gotoで制御フローをどこへでも飛ばせた時代を経て、ifやforのブロック構造が「入った場所へ制御が戻る」ことを保証するようになりました。

構造化並行処理は同じ原則を非同期処理に適用します。子タスクは親のスコープから抜けられず、親はすべての子が終わるまで終了できません。

第1回で見たasync letは、実はこの原則の最初の例でした。

func loadDashboard() async throws -> Dashboard {
    async let profile = fetchProfile()    // 子タスク 1
    async let feed = fetchFeed()          // 子タスク 2
    return try await Dashboard(profile: profile, feed: feed)
}   // この関数は2つの子タスクの後始末が終わる前には返れません

async letで作った子タスクは、関数が返る前に必ず完了またはキャンセルされます。例外が発生した場合も同じです。

feedがエラーを投げると、まだ実行中のprofileには自動的にキャンセルシグナルが届きます。関数は後始末を終えてからエラーを伝播します。

処理がスコープに束縛されているため、「開始して放置するタスク」は構造上存在できません。

クロージャ編では、escapingは「関数より長く生きるクロージャ」を示す警告ラベルでした。構造化並行処理は「関数より長く生きる処理」をデフォルトから取り除いたのです。

子の数が動的に決まる場合はTaskGroupを使います。URL一覧を並列取得する典型的なコードは次のようになります。

let images = try await withThrowingTaskGroup(of: (URL, Image).self) { group in
    for url in urls {
        group.addTask { (url, try await download(url)) }
    }
    var result: [URL: Image] = [:]
    for try await (url, image) in group {
        result[url] = image
    }
    return result
}

注目点は2つです。結果は完了順に届くこと(順序が必要なら、上のようにキーと一緒に保持します)、そしてクロージャが終わるとグループ内のすべての子が後始末されることです。

async letが「子を数個」作る構文なら、TaskGroupは「子をn個」作る構文であり、スコープの保証は同じです。

キャンセル — シグナルは届くが、停止するのは自分の仕事

構造化並行処理の第2の柱がキャンセルの伝播です。親がキャンセルされると、キャンセルシグナルがツリーを下ってすべての子孫に届きます。

画面を離れると、その画面が開始したネットワークリクエストが連鎖的にキャンセルされるのは、この構造のおかげです。

ただしSwiftのキャンセルは協調的(cooperative)です。キャンセルシグナルはタスクを強制終了しません。

isCancelledフラグを立てるだけで、そのフラグを確認して停止するのはタスク自身の仕事です。

func processLargeFile() async throws {
    for chunk in chunks {
        try Task.checkCancellation()   // キャンセルされていれば CancellationErrorを投げる
        await process(chunk)
    }
}

なぜ強制終了ではないのでしょうか。ファイルを途中まで書いた状態や、ロックを保持した状態、トランザクションの途中でタスクが即死すると、システムが壊れた状態で残るからです。

「停止して安全な地点はタスク自身だけが知っている」というのが、協調的キャンセルの論理です。

実務上の含意は明確です。長時間実行するループにはcheckCancellationを入れましょう。

URLSessionのようなシステムAPIは内部ですでにキャンセルを尊重するため、そのまま任せて構いません。

逆にキャンセルを無視する長い計算コードは、キャンセルしても止まらないタスクになります。キャンセルできないのではなく、確認していないだけです。

親から子へ伝わるキャンセルシグナルと確認チェックリストを描いた図
キャンセルシグナルはツリーを下るが、停止するのは確認したタスクだけ

TaskとTask.detached — 非構造化の世界とその代償

ここまでが構造の世界なら、Task { }はその外側です。Taskで作った処理は親子ツリーに入らない非構造化(unstructured)タスクです。

スコープに束縛されず、エラーもキャンセルも自動的には伝播しません。

では、なぜ存在するのでしょうか。同期の世界から非同期の世界へ渡る橋が必要だからです。

ボタンのタップハンドラは同期関数なのでawaitを使えません。そこでTask { await viewModel.refresh() }が新しい非同期コンテキストを開きます。これがTaskの正当な使いどころです。

SwiftUIの.task修飾子は、この橋にライフタイム管理(ビューが消えると自動キャンセル)まで加えたものなので、UIではこちらを優先します。

問題はTaskが習慣になることです。async関数内でさらにTaskを作るコードや、fire-and-forgetで放置するTaskがその例です。構造が提供していた保証(完了待ち、エラー伝播、キャンセルの連鎖)をすべて手動で取り戻す必要があります。

参照を保持して自分でcancelし、エラーも自分でログに記録します。その管理コードがなければ、静かに消えるエラーやゾンビタスクが生まれます。

ルールとしてまとめると、asyncコンテキスト内ではasync let・TaskGroupが基本で、Taskは同期→非同期の境界でだけ使います。

Task.detachedはさらに外側です。優先度もactor隔離もtask-local値も継承しない、完全に孤立したタスクです(SE-0304)。

「@MainActorコンテキストを抜けて重い処理をしたい」という理由で使われがちですが、多くは誤った処方です。nonisolated async関数として宣言すれば、協調プールで自動的に実行されるからです(第1回)。

detachedが本当に必要なのは、現在のコンテキストと意図的に無関係であるべきバックグラウンド処理など、まれな場合だけです。公式ドキュメントの表現を借りれば、最後の手段(last resort)です。

連載の総括 — 4つの概念が描く1枚の絵

Concurrency連載を締めくくり、全体像を一度に整理します。

async/awaitは非同期フローをコンパイラの視野に戻しました(第1回)。actorは共有可変状態に直列的な保護を組み込み(第2回)、Sendableは隔離境界を越える値の安全性を検査します(第3回)。

そして構造化並行処理は、タスクのライフタイムをスコープに束縛しました。開始したものは必ず終わり、キャンセルはツリーを流れます(今回)。

4つを貫く文は1つです。並行処理の暗黙の規律を、言語の明示的な構造へ。

スレッド管理の規律は協調プールとsuspensionへ、ロックの規律はactorへ変わりました。「このオブジェクトを別スレッドへ渡してよいか」という口伝の知識はSendableへ、「リクエストの後始末を忘れないで」というレビューコメントはタスクツリーへ変わったのです。

Swift哲学シリーズ第1回で見た安全第一の原則が、並行処理という最も難しい領域まで拡張されました。それがSwift Concurrencyの全体像です。

柵の中の構造化タスクと、浮遊する非構造化Taskを対比したイラスト
構造の外にあるTaskは、完了・エラー・キャンセルの保証を手動で取り戻す必要があります

まとめ

  • 構造化並行処理とは、「子タスクは親スコープから抜けられない」という原則です。async let(固定数)とTaskGroup(動的な数)がその構文で、エラー時の兄弟キャンセルと後始末は自動です。
  • キャンセルは協調的です。シグナルはツリーを伝播しますが、停止するのはcheckCancellationを組み込んだタスク自身です。
  • Task { }は同期→非同期の橋としてだけ使います。asyncコンテキスト内で習慣的にTaskを使うと、構造の保証が手動管理の負債に変わります。Task.detachedは最後の手段です。
  • 連載全体の要点:スレッド・ロック・ライフタイム管理という暗黙の規律が、suspension・actor・Sendable・タスクツリーという言語構造へ移されました。

次回からはパフォーマンスの深部に入ります。finalがパフォーマンスキーワードである理由、プロトコル呼び出しが遅くなる箇所、静的ディスパッチと動的ディスパッチの実体を扱います。

あわせて読みたい

出典と確認基準

  • SE-0304: Structured ConcurrencySwift Evolution · 標準・仕様 · 確認日 2026年8月17日根拠: Task・TaskGroupの親子構造、キャンセル・優先度・task-local・actorコンテキストの継承