Isolated synchronous deinit
01 何が問題だったのか
アクターやグローバルアクター isolated なクラスの deinit は、SE-0327 によって「actor-isolated な状態に同期的にアクセスしてはいけない」という制約を課されていました。deinit は最後の強参照が解放されたタイミングで任意のスレッドから呼ばれる可能性があり、そこから isolated な stored property を触るとデータ競合になり得るためです。
この制約のせいで、明示的な deinit を書いても実質的にほとんどの後始末ができず、代わりに close() 相当のメソッドを手で呼ばせたり、ひどい場合は手動のリファレンスカウントを組み上げたりする羽目になっていました。UIView や UIViewController のサブクラスのように「dealloc は必ずメインスレッドで呼ばれる」ことが実装上保証されている型でも、コンパイラから見ると deinit は nonisolated なので、警告を黙らせるために本来 Sendable ではない型に @unchecked Sendable を付けてしまう、といった危険な回避策も広がっていました。これは他の箇所での concurrency チェックまで無効化してしまい、データ競合の温床になります。
@MainActor
class Maria {
let friend: NonSendableAhmed
deinit {
// SE-0327 下ではエラー。
// friend は non-Sendable で、deinit は nonisolated なので触れない。
friend.state += 1
}
}
つまり、deinit の本体を「適切なエグゼキュータ上で実行する」仕組みがないことが根本的な問題でした。
02 どのように解決されるのか
deinit に isolated 修飾子を付けて isolated deinit として宣言できるようになりました。isolated deinit は、本体の実行・stored property の破棄・オブジェクトの deallocation を、そのクラスの isolation に対応するエグゼキュータ(アクター自身のエグゼキュータ、またはグローバルアクターのエグゼキュータ)上で行うことを保証します。ランタイムは、最後の強参照を解放したスレッドがすでに正しいエグゼキュータ上にいればそのまま同期的に実行し、そうでなければ、現在のタスク/スレッドと同じ優先度でジョブをスケジュールして hop します。
@MainActor
class Maria {
let friend: NonSendableAhmed
isolated deinit {
// MainActor に isolated されているので、
// non-`Sendable` な friend にも安全にアクセスできます。
friend.state += 1
}
}
actor Clicker {
var count: Int = 0
isolated deinit {
// self に isolated されているので、
// count を同期的に読み書きできます。
count += 1
}
}
isolated / グローバルアクター属性の付け方
後方互換性のため、クラスが isolated でも 同期 deinit は既定で nonisolated のままです。明示的に isolated を付けたときだけ、コンテナの isolation が propagate されます。
@MainActor
class Foo {
deinit {} // nonisolated のまま
isolated deinit {} // MainActor.shared に isolated
}
actor Bar {
isolated deinit {} // self に isolated
}
isolated を付けられるのは、クラス側に何らかの isolation がある場合だけです。isolation のないクラスで isolated deinit と書くとコンパイルエラーになります。ただし、isolation のないクラスでも、グローバルアクター属性を直接付けて @MainActor deinit {} のように書くことは可能です。また、コンテナと異なるアクターを deinit に指定して isolation を上書きすることもできます(同じアクターを重ねて書くのは DRY 違反ですが、エラーにはなりません)。nonisolated deinit は、同期 deinit が既定で nonisolated なので意味を持ちません。
継承との関係
isolated deinit は、super.deinit を呼び出せるかどうかの観点から検査されます。これは通常のメソッドの override 検査とは向きが逆です(通常は「override 側が override 元の vtable スロットから呼べるか」を見ますが、deinit では「派生側の本体から基底の deinit を呼べるか」を見ます)。このため、
- 基底の
deinitが nonisolated なら、派生は isolation を 追加 できます。 - 基底の
deinitが isolated なら、派生は 同じ isolation を持たなければなりません。外したり、別のアクターに変えたりすることはできません。 - 明示的に書いた
deinitには、基底の isolation は自動継承されません。明示的にisolatedやグローバルアクター属性で合わせる必要があります。暗黙に合成されるdeinitは基底の isolation を自動で継承します。
@MainActor
class Base { isolated deinit {} }
class Derived: Base {
isolated deinit {} // OK: isolation が一致
}
class Removed: Base {
deinit {} // error: 基底と isolation が不一致
}
class Implicit: Base {} // OK: 合成 deinit が isolation を継承
stored property の解放だけなら nonisolated deinit で十分なので、子オブジェクトに固有の isolation があれば、その子オブジェクト側で isolated deinit を持たせるのが基本方針になります。
実行タイミングに関する注意
isolated deinit は「後で実行」される可能性があるため、リソース解放のタイミングが非決定的になり得ます。ファイルディスクリプタやネットワーク接続など、速やかで予測可能な解放が必要なリソースは、isolated deinit ではなく withスタイル API(await withResource { ... })、明示的な await resource.close()、あるいは noncopyable / nonescapable 型で管理するのが推奨です。
また、deinit から Task { await self.click(...) } のように self を暗黙にキャプチャすると、deinit 後も self が生き続けるダングリング参照になり、Swift 5.8 以降は fatal error で検出されます。非同期処理を起こしたい場合は、必要なデータを明示的にキャプチャします。
actor Clicker {
var count: Int = 0
isolated deinit {
Task { [count] in
await logClicks(count)
}
}
}
task-local な値
最後の強参照を解放したスレッド側で設定されていた task-local 値は、isolated deinit の内側からは見えません(既定値が返り、警告も出ません)。hop が起きる場合と起きない場合で挙動を揃えつつ、task-local のコピーコストを避けるための仕様です。最後の解放地点は最適化によっても変わり得るので、deinit の中で task-local に依存するのは避け、必要な依存は stored property として注入する形にします。
Objective-C との相互運用
Objective-C のクラスは、ヘッダの @interface で dealloc メソッドに __attribute__((swift_attr(...))) を使ってグローバルアクターへの isolation を明示した場合にのみ、Swift 側で isolated deinit として import されます。これは、retain / release をオーバーライドして対応するエグゼキュータ上で dealloc を呼ぶ実装になっているクラス向けの指定です。逆に、dealloc 内部でエグゼキュータを自前で切り替えている(dealloc 自体は任意スレッドから呼ばれ得る)タイプの実装では、dealloc を isolated として宣言してはいけません。Swift から Objective-C 側には deinit の isolation 情報は露出しません(ObjC 側が Swift クラスを継承できないため)。
分散アクター
分散アクターでソースコードに書いた deinit は、ローカルアクターに対してのみ適用され、上記と同じ規則で isolated にできます。リモートプロキシ側の暗黙 deinit は常に nonisolated です。
Future Directions(参考)
今回のスコープ外ですが、将来的には deinit async として非同期 deinit を直接書けるようにする方向性や、「必ず一度だけ消費される」ことを保証する linear type、アクターに同期的に任意の仕事を積む API(enqueue / executeOrEnqueue など)の拡張も議論されています。いずれも現時点では speculative な方向性で、実現が約束されているものではありません。