Swift の計算をキャンセル可能にするには、タスクハンドルを保持し、要求されたときに cancel() を呼び出して、ループ内でキャンセルを確認します。この macOS の例では、インターフェースの応答性も保ち、キャンセルされた実行の結果が遅れて表示されるのを防ぎます。
Swift のタスクをキャンセルすると、実際には何が起こるのか?
Swift では協調的キャンセルが使われます。キャンセルを要求するとタスクに印が付き、実行中のコードが停止するタイミングを決めます。キャンセルを一度も確認しない CPU ループは、最後まで実行される可能性があります。キャンセルが検出されると、Task.checkCancellation() は CancellationError をスローします。Apple のキャンセルに関するドキュメント、checkCancellation()。
ここで扱うのは、具体的な読者のタスクです。合成された合計値を計算する小さな Mac アプリに、動作する Start ボタンと Cancel ボタンを追加します。すべての入力はローカルで生成します。この計算では、連続する正の整数の平方根を加算し、要求されたすべての項を処理した場合にのみ、1 つの Double を生成します。
キャンセルを実装する前に、結果の仕様を決めてください。この例では、未完了の合計値を破棄し、キャンセルを別途通知します。途中までの合計値を返すには、何項まで含めたかを明示する別の仕様が必要です。そうしなければ、呼び出し元が未完了の計算を要求した答えと取り違えるおそれがあります。Swift の言語ガイドでは、スローする、空の結果を返す、途中までの結果を返す方法が、キャンセルへの応答例として説明されています。Concurrency。
実装する責任は 2 つあります。不要な計算を止めることと、完了した値がまだインターフェースに表示されるべきか判断することです。この例は、ワーカーの最後の確認後、結果が表示される前にキャンセルが要求された場合も含め、両方に対処します。
計算の中にキャンセル確認を入れる
Xcode で SwiftUI と Swift を使った macOS アプリプロジェクトを作成します。この例では Swift 6 言語モードと、macOS 13 以降のデプロイメントターゲットを使用します。SyntheticCalculation.swift という名前で、次の完全なファイルを追加します。このファイルはビューに依存しません。
// SyntheticCalculation.swift
import Swift
enum SyntheticCalculation {
nonisolated static func sumOfSquareRoots(
terms: Int,
checkEvery: Int = 4_096
) throws -> Double {
precondition(terms >= 0, "terms must be nonnegative")
precondition(checkEvery > 0, "checkEvery must be positive")
try Task.checkCancellation()
var completed = 0
var total = 0.0
while completed < terms {
try Task.checkCancellation()
let count = min(checkEvery, terms - completed)
let end = completed + count
while completed < end {
completed += 1
total += Double(completed).squareRoot()
}
}
try Task.checkCancellation()
return total
}
}
この関数は、開始前、各チャンクの境界、そして戻る直前にキャンセルを確認します。これらは意図的に設けた停止ポイントです。チャンク処理中にキャンセルが要求された場合、次の確認時に検出されます。Task.checkCancellation() は同期的なため、計算は通常のスロー可能な関数のままにできます。キャンセルを確認するだけなら、await は必要ありません。checkCancellation()。
チャンクごとの計算では、入力の境界も明確に保たれます。キャンセルされていないタスクで項数がゼロなら、結果はゼロです。負の項数とゼロ以下のチャンクサイズは、事前条件によって検出されるプログラマーエラーです。残りの項数から各チャンクを計算することで、終点が terms を超えて進むのを防ぎます。
累積値はワーカー内のローカル変数に保たれます。キャンセルエラーがスローされると、その累積値を返さずに関数を抜けます。これにより、選択した「完了した結果のみを返す」という仕様が実現されます。浮動小数点数の加算であるため、完了した値は数学的な合計の近似値です。
Cancel ボタンの応答性を保つ
ボタンのアクションを実行する機会がなければ、キャンセル確認も役に立ちません。Apple の説明によると、SwiftUI ビューから作成した通常の Task 内に同期的で重い処理を置くと、タスクが周囲のアクターコンテキストを継承するため、メインアクターを占有し続ける場合があります。Apple は、その継承されたコンテキストの外で同期処理を実行する方法として、detached task を紹介しています。アプリの応答性を高める。
ここでは、detached worker がキャプチャするのは不変の整数だけで、明示的に nonisolated と指定された同期関数を呼び出します。インターフェースの状態はすべてメインアクターに置きます。別のメインアクタータスクがワーカーの値を待ち受け、表示を更新します。
生成されたアプリのエントリーファイルを次の完全なファイルに置き換え、生成された ContentView.swift が残っている場合は削除します。SwiftUI のアプリライフサイクルで求められるとおり、ターゲット内の @main 宣言は 1 つだけにしてください。この 2 つのファイルで、例のアプリが構成されます。App。
// CancellationDemoApp.swift
import Foundation
import SwiftUI
@main
struct CancellationDemoApp: App {
var body: some Scene {
WindowGroup {
CalculationView()
}
}
}
@MainActor
struct CalculationView: View {
@State private var worker: Task<Double, Error>?
@State private var runID: UUID?
@State private var status = "Ready"
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Synthetic square-root sum")
.font(.headline)
Text(status)
.textSelection(.enabled)
HStack {
Button("Start") {
start()
}
.disabled(worker != nil)
Button("Cancel") {
guard let worker else { return }
worker.cancel()
status = "Cancellation requested"
}
.disabled(worker == nil)
}
}
.padding(24)
.frame(minWidth: 420, minHeight: 160)
.onDisappear {
worker?.cancel()
runID = nil
worker = nil
status = "Ready"
}
}
private func start() {
guard worker == nil else { return }
let id = UUID()
let terms = 500_000_000
runID = id
status = "Calculating"
let run = Task.detached(priority: .userInitiated) {
try SyntheticCalculation.sumOfSquareRoots(terms: terms)
}
worker = run
Task { @MainActor in
do {
let value = try await run.value
guard runID == id else { return }
if run.isCancelled {
status = "Cancelled"
} else {
status = "Result: \(value)"
}
} catch is CancellationError {
guard runID == id else { return }
status = "Cancelled"
} catch {
guard runID == id else { return }
status = "Failed: \(error)"
}
guard runID == id else { return }
worker = nil
runID = nil
}
}
}
worker を保持することが不可欠です。タスクハンドルを破棄してもタスクはキャンセルされません。そのハンドルを使ってキャンセルする手段がなくなります。スロー可能なワーカーの value を待ち受けることで、そのエラーも検出できます。detached task には明示的なライフサイクル管理が必要なため、ボタンと画面が消えるときのハンドラーからワーカーを直接キャンセルします。結果を待ち受けているタスクだけをキャンセルしても、この detached worker にキャンセルが伝播するとは限りません。Detached task のドキュメント。
Foundation の UUID は、実行ごとに識別子を提供します。識別子の比較はアプリケーションレベルのルールです。画面が消えたことで実行が無効になった後は、最終的に処理が完了しても、その結果でこのビューの現在の状態が更新されることはありません。識別子自体は計算を止めません。計算を止めるのは worker.cancel() とループ内の確認です。UUID。
ワーカーが完了するかエラーをスローするまで、Start ボタンは無効のままです。Cancel を押すと、まず Cancellation requested が表示されます。完了を監視する処理が結果を処理すると、表示は Cancelled に変わります。これにより、キャンセル要求とワーカーの終了確認を区別できます。
遅れて届いた結果はどう扱うべきか?
ワーカーが最後の確認を通過して完了し、その値をメインアクターが受け取る前にユーザーが Cancel を押す場合を考えてみましょう。Apple は、最後に可能な確認を過ぎてからキャンセルしても、ワーカーには何の影響も現れない場合があると明記しています。最後の確認を置けばその時間差は短くできますが、なくすことはできません。cancel()。
そこでこの例では、値の取得に成功した後で run.isCancelled を確認します。これはワーカーのハンドルを確認する処理です。ここで静的な Task.isCancelled を使うと、ワーカーではなく結果を監視するタスクを確認してしまいます。Swift の言語ガイドでは、型プロパティを使った現在のタスクの確認と、インスタンスプロパティを使った別のタスクの確認が区別されています。Concurrency。
これにより、明確な採用ルールができます。結果を採用する前に処理されたキャンセルが優先されます。結果の採用がすでに完了してワーカーが解放された後は、無効になった Cancel ボタンで完了済みの結果を取り消すことはできません。このルールは、キャンセルによって完了済みの処理を取り消せると約束するのではなく、メインアクター上の操作順序に従います。
役立つキャンセル確認の間隔を選ぶ
デフォルトのチャンクサイズ 4,096 項は、例示用の出発点です。チャンクを小さくすると、キャンセルを確認する機会が増え、確認処理も増えます。チャンクを大きくすると確認頻度は下がりますが、停止ポイントの間により多くの計算を行います。
おおまかな計画用の計算として、1 チャンクに 4,096 項があり、各項の処理時間を例として 2 マイクロ秒と仮定します。このチャンクの計算時間は約 8.2 ミリ秒です。この数値は計算方法を示すために設定したもので、Swift や特定の Mac の実測値ではありません。スケジューリングやインターフェースの更新によって、さらに遅延が加わることがあります。
| 観察された状態 | 確認または変更する点 |
|---|---|
| 計算中にインターフェースの反応が止まる | ループが detached worker 内で実行され、メインアクターの隔離外にあることを確認します。 |
| インターフェースは反応するが、キャンセルに時間がかかる | checkEvery を小さくして、最長チャンクの時間を測ります。 |
| チャンクに後から時間のかかる操作が追加された | その実装が対応している場合は、その操作の内部にも停止ポイントを追加します。 |
| キャンセルした実行の結果が表示される | 結果を採用する時点でワーカーハンドルを確認し、実行識別子によるガードも保持します。 |
このループで別のフラグを設定するだけのキャンセルハンドラーを追加しないでください。タスクにはすでにキャンセル状態があり、Swift の言語ガイドでは、ハンドラーを設定しても停止は協調的なままであると説明されています。このローカル計算では、明示的な確認で必要な停止処理を実現できます。Concurrency。
完了、キャンセル、再実行を確認する
調査環境では Swift コンパイラーと macOS ランタイムを利用できなかったため、この例は確認しましたが、コンパイルも実行もしていません。Xcode で両方のファイルをビルドし、次の動作をそれぞれ確認してください。
- 小さな計算結果:
termsを一時的に4に変更します。浮動小数点の丸め誤差を考慮すると、表示結果はおよそ6.146264369941973になるはずです。 - 空の入力:
termsを0に変更します。キャンセルされていない実行では、ゼロが表示されるはずです。 - 計算中のキャンセル:
500_000_000に戻し、Startを押してから、実行中にCancelを押します。Cancellation requestedの後に、途中の結果を伴わずCancelledと表示されることを確認します。 - 再実行: キャンセル処理の完了後に、もう一度
Startを押します。新しい実行が通常どおり始まるはずです。 - 画面が消える場合: 計算中のウィンドウを閉じます。画面が消えるときのハンドラーがキャンセルを要求し、その実行によるインターフェース更新を無効にするはずです。
大きな項数は操作できる時間を確保するためのものですが、所要時間は Mac とビルド設定によって異なります。キャンセルする前に完了してしまう場合は、サポートされる整数の範囲内で項数を増やしてください。大規模なデモが終わるのを待つのではなく、小さな入力で計算結果を検証してください。
完成した設計には、明確な停止経路と結果の採用ルールがあります。ボタンは保持したワーカーをキャンセルし、チャンク境界で計算がエラーをスローでき、メインアクターは現在の実行から得たキャンセルされていない結果だけを採用します。この 3 つの判断によって、一般的な Mac の計算でもキャンセルが役立つようになります。
出典
- Concurrency — Swift 言語ガイド、Swift プロジェクト。
- checkCancellation() — Apple Developer Documentation。
- cancel() — Apple Developer Documentation。
- アプリの応答性を高める — Apple Developer Documentation。
- detached(name:priority:operation:) — Apple Developer Documentation。
- App — Apple Developer Documentation。
- UUID — Apple Developer Documentation。