Swift Digest
SE-0472 | Swift Evolution

Starting tasks synchronously from caller context

Proposal
SE-0472
Authors
Konrad 'ktoso' Malawski
Review Manager
Tony Allevato
Status
Implemented (Swift 6.2)

01 何が問題だったのか

Swift Concurrency で非同期コンテキストに入る唯一の方法は、Task を作ることです。Task.initTask.detached で作られたタスクは、グローバルの並行エグゼキュータや特定のアクターにスケジューリングされ、実際に走り始めるのはそのスケジューリングが済んだ後になります。この「一度キューに積んで後で実行する」という挙動は多くの場面で安全で妥当な既定ですが、いくつかの状況ではオーバーヘッドや意味論の不一致を引き起こします。

ひとつは、タスクの中身が軽量で、多くの場合まったく suspend しないケースです。タスクを作って enqueue するだけで無視できないコストが発生し、本来すぐに終わる仕事が後回しにされます。

もうひとつは、UIなどパフォーマンスに敏感なコードで「今この瞬間に、このアクターの上で動いている」ことが分かっているのに、同じアクター上の async 関数を呼ぶためだけに Task を生成して一度スケジューリングを挟まなければならない、という状況です。

典型的には @MainActor まわりで発生します。

@MainActor var thingsHappened: Int = 0

@MainActor func asyncUpdateThingsHappenedCounter() async {
  // 何らかの事情でこの関数は async である必要がある
  thingsHappened += 1
}

func synchronousFunction() {
  // メインアクター上で動いていることは分かっているが、
  MainActor.assumeIsolated {
    // ここで async 関数は呼べない
    // await asyncUpdateThingsHappenedCounter() // error
  }

  // そのため Task を作って非同期コンテキストに入るしかない
  Task {
    await asyncUpdateThingsHappenedCounter()
  }
  // しかしこの Task は一度 enqueue され、呼び出し元の同期処理が終わってから動き出す
}

MainActor.assumeIsolated は isolation を動的に取り戻せますが、新しい非同期コンテキストは作らないので async 関数は呼べません。一方 Task { ... } は非同期コンテキストを作りますが、そのタスクは必ず enqueue され、呼び出し元の同期処理がすべて終わってから実行されます。

この「すでに正しいアクター上にいると分かっているのに、非同期コンテキストに入るためだけにスケジューリング遅延を払わされる」という隙間を埋める手段が存在しませんでした。

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

「immediate task」と呼ばれる新しいタスク生成APIを導入します。スケジューリング遅延なしで、呼び出し元のエグゼキュータ/スレッド上でタスクの本体を即座に実行し始めるのが特徴です。最初の本当の suspension に到達するまでは呼び出し元のコンテキストで同期的に走り続け、そこで初めて呼び出し元に制御が戻ります。

extension Task {
  @discardableResult
  public static func immediate(
    name: String? = nil,
    priority: TaskPriority? = nil,
    executorPreference taskExecutor: consuming (any TaskExecutor)? = nil,
    @_inheritActorContext(always) operation: sending @escaping () async throws(Failure) -> Success
  ) -> Task<Success, Failure>

  @discardableResult
  public static func immediateDetached(
    name: String? = nil,
    priority: TaskPriority? = nil,
    executorPreference taskExecutor: consuming (any TaskExecutor)? = nil,
    @_inheritActorContext(always) operation: sending @escaping () async throws(Failure) -> Success
  ) -> Task<Success, Failure>
}

基本的な挙動

Task.immediate で作ったタスクは、その場で走り始めます。

func synchronous() { // 呼び出し元スレッド: T1
  let task: Task<Void, Never> = Task.immediate {
    // T1 上で即座に実行開始
    guard keepRunning() else { return }

    await noSuspension() // 実際には suspend しなかった → T1 で継続
    await suspend()      // ここで本当に suspend → T1 を手放す
    // ここから先は別のエグゼキュータ/スレッドで再開
  }

  // task が suspend した時点で、呼び出し元はここに戻ってくる
}

await が付いていても実際に suspend しなければ、呼び出し元のコンテキストのまま走り続けます。実際に suspend したタイミングで呼び出し元に制御が戻り、タスクの続きは isolation に応じたエグゼキュータで再開されます。

タスクローカルや基底 priority などは通常のタスクと同様に外側から引き継がれ、キャンセルや priority escalation といった仕組みも従来どおり機能します。異なるのは「どこで実行を開始するか」だけです。

isolation の推論は積極的

Task.init と違って、Task.immediateキャプチャに関係なく周囲の isolation を積極的に継承 します。immediate task は常に「現在のエグゼキュータ」で即座に走ろうとするので、その挙動に合わせた isolation 推論になっています。

@MainActor
func onMain() {
  Task { /* @MainActor isolated だが enqueue される */ }
  Task.immediate { /* @MainActor isolated で即座に実行 */ }
}

actor Caplin {
  var anything: Int = 0

  func act() {
    Task { /* nonisolated。グローバルエグゼキュータに enqueue */ }
    Task {
      // self をキャプチャしていれば self isolated で enqueue
      self.anything
    }
    Task.immediate {
      // キャプチャに関係なく self isolated、即座に実行
    }
  }
}

一方 Task.immediateDetached とタスクグループの addImmediateTask 系は、Task.detachedaddTask と同じく isolation を自動では継承しません(必要なら明示的に指定します)。

また、operation クロージャは sending であり、かつ isolation は明示的な指定(例えば { @MainActor in })のみが許されます。

