Swift Digest
SE-0504 | Swift Evolution

Task Cancellation Shields

Proposal
SE-0504
Authors
Konrad 'ktoso' Malawski
Review Manager
John McCall
Status
Implemented (Swift 6.4)

01 何が問題だったのか

Swift concurrencyでは、タスクのキャンセルは 終局的(final) で、一度キャンセルされたタスクは以降ずっとキャンセル状態のままです。さらに、キャンセルは親から子へと構造化された子タスクのツリー全体に自動的に伝播します。これは木全体の処理を効率良く打ち切れるという強みがある一方で、「キャンセル状態でも必ず最後まで走らせたいコード」を書くのが難しいという問題を生みます。

典型的な例がリソースのクリーンアップです。たとえば次のような Resource.cleanup() は、内部で使う performAction(_:)Task.isCancelled をチェックして早期リターンしてしまうため、キャンセル済みのタスクから呼ばれるとクリーンアップが実行されません。

extension Resource {
  func cleanup() { // 一見正しそうに見えるが…
    system.performAction(CleanupAction())
  }
}

extension SomeSystem {
  func performAction(_ action: some SomeAction) {
    guard !Task.isCancelled else {
      // キャンセル済みの Task から Resource.cleanup が呼ばれると、
      // このアクションはまったく実行されずに終わってしまう
      return
    }
    // ...
  }
}

cleanup() の呼び出し側としては、「今のタスクがキャンセル済みかどうかに関わらず、この処理だけは通常どおり走らせたい」という意図を表現する手段が欲しくなります。

これまでは、そのために非構造化な Task { } を作って、そこから .valueawait するという回避策が使われてきました。

// キャンセルシールド導入前の回避策
func example() async {
  let resource = makeResource()

  await Task {
    assert(!Task.isCancelled)
    await resource.cleanup()
  }.value // タスクツリーから抜けてキャンセル状態を切り離す
}

しかしこの書き方にはいくつも欠点があります。新しい非構造化タスクを毎回スケジュールすることになるのでクリーンアップの開始が遅れますし、同期関数の中ではそもそも await できないので使えません。

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

一時的にタスクのキャンセル状態を 観測できなくする 仕組みとして、withTaskCancellationShield(_:) を導入します。同期版・非同期版の両方が用意されていて、シグネチャは次のとおりです。

public func withTaskCancellationShield<Value, Failure>(
  _ operation: () throws(Failure) -> Value,
  file: String = #fileID, line: Int = #line
) throws(Failure) -> Value

public nonisolated(nonsending) func withTaskCancellationShield<Value, Failure>(
  _ operation: nonisolated(nonsending) () async throws(Failure) -> Value,
  file: String = #fileID, line: Int = #line
) async throws(Failure) -> Value

重要なのは、シールドはキャンセル自体を無効にするものではなく、あくまで 観測できなくする だけ、という点です。シールド内では Task.isCancelledfalse を返し、Task.checkCancellation() も投げません。シールドを抜けると元の状態に戻ります。

print(Task.isCancelled) // true
withTaskCancellationShield {
  print(Task.isCancelled) // false
}
print(Task.isCancelled) // true

同期関数の中でも使えるため、リソースのクリーンアップのような「キャンセル状態でも必ず走らせたい処理」を、非構造化タスクを経由せずに素直に書けるようになります。

SE-0493(defer の中での async 呼び出し)との相性もよく、次のように defer ブロックの中でシールドを張ると、関数の出口で必ずクリーンアップが走ることが保証できます。

let resource = makeResource()

defer {
  await withTaskCancellationShield { // キャンセルされていても必ずクリーンアップが走る
    await resource.cleanup()
  }
}

子タスクへの伝播も止まる

シールドは現在のタスクだけでなく、その中で作られる構造化された子タスク(async let やtask group)への キャンセルの自動伝播 も止めます。外側のタスクはキャンセル済みのままですが、その事実はシールドの内側では観測されず、子タスクも自動キャンセルされません。

Task {
  withUnsafeCurrentTask { $0?.cancel() } // 自分自身を即キャンセル

  // シールドなしの場合:
  async let a = compute() // 🛑 即キャンセルされる
  await withDiscardingTaskGroup { group in // 🛑 グループも即キャンセル
    group.addTask { compute() }            // 🛑 子タスクも即キャンセル
    group.addTaskUnlessCancelled { compute() } // 🛑 そもそも起動されない
  }

  // シールドありの場合:
  await withTaskCancellationShield {
    async let a = compute() // 🟢 即キャンセルされない
    await withDiscardingTaskGroup { group in // 🟢
      group.addTask { compute() }            // 🟢
      group.addTaskUnlessCancelled { compute() } // 🟢
    }
  }
}

