Custom Actor Executors
01 何が問題だったのか
Swift Concurrencyの設計は、「コードが具体的にどのスレッドで・どの仕組みで動くか」を意図的に曖昧にしています。多くのコードは実行環境の詳細には依存せず、actor isolationのような高レベルの意味論だけを必要とするため、このあいまいさがランタイム側のスケジューリング最適化の余地を生みます。
しかし実際には、どこでコードが走るかをもっと細かく制御したい場面があります。
- 既存システムとの協調: UIコードはメインスレッドで動かす必要がある、シングルスレッドのイベントループランタイムはすべての呼び出しが特定のスレッドから来ることを前提にしている、あるいは既存コードが特定のキューで共有状態を守っている、といった状況です。後者の場合、原理的にはアクターパターンそのものなのですが、一度に全部書き換えるのが現実的でないことが多く、既存キューをアクターのエグゼキュータとして使えれば段階的な移行が可能になります。
- 特定スレッドへの依存: スレッドローカル変数に状態を持つライブラリや、処理能力が不均一なスレッド(特定プロセッサに固定されたスレッドなど)を扱う場合、誤ったスレッドで動かすと前提が崩れます。
- 性能上の明示的制御: 頻繁に相互呼び出しするアクター同士を同じエグゼキュータに乗せれば切り替えコストを減らせますし、非同期関数が同じアクターに連続してアクセスする場合、そのアクターのエグゼキュータで最初から走らせられれば切り替えのオーバーヘッドを避けられます。
加えて、他の並行処理モデルからSwift Concurrencyへ移行する途中では、「このコードは期待したエグゼキュータ/スレッドで動いているか」を動的にチェックしたい、あるいは「ここは確実にメインアクター上だからメインアクター隔離された状態に同期的に触っても安全」と表明したい、といったニーズも出てきます。静的に @MainActor やアクターメソッドとして表現できればそれが一番良いのですが、同期的なプロトコル要件を満たすレガシーコードなど、静的に表現できないケースも残ります。
Swiftには当初からアクターや Executor 型が存在していたものの、利用者がカスタムのシリアルエグゼキュータを実装してアクターに結び付けたり、エグゼキュータ一致をチェック・前提化したりするための公開APIが揃っていませんでした。この提案は、その最初の基礎を整えるものです。
02 どのように解決されるのか
カスタムのシリアルエグゼキュータをアクターに関連付けるための最小限のAPI群を導入します。中心となるのは次の要素です。
Executor/SerialExecutorプロトコル- アクターが自身のエグゼキュータを指定するための
unownedExecutorプロパティ - エグゼキュータに渡される仕事の単位を表す
ExecutorJob(およびその unowned 版UnownedExecutorJob) - 「今この実行コンテキストは期待したエグゼキュータか」を確認・前提化する
assertIsolated/preconditionIsolated/assumeIsolated
Executor と SerialExecutor
Executor は「ジョブを実行できるサービス」を表すルートプロトコルで、並列実行も直列実行も許容します。ランタイムは enqueue(_:) を通じて仕事を渡します。
public protocol Executor: AnyObject, Sendable {
func enqueue(_ job: consuming ExecutorJob)
@available(*, deprecated, message: "Implement the enqueue(_:ExecutorJob) method instead")
func enqueue(_ job: UnownedExecutorJob)
}
Swift 5.5 時点で存在していた UnownedExecutorJob ベースの enqueue は非推奨化され、move-onlyな ExecutorJob を受け取る新形式に置き換わります。古い実装も当面は動きますが、コンパイラが新APIへの移行を促す警告を出します。
SerialExecutor は Executor を絞り込み、「一度にひとつのジョブだけを実行し、相互排他を保証する」ことを約束します。アクターのisolationはこのプロトコルの上に成り立っています。
public protocol SerialExecutor: Executor {
func asUnownedSerialExecutor() -> UnownedSerialExecutor
func isSameExclusiveExecutionContext(other executor: Self) -> Bool
}
asUnownedSerialExecutor() は、参照カウント操作を避けるためにランタイム内部で使われる軽量ハンドル UnownedSerialExecutor を返します。デフォルト実装があるため通常は自分で書く必要はありません(UnownedSerialExecutor(ordinary: self) を返すだけです)。
SerialExecutor が順序について守るべきルールは次のとおりです。
enqueue(_:)の呼び出しは、対応するExecutorJob.runSynchronously(on:)よりも先に発生しなければなりません。- 同じシリアルエグゼキュータに投入された任意の2つのジョブ A と B について、「A のすべてのイベントが B より前に起きる」か「B が A より前に起きる」のどちらかでなければなりません。優先度による並び替えは許されますが、一方が完了してからもう一方が始まる必要があります。
素朴なカスタムエグゼキュータの例
特定スレッド上でジョブを実行するエグゼキュータは次のように書けます。
final class SpecificThreadExecutor: SerialExecutor {
let someThread: SomeThread // 特定スレッドへの簡略化したハンドル
func enqueue(_ job: consuming ExecutorJob) {
// クロージャへ渡すためにunownedに変換する
let unownedJob = UnownedExecutorJob(job)
someThread.run {
unownedJob.runSynchronously(on: self.asUnownedSerialExecutor())
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
extension SpecificThreadExecutor {
static var sharedUnownedExecutor: UnownedSerialExecutor {
// 共有インスタンスを返す
}
}
アクターへの結び付け
アクターは Actor / DistributedActor プロトコルの unownedExecutor 要件を実装することで、自分のシリアルエグゼキュータを指定します。コンパイラが暗黙に合成するデフォルト実装は、プラットフォームの標準スケジューリング機構(Dispatchなど)を使う「デフォルトアクター」になります。
actor Worker {
nonisolated var unownedExecutor: UnownedSerialExecutor {
SpecificThreadExecutor.sharedUnownedExecutor
}
}
unownedExecutor は nonisolated で、同じアクターインスタンスに対して常に同じ値を返す必要があります。また、アクターが生きている間はエグゼキュータも生き続ける責任があります(異なるオブジェクトならアクターが強参照で保持する必要があります)。ランタイムはこのプロパティを任意のタイミングで読み、結果をマージ・省略・並び替えする可能性があるため、副作用を含めてはいけません。
MainActor には sharedUnownedExecutor という静的プロパティがあり、別のアクターをメインアクターと同じエグゼキュータ(= メインスレッド)で走らせることもできます。
actor MainActorsBestFriend {
nonisolated var unownedExecutor: UnownedSerialExecutor {
MainActor.sharedUnownedExecutor
}
}
このとき MainActor と MainActorsBestFriend は別のアクターで、原理的には並行に動けるはずですが、同じシリアルエグゼキュータを共有している ため、実際には同時には走りません。シリアルエグゼキュータが一度にひとつのジョブしか動かさないので、両者のあいだにも相互排他が保たれるわけです。
ライブラリ側で専用のエグゼキュータ型を要求するプロトコルを提供する、といった使い方もできます。
protocol WithSpecifiedExecutor: Actor {
nonisolated var executor: LibrarySpecificExecutor { get }
}
protocol LibrarySpecificExecutor: SerialExecutor {}
extension WithSpecifiedExecutor {
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
}
ExecutorJob
ExecutorJob はエグゼキュータが実行すべき「仕事の一単位」を表す、noncopyable(move-only)な型です。Task は概念的には一連のジョブの列として表現されます。
@noncopyable
public struct ExecutorJob: Sendable {
public var priority: JobPriority { get }
}
extension ExecutorJob {
// このメソッドはジョブを消費する
public consuming func runSynchronously(on executor: UnownedSerialExecutor)
}
move-onlyの制約でまだ扱いにくいケース(コレクションへ入れる、ジェネリック文脈で使うなど)のために、アンセーフな UnownedExecutorJob も用意されます。こちらは消費されないため、二重実行は未定義動作 になります。可能な限り ExecutorJob 側のAPIを使うのが推奨です。
ジョブの description にはジョブ/タスクIDが含まれ、Instrumentsなどの観測ツールに現れるIDと対応するので、スケジューリングのデバッグに使えます。
エグゼキュータに対するアサーション
同期コードから「今の実行コンテキストは期待したシリアルエグゼキュータか」を確認するAPIとして、Actor・DistributedActor・SerialExecutor それぞれに preconditionIsolated / assertIsolated が追加されます(前者は常時、後者はデバッグビルドのみ)。
func synchronousButNeedsMainActorContext() {
// メインアクターのエグゼキュータ上でなければクラッシュ
MainActor.preconditionIsolated()
// 同上、ただしデバッグビルドのみ
MainActor.assertIsolated()
}
アクター/分散アクター版は、実際に走っているエグゼキュータ名を含むわかりやすい診断を出します。
Precondition failed: Incorrect actor executor assumption;
Expected 'MainActorExecutor' executor, but was executing on 'Sample.InlineExecutor'.
チェックは「アクターインスタンス単位」ではなく「シリアルエグゼキュータ単位」で行われます。複数のアクターが同じエグゼキュータを共有している場合、それらはいずれも同じisolationドメインに属するとみなされるため、どのアクターのメソッドから呼んでもアサートは通ります。ただし静的には別アクターなので、アクター間呼び出しでは引き続き await が必要です。
エグゼキュータの仮定(assumeIsolated)
「このコードは確実にメインアクターのエグゼキュータから呼ばれる」とわかっているのに @MainActor で静的に表現できない場面のために、assumeIsolated が導入されます。現在の実行コンテキストが期待したエグゼキュータであれば、クロージャを同期的にメインアクター(またはそのアクター)隔離で実行できます。違っていればクラッシュします。
@MainActor func example() {}
func alwaysOnMainActor() /* 同期関数 */ {
MainActor.assumeIsolated { // メインアクターのエグゼキュータ上でなければクラッシュ
example() // 安全に同期呼び出しできる
}
}
インスタンスアクター版は isolated な自身の参照をクロージャに渡します。
extension Actor {
@available(*, noasync)
func assumeIsolated<T>(
_ operation: (isolated Self) throws -> T,
file: StaticString = #fileID, line: UInt = #line
) rethrows -> T
}
assumeIsolated は同期文脈専用で、非同期コードでは @MainActor やアクターメソッドとして静的に表現する方が望まれます。分散アクター版も用意されますが、リモート参照に対しては常にクラッシュします(リモート参照はメモリ上に実体を持たないため、isolateして触ること自体が不正です)。
エグゼキュータ同一性の「深い比較」
アサート系APIが「同じシリアル実行コンテキストか」を判定する際、デフォルトではエグゼキュータのポインタを比較するだけです。デフォルトアクターの場合、各アクターに固有のエグゼキュータインスタンスが割り当てられるので、この比較は事実上「同じアクターか」と同じ結果になります。
ですが、ひとつのシリアルエグゼキュータに別のエグゼキュータをターゲットとして紐付ける、といった構成もあり得ます(dispatch queueがターゲット先のキューへ転送するケースなど)。このような場合にポインタ比較だけでは不十分なので、次の2段階の仕組みが提供されます。
UnownedSerialExecutor(ordinary:): 通常のエグゼキュータ。ポインタ比較のみ。UnownedSerialExecutor(complexEquality:): 「深い比較」を希望するエグゼキュータ。ポインタが一致しない場合、両者の型が等しければisSameExclusiveExecutionContext(other:)を呼び出して判定します。
extension MyQueueExecutor {
public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(complexEquality: self)
}
func isSameExclusiveExecutionContext(other: Self) -> Bool {
// 例えば両方が同じ「ターゲットキュー」を指しているか確認する
self.targetQueue === other.targetQueue
}
}
逆に、同じシリアルエグゼキュータに乗せたいがアサート上は別の実行コンテキストとして扱いたい場合は、委譲先のエグゼキュータを包む「ユニークなラッパーエグゼキュータ」を用意することで、固有のアイデンティティを持たせられます。
デフォルトのエグゼキュータ
Swift Concurrencyランタイムは次の既定エグゼキュータを備えます。
MainActorのエグゼキュータ(@MainActorなコードを実行)- デフォルトのグローバル並行エグゼキュータ(特別な指定のないタスクや、トップレベルの
async関数などを実行)
メインアクターのエグゼキュータは MainActor.sharedUnownedExecutor から取得できますが、実装型そのものは公開されません。これは環境に応じてランタイムが実装を切り替えるためで、利用側は常にunownedラッパー越しに扱います。グローバル並行エグゼキュータは直接アクセスできませんが、「特定のエグゼキュータを要求しないジョブ」はすべてここで実行されます。
Future Directions
今回のスコープ外として、次のような拡張が議論されています(speculativeであり、実現を約束するものではありません)。
MainActorのエグゼキュータ自体を差し替えるAPI。プログラム起動時にsetMainActorExecutor(...)のような形で、独自のランループなどをメインアクターに割り当てることを想定します。- エグゼキュータ間の「スイッチング」: 互換性のあるエグゼキュータ同士で現在のスレッドを明け渡し/引き継ぎすることで、不要なスレッドホップを省く最適化。
Task(startingOn: someExecutor) { ... }のように、タスク単位でエグゼキュータを指定する機能。delegateActorプロパティ: あるアクターが他のアクターと同じエグゼキュータ上で動くことを型レベルで宣言し、その2つのアクター間のawaitを静的に省略できるようにする拡張。