Custom isolation checking for SerialExecutor
01 何が問題だったのか
SE-0392 によりカスタムのアクターエグゼキュータを定義できるようになりましたが、Actor.assumeIsolated や assertIsolated などの実行時isolationチェックには抜け穴が残っていました。これらのAPIは、現在のスレッド上で走っている Task が追跡している「現在のエグゼキュータ」と、期待されるエグゼキュータとを突き合わせる仕組みになっています。そのため、Task 経由でエグゼキュータ上を走っているコードであれば正しく isolation を確認できますが、Task を介さずに同じエグゼキュータへ直接仕事を積んだ場合には対応できません。
典型的な例が、アクターのエグゼキュータとして DispatchSerialQueue を使い、そのキューに queue.async { ... } で直接クロージャを積むケースです。キューは本来シリアルに動作するので、そこに積まれたコードはアクターの isolated state に安全にアクセスできてもおかしくありません。しかし Task とは無関係に走っているため、Swift ランタイムから見ると「エグゼキュータに紐付いていないスレッド」としか見えず、isolation チェックが失敗してしまいます。
import Dispatch
actor Caplin {
let queue: DispatchSerialQueue = .init(label: "CoolQueue")
var num: Int // actor isolated state
// このキューを self のシリアルエグゼキュータとして使う
nonisolated var unownedExecutor: UnownedSerialExecutor {
queue.asUnownedSerialExecutor()
}
nonisolated func connect() {
queue.async {
// 確実に queue 上で実行されている
// (= self のシリアルエグゼキュータ上にいる)にもかかわらず:
self.queue.assertIsolated() // CRASH: Incorrect actor executor assumption
self.assumeIsolated { caplin in // CRASH: Incorrect actor executor assumption
caplin.num += 1
}
}
}
}
Swift ランタイムにはこの問題への対処として、MainActor と「メインスレッド」に関してだけは特別扱いが組み込まれており、Task 経由でなくてもメインスレッド上であれば MainActor への isolation チェックが通るようになっています。しかし、同じ状況はメインアクターに限らず、エグゼキュータとして使われるあらゆるスレッドで起こり得ます。DispatchSerialQueue のように既存コードで広くアクター的な isolation を担ってきた仕組みを SerialExecutor として活用するには、メインアクターに閉じた特別扱いではなく、すべての SerialExecutor が同種の「最後の手段としての isolation チェック」を提供できる必要があります。
02 どのように解決されるのか
SerialExecutor プロトコルに、新たなプロトコル要件 checkIsolated() を追加します。これは、ランタイムによる通常のエグゼキュータ比較で isolation を確認できなかった場合に、最後の手段として呼び出されるフックです。
protocol SerialExecutor: Executor {
// ...
/// Swift concurrency ランタイムが isolation assertion を行い、
/// 現在の実行コンテキストが期待されるエグゼキュータに属していると
/// 確認できなかった場合に、最後の手段として呼ばれる。
///
/// 現在のスレッドをこの SerialExecutor に isolated として扱えると
/// 証明できない場合は、このメソッドは fatalError でプログラムを
/// 停止させなければならない。
func checkIsolated()
}
extension SerialExecutor {
public func checkIsolated() {
fatalError("Incorrect actor executor assumption, expected: \(self)")
}
}
デフォルト実装は無条件に fatalError を投げるため、何も実装しなければ従来どおりの挙動(= チェック失敗)になります。独自に checkIsolated() をオーバーライドしたエグゼキュータだけが、この「最後の手段」に参加できます。
エグゼキュータ比較の流れ
assertIsolated / preconditionIsolated / assumeIsolated、および @preconcurrency コードに暗黙的に挿入されるアサーションはすべて、次のような比較ロジックを通るようになります(擬似コード)。
let current = Task.current.executor
guard let current else {
// 現在のエグゼキュータが不明: expected に最後のチェックを委ねる
expected.checkIsolated()
return
}
if isSameSerialExecutor(current, expected) {
// SE-0392 で導入された complex equality も含めた比較で一致
return
}
// 比較で一致しなかった: expected に最後のチェックを委ねる
expected.checkIsolated()
// デフォルト実装のままならここで fatalError
ポイントは、checkIsolated() が呼ばれるのが「現在のエグゼキュータが分からない場合」と「比較で一致しなかった場合」の両方だという点です。エグゼキュータ側で独自の判定手段(特定のスレッド上か、特定のキュー上か、など)を持っていれば、それを使って「このスレッドはこのエグゼキュータに isolated として扱ってよい」と証明できます。
DispatchSerialQueue での実装例
このフックの想定ユースケースの筆頭が DispatchSerialQueue です。Dispatch 側には既に dispatchPrecondition(condition: .onQueue(self)) という API があるので、それをそのまま使えます。
// Dispatch 側
extension DispatchSerialQueue {
public func checkIsolated() {
dispatchPrecondition(condition: .onQueue(self))
}
}
この実装が入ることで、冒頭の例のように queue.async { ... } から直接走るコードでも、assumeIsolated や assertIsolated が期待どおり通るようになり、アクターの isolated state に安全にアクセスできます。既存のコードベースを一度に書き換えずに、キューとアクターを併用しながら少しずつアクターへ移行していけるようになります。
独自にアクティブなワーカースレッドを識別する手段を持たないエグゼキュータについては、無理に実装せずデフォルト実装(常に fatalError)のままにしておくのが推奨される方針です。
async 関数への影響はない
assumeIsolated(_:file:line:) は同期クロージャしか受け取らないため、今回の追加によって非同期コードの正しさが損なわれることはありません。同期関数は呼び出し元の実行コンテキストをそのまま引き継ぐので、形式上 nonisolated であっても実行時には isolated なコンテキストで走り得ます。こうした同期関数に対して動的な isolation チェックを行うことには意味がありますが、async 関数は入口で自身の formal isolation に切り替わるため、そもそも動的チェックをかける意味がありません。
今後の展望
この仕組みは、現在 Swift ランタイムにハードコードされている「メインスレッド = MainActor」という特別扱いを一般化する下地にもなります。将来的には、
nonisolated(unsafe)
public var globalMainExecutor: any SerialExecutor { get }
のような「メインアクターのエグゼキュータ」を指すグローバルプロパティを導入し、ランタイムのハードコードされたヒューリスティックを globalMainExecutor.checkIsolated() の呼び出しに置き換える、という発展が考えられます。こうなれば、プラットフォームによってメインアクターがメインスレッド以外で動く場合でも、同じ枠組みで正しく isolation を確認できるようになります。現時点ではあくまで見通しの話であり、本proposalの範囲には含まれません。