実行時の isolation が一致するときだけ「即座」

Task.immediate は、要求された isolation と「実行時の現在のエグゼキュータ」が一致するときにだけ、本当に即座に実行されます。一致しなければ通常の enqueue にフォールバックします。

@MainActor var counterUsual = 0
@MainActor var counterImmediate = 0

@MainActor
func sayHelloOnMain() {
  sayHello() // メインアクターから呼ぶ
}

// 同期関数(@MainActor は付いていない)
func sayHello() {
  MainActor.assertIsolated()

  Task { @MainActor in
    counterUsual += 1
  }
  // この時点では counterUsual はまだ 0
  // メインアクターのエグゼキュータをまだ手放していないので走れない

  Task.immediate { @MainActor in
    counterImmediate += 1
  }
  // この時点で counterImmediate は 1 であることが保証される
  // 呼び出し元のエグゼキュータが要求 isolation と一致するので即座に実行された
}

同じ sayHello()MainActor 以外(例えば別のアクター)から呼ばれた場合は、要求 isolation と一致しないため、Task.immediate も通常の Task.init と同様に enqueue されます。つまり Task.immediate は「要求した isolation に今いるなら即実行、そうでなければ通常どおり後回しで実行」という opportunistic なセマンティクスとしても使えます。これは特に MainActor との組み合わせで以前から要望のあった挙動です。

assumeIsolated との組み合わせ

Actor/assumeIsolated は「今このアクター上にいる」ことを動的に確認して isolated state にアクセスできるようにするAPIですが、新しい非同期コンテキストは作りません。そのため assumeIsolated のクロージャの中で async 関数を直接は呼べません。

Task.immediate と組み合わせると、「このアクター上にいることを確認しつつ、その上で新しい非同期コンテキストに入る」ことができます。

// このAPIは @MainActor で呼ばれる前提だが、古い設計のためシグネチャには現れていない
func alwaysCalledFromMainActor() {
  MainActor.assumeIsolated { // ここで @MainActor に入る
    assert(num == 0)
    Task.immediate { // @MainActor のまま即座に実行
      num += 1
      assert(num == 1) // 間に他の仕事は挟まれない
      await asyncMainActorMethod() // ここで初めて suspend しうる
    }
  }
}

immediate task の最初の suspension に到達するまでは、他の仕事は割り込みません。assert(num == 0)num += 1 の間に別の @MainActor タスクが走ることはない、ということが保証されます。最初に suspend したあとに再開する頃には他のタスクが走った可能性があるので、状態はもう一度確認する必要があります。

タスクグループ向けAPI

通常の TaskGroup / ThrowingTaskGroup / DiscardingTaskGroup / ThrowingDiscardingTaskGroup に対しても、対応する addImmediateTaskaddImmediateTaskUnlessCancelled が追加されます。

extension (Throwing)TaskGroup {
  func addImmediateTask(
    name: String? = nil,
    priority: TaskPriority? = nil,
    executorPreference taskExecutor: (any TaskExecutor)? = nil,
    operation: sending @escaping () async throws -> ChildTaskResult
  )

  func addImmediateTaskUnlessCancelled(
    name: String? = nil,
    priority: TaskPriority? = nil,
    executorPreference taskExecutor: (any TaskExecutor)? = nil,
    operation: sending @escaping () async throws -> ChildTaskResult
  )
}

構造化同時実行としての振る舞い(キャンセル伝播、タスクローカル、priority escalation など)は通常の addTask と同じで、違いは「呼び出し元の上で即座に走り始める」という実行開始位置だけです。子タスクは通常どおりデフォルトで nonisolated で、周囲の isolation は自動継承されません。

actor Worker {
  func workIt(work: Work) async {
    await withDiscardingTaskGroup { group in
      group.addImmediateTask { // nonisolated だが、Worker 上で即座に走り始める
        let partial = await work.work() // 実際に suspend するとここで Worker を手放す
        work.moreWork(partial) // 以降はグローバル並行エグゼキュータ等で実行
      }
    }
  }
}

子タスク本体に一切 suspension が無ければ、実質的に呼び出し元アクター上で順に同期実行されるだけで、並行実行は発生しません。suspension が起きると、そこから先はアクターとは別のエグゼキュータで並行に進みます。

利用の指針

Task.immediate は呼び出し元のエグゼキュータを占有してしまう可能性があるため、乱用すべきAPIではありません。想定している使いどころは次のようなケースです。

  • 多くの場合 suspend せずに終わる軽量なタスクを、スケジューリング遅延なしで走らせたい
  • すでに目的のアクター上にいるはずの同期関数から、同じアクターの async 関数を呼びたい
  • Task.init の通常の enqueue と比べて、タスク開始までの間に他の仕事が割り込まないことを利用したい

Future Directions

Proposal では、将来の可能性としていくつかの方向性に言及されています。いずれも本Proposalでの実装は約束されていません。

  • 最初の suspension までは同期実行であるという事実を利用して、region isolation analysis よりも踏み込んだ isolation ルールを許す方向性(例えば Task.immediateDetached の中で、suspension より前に限って呼び出し元アクターの状態に触れられるようにする)。
  • @isolated(to:) のような、クロージャの isolation を関数パラメータに縛り付ける表現手段の導入。これが実現すれば、Task.immediate の operation クロージャが呼び出し元の isolation を静的に引き継ぐと表現でき、最初の suspension 後も同じ isolation に留まる、といったより自然な意味論が書けるようになります。