Starting tasks synchronously from caller context
01 何が問題だったのか
Swift Concurrency で非同期コンテキストに入る唯一の方法は、Task を作ることです。Task.init や Task.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.detached や addTask と同じく 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 に対しても、対応する addImmediateTask と addImmediateTaskUnlessCancelled が追加されます。
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 に留まる、といったより自然な意味論が書けるようになります。