Dynamic actor isolation enforcement from non-strict-concurrency contexts
01 何が問題だったのか
Swiftでデータ競合安全性を段階的に導入するために、SE-0337 で @preconcurrency import が用意されました。これは、strict concurrency checking に対応していない依存ライブラリから発生する並行性関連の警告を抑制するための仕組みです。
ただし、こうしたライブラリには「このAPIは必ずメインスレッド(もしくは特定のディスパッチキュー)から呼ぶこと」といった動的な約束だけで成り立っているものが多数あります。利用側が strict concurrency を有効にしていても、ライブラリ側のactor isolation違反は素通りしてしまい、最終的にデバッグが難しいデータ競合として顕在化するおそれがありました。
また、@preconcurrency import はプロトコルへの適合には効きません。ライブラリ側のプロトコルが「要件はメインスレッドでしか呼ばれない」という動的保証を持っていても、それを @MainActor に isolate されたクラスで実装しようとするとコンパイルエラーになります。
// NotMyLibrary 側
public protocol ViewDelegateProtocol {
func respondToUIEvent()
}
// クライアント側
import NotMyLibrary
@MainActor
class MyViewController: ViewDelegateProtocol {
func respondToUIEvent() { // error: @MainActor function cannot satisfy a nonisolated requirement
// ...
}
}
回避策として、要件を nonisolated で実装したうえで本体を MainActor.assumeIsolated で包む、という書き方が取られていました。
@MainActor
class MyViewController: ViewDelegateProtocol {
nonisolated func respondToUIEvent() {
MainActor.assumeIsolated {
// ...
}
}
}
しかしこの書き方には二つの弱点があります。
- 要件ひとつひとつに
nonisolatedとMainActor.assumeIsolatedを書き足す必要があり、ボイラープレートが増える - 自モジュール内の他の箇所からも
respondToUIEvent()を任意のisolation domainから呼べてしまうため、自分のコードのデータ競合安全性まで弱まる
加えて、actor-isolatedなメソッドを @objc 経由で Objective-C から呼ぶ場合や、isolationを持った関数値を strict concurrency 未対応のAPIに渡す場合、さらに Swift 6 でビルドされたモジュールの actor-isolated なAPIを Swift 5 の @preconcurrency 経由で呼ぶ場合など、静的チェックの外側からisolationが破られる経路がいくつも残っていました。
02 どのように解決されるのか
静的チェックの外側からisolationが破られる可能性のある境界に、実行時のactor isolationチェックを挿入します。同期のisolated関数が想定外のエグゼキュータから呼ばれた場合、実行時エラーでプログラムを停止させ、データ競合として顕在化する前に問題を捉えます。
async 関数にはこの検査は不要です。async 関数は呼び出し時に常に callee 側のアクターへ切り替わり、かつ C/C++/Objective-C から直接呼ばれることもないためです。
@preconcurrency conformance
プロトコル適合に @preconcurrency を付けると、actor isolationに関する witness checker の診断が抑制されます。代わりに、isolatedなwitnessが nonisolated な要件を満たすケースでは、コンパイラが実行時チェックを挿入します。
import NotMyLibrary
@MainActor
class MyViewController: @preconcurrency ViewDelegateProtocol {
func respondToUIEvent() {
// 実装
}
}
この書き方では、
NotMyLibraryの内部からrespondToUIEvent()がメインアクター外で呼ばれた場合、実行時アサーションで停止する- 自モジュール内から誤ってメインアクター外で呼んだ場合は、引き続きコンパイルエラーになる
という二段構えで isolation を守れます。@preconcurrency は主宣言にも extension にも書けます。抑制すべき診断が何もなければ、@preconcurrency 自体が効果なしの警告になります。
これらのチェックは、同期のactorメソッドが nonisolated な要件を満たす場合にも同様に適用されます。
@objc thunk
actor-isolatedなクラスやメンバーを @objc / @objcMembers で Objective-C に公開する場合、コンパイラが生成する thunk に precondition チェックが追加されます。Objective-C 側から誤ったアクター上で呼ばれた場合も、実行時に検出されるようになります。
isolationを erase する関数値
actor-isolatedな同期関数値を、isolationを落とす形で strict concurrency 未対応のAPIに渡すと、コンパイラが呼び出し側を防御的に変換します。
@MainActor
func updateUI(view: MyViewController) {
NotMyLibrary.track(view.renderToUIEvent)
}
ここで track が同期の nonisolated 関数型を受け取るとすると、内部的には次と等価なコードに変換されます。
@MainActor
func updateUI(view: MyViewController) {
NotMyLibrary.track({
MainActor.assumeIsolated {
view.renderToUIEvent()
}
})
}
結果として、ライブラリ側が想定外のアクター上でこの関数値を呼んだ場合も、実行時に弾かれます。
Swift 6 モジュールからインポートしたactor-isolated関数の呼び出し
Swift 6 でビルドされたモジュールの actor-isolated な関数を、Swift 5 かつ minimal な strict concurrency でビルドされた側から @preconcurrency 付きで呼ぶと、静的にはアクター外から呼べてしまう穴が残ります。
// ModuleA: -swift-version 6
@MainActor public func onMain() { ... }
// ModuleB: -swift-version 5 -strict-concurrency=minimal
import ModuleA
@preconcurrency @MainActor func callOnMain() {
onMain()
}
func notIsolated() {
callOnMain() // ここを経由してメインアクター外から onMain が呼ばれうる
}
このケースでは、ModuleB を ModuleA(Swift 6 化後)に対して再コンパイルしたタイミングで、onMain() の呼び出し側に実行時チェックが挿入されます。
実行時チェックの範囲と無効化
実行時チェックは「外側の未チェックコードから呼ばれる可能性がある場合」に限って挿入されます。エコシステム全体が Swift 6 に移行するに従って、このチェックは徐々に不要になり、実行時オーバーヘッドも減っていきます。
どうしても無効化が必要な場合は、コンパイラフラグ -disable-dynamic-actor-isolation で全面的にオフにできます。ただしこれは、制御できないコードがisolation違反を起こしてクラッシュするような緊急回避にのみ使うべきもので、通常は推奨されません(かつて -enforce-exclusivity=unchecked が提供されたのと同じ位置付けです)。
この機能は upcoming feature flag DynamicActorIsolation としても提供され、Swift 6 言語モードでは既定で有効になります。