ただしシールドが止めるのは「外側から流れ込んでくるキャンセル」だけです。子タスクやtask groupを内側で明示的に cancelAll() したり、未 await のまま async let のスコープを抜けたりした場合には、これまでどおりキャンセルが発生します。

また、group.addTask { ... } の呼び出し自体を外側でシールドしても、子タスクの実行期間を囲っているわけではないので意味がありません。特定の子タスクをシールドしたいなら、addTask の中(子タスク本体)でシールドを張る必要があります。

await withDiscardingTaskGroup { group in
  // 子タスクのキャンセル観測には影響しない
  withTaskCancellationShield {
    group.addTask { ... }
  }

  // こちらは子タスクをちゃんとシールドできる
  group.addTask {
    withTaskCancellationShield { ... }
  }
}

キャンセルハンドラもシールドされる

withTaskCancellationHandler(operation:onCancel:) によるキャンセルハンドラも、シールドの中で登録されているあいだはキャンセル発生時に発火しません。たとえば次のコードでは、slowOperationcleanup の中からシールド下で呼ばれているため、タスクがキャンセルされてもハンドラの print("Let's cancel the slow operation!") は実行されません。

func slowOperation() -> ComputationResult {
  await withTaskCancellationHandler {
    return < ... slow operation ... >
  } onCancel: {
    print("Let's cancel the slow operation!")
  }
}

func cleanup() {
  withTaskCancellationShield {
    slowOperation()
  }
}

ここでも影響を受けるのは「シールドされた当のタスク」だけで、別の子タスクに登録されたハンドラは影響を受けません。

static と instance で挙動が分かれる

シールドは「いま実行中のコンテキストからキャンセル状態がどう見えるか」を変えるものなので、static な API と instance な API で意図的に挙動が分かれています。

  • staticTask.isCancelled / Task.checkCancellation() / withTaskCancellationHandler は、呼び出された コンテキスト のキャンセル状態を観測します。シールドの中では false を返します。
  • TaskUnsafeCurrentTaskinstance メソッド(task.isCancelled など)は、タスクの 実際の キャンセル状態を返します。こちらはシールドを無視します。
let task = Task {
  Task.isCancelled // true
  withTaskCancellationShield {
    Task.isCancelled // false
  }
  Task.isCancelled // true
}

task.cancel()
print(task.isCancelled) // 常に true(外から見た本当のキャンセル状態)

タスクハンドル経由で task.isCancelled を外側から問い合わせると、相手が今シールドの中にいるかどうかで結果が変わってしまい、レースコンディションになります。そのためinstance メソッドはシールドを尊重せず、常に「実際の状態」を返すようになっています。

この変更にともない、Task.isCancelled の「いったん true になったら永遠に true」という契約も改められ、シールドに入っているあいだだけは false を返しうる という注記が加わります。キャンセル自体が巻き戻るわけではない点は従来どおりです。

シールドの有無を問い合わせる

深い呼び出しの先でシールドが張られていると、Task.isCancelled の値が直感と合わずに混乱しやすいので、デバッグ用途として現在のタスクにシールドがかかっているかを問い合わせるプロパティも追加されます。

extension Task {
  /// 現在のタスクにキャンセルシールドが張られているか
  public static var hasActiveTaskCancellationShield: Bool { get }
}

extension UnsafeCurrentTask {
  public var hasActiveTaskCancellationShield: Bool { get }
}

UnsafeCurrentTaskisCancelled と組み合わせれば、「本当のキャンセル状態」と「シールドを尊重したキャンセル状態」を自分で使い分けることもできます。

let task = Task {
  Task.isCancelled // true

  withTaskCancellationShield {
    Task.isCancelled // false

    withUnsafeCurrentTask { unsafeTask in
      unsafeTask.isCancelled                    // true(実際の状態)
      unsafeTask.hasActiveTaskCancellationShield // true

      let isCancelledRespectingShield =
        if unsafeTask.hasActiveTaskCancellationShield { false }
        else { unsafeTask.isCancelled }
    }
  }
}

task.cancel()
print(task.isCancelled) // true

利用上の注意

この機能はSwift concurrencyのランタイム変更を含むため、back-deploymentでは利用できません。導入には対応ランタイムが必要です。