Delayed Enqueuing for Executors
01 何が問題だったのか
Swift concurrencyのランタイムには、グローバルエグゼキュータに対して「一定時間後」や「指定した時刻」にジョブを実行させるためのCのエントリポイント(swift_task_enqueueGlobalWithDelay / swift_task_enqueueGlobalWithDeadline)がもともと存在します。Task.sleep(for:) のような時間ベースのAPIは、最終的にこれらを経由してディレイ付きのスケジューリングを行っています。
ただしこれらは利用者が自由に差し替えられるものではなく、さらに次のような制約があります。
- スケジュール先はグローバルエグゼキュータに固定されており、カスタムのエグゼキュータに差し向けることができません。
- 利用できるクロックが
ContinuousClock(実装によっては加えてSuspendingClock)といった組み込みのものに限られ、任意のClock実装を使えません。 - トレランス(許容される追加遅延)の指定ができない、もしくは整数秒・ナノ秒のペアでしか時刻を表現できないなど、APIとしても扱いにくい形をしています。
一方で、SE-0392(Custom Actor Executors)で導入された Executor / SerialExecutor プロトコルには、ジョブを即時にエンキューする enqueue(_:) はあっても、遅延付き・時刻指定付きでジョブをスケジュールする窓口が存在しません。そのため、
- カスタムエグゼキュータが自前のタイマーやイベントループで時間ベースのスケジューリングを引き受ける、
- Swiftのコードから、
Task.sleepのような仕組みを任意のClockと組み合わせてカスタムエグゼキュータ上で使う、
といったことが、これまでエグゼキュータプロトコルのレベルではサポートされていませんでした。これはカスタムのmain / globalエグゼキュータを実装していくうえでも前提として埋めておく必要がある穴です。
02 どのように解決されるのか
エグゼキュータ側に「遅延付きエンキュー」の窓口を設けるため、新しいプロトコル SchedulingExecutor と、それに対応する Clock / Executor の拡張を導入します。
SchedulingExecutor プロトコル
Executor を直接拡張せずに、スケジューリング機能を持つエグゼキュータを表す別プロトコル SchedulingExecutor を追加します。既存のカスタムエグゼキュータ実装を壊さず、かつ「そのエグゼキュータが遅延スケジューリングに対応しているか」を型レベルで判定できるようにするためです。
protocol SchedulingExecutor: Executor {
/// 指定した時間だけ経過したあとにジョブを実行する
func enqueue<C: Clock>(_ job: consuming ExecutorJob,
after delay: C.Duration,
tolerance: C.Duration?,
clock: C)
/// 指定した時刻に達したあとにジョブを実行する
func enqueue<C: Clock>(_ job: consuming ExecutorJob,
at instant: C.Instant,
tolerance: C.Duration?,
clock: C)
}
after delay: 版と at instant: 版の両方を用意しているのは、遅延と時刻の変換には丸め誤差が伴い、エグゼキュータによっては一方だけをネイティブにサポートしている場合があるためです。実装側は どちらか一方だけを実装すればよく、もう片方はデフォルト実装が必要な計算をしてくれます。
tolerance は実行タイミングに許容される追加の遅延で、nil は上限なしを意味します。タイマーを合体させて消費電力を抑えたい場合などに利用できます。
asSchedulingExecutor で高速に判定する
あるエグゼキュータが SchedulingExecutor に適合しているかを as? キャストで毎回調べるのは、プロトコル適合テーブルの探索が走るため高コストです。そこで Executor にも次のプロパティを追加します。
protocol Executor {
/// このエグゼキュータを SchedulingExecutor として返す。
/// 非対応なら nil。
var asSchedulingExecutor: (any SchedulingExecutor)? { get }
}
デフォルト実装はランタイムキャストを行いますが、自分が SchedulingExecutor に適合していると静的にわかっているエグゼキュータは、次のように self をそのまま返すことでキャストを回避でき、コンパイラも最適化しやすくなります。
class MyExecutor: SchedulingExecutor {
var asSchedulingExecutor: (any SchedulingExecutor)? { self }
}
Clock 側の拡張
任意の Clock 実装に対応できるよう、Clock プロトコルにもジョブの実行/エンキューを引き受けるメソッドを追加します。
protocol Clock {
/// 指定時刻以降に、どこか適当なエグゼキュータ上でジョブを実行する
func run(_ job: consuming ExecutorJob,
at instant: Instant, tolerance: Duration?)
/// 指定時刻以降に、指定されたエグゼキュータ上でジョブを実行する
func enqueue(_ job: consuming ExecutorJob,
on executor: some Executor,
at instant: Instant, tolerance: Duration?)
}
Clock.enqueue にはデフォルト実装があり、run を使って「時間が来たらエグゼキュータに対して enqueue(job) するだけのジョブ」を起動します。クロックが「このエグゼキュータはちょうど自分が run で走らせる先と同じだ」と分かっている場合は、これを短絡して直接 run を呼ぶこともできます。
エグゼキュータと Clock の役割分担は次のようになります。
- エグゼキュータは、自分が直接理解できるクロック(例えば
ContinuousClock)についてはネイティブに処理します。 - 見知らぬ
Clockが渡された場合、エグゼキュータは自分のenqueue(..., clock:)からclock.enqueue(...)に処理を委ねることができ、クロック側が適切なスケジュール方法を判断します。 Clock自身がrunを実装していない状態で、未知のエグゼキュータと組み合わせて使われた場合は、実行時に致命的エラーとなります。
使い方のイメージ
特定のエグゼキュータに対して遅延付きでジョブを流したい場合は、asSchedulingExecutor で対応状況を確認してから呼び出します。
func schedule<C: Clock>(_ job: consuming ExecutorJob,
on executor: some Executor,
after delay: C.Duration,
clock: C) {
if let scheduling = executor.asSchedulingExecutor {
scheduling.enqueue(job, after: delay, tolerance: nil, clock: clock)
} else {
// スケジューリング非対応のエグゼキュータ。別の手段にフォールバックする
}
}
カスタムエグゼキュータを書く側は、まず SchedulingExecutor に適合し、after か at のどちらか実装しやすい方だけを書けば、もう片方はデフォルト実装で動きます。
Embedded Swiftでの制限
Clock 側の新しい enqueue APIはジェネリック関数を含むため、ジェネリックなプロトコル要件をサポートしていない現在のEmbedded Swiftでは利用できません。
今後の展望
このプロトコル群は、カスタムのmainエグゼキュータやglobalエグゼキュータをユーザーコードから差し替え可能にしていくための前提として位置づけられています(その具体的なAPIは別のProposalで提案されます)。ここではあくまで、エグゼキュータが遅延スケジューリングの責務を担うための土台が整えられる、という見通しにとどまります。
利用上の注意
この提案で追加されるプロトコル要件はすべてデフォルト実装付きで、ソース互換性の観点では純粋な追加です。ただしランタイムにも対応した実装が必要なため、利用するにはこの機能を含むSwift concurrencyランタイムをターゲットにする必要があります。