Swift Digest
SE-0371 | Swift Evolution

isolatedな同期deinit

Isolated synchronous deinit

Proposal
SE-0371
Authors
Mykola Pokhylets
Review Manager
Frederick Kellison-Linn
Status
Implemented (Swift 6.2)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

アクターやグローバルアクター isolated なクラスの deinit は、SE-0327 によって「actor-isolated な状態に同期的にアクセスしてはいけない」という制約を課されていました。deinit は最後の強参照が解放されたタイミングで任意のスレッドから呼ばれる可能性があり、そこから isolated な stored property を触るとデータ競合になり得るためです。

この制約のせいで、明示的な deinit を書いても実質的にほとんどの後始末ができず、代わりに close() 相当のメソッドを手で呼ばせたり、ひどい場合は手動のリファレンスカウントを組み上げたりする羽目になっていました。UIViewUIViewController のサブクラスのように「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 どのように解決されるのか

deinitisolated 修飾子を付けて 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 のクラスは、ヘッダの @interfacedealloc メソッドに __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 です。

03 今後の見通し

元のProposalでは、isolated synchronous deinit を足がかりにいくつかの将来的な拡張が議論されています。いずれも今後の方向性として示されているもので、実現を約束するものではありません。

非同期 deinit

deinit から非同期処理を起こしたいケースでは、現状は Task { [service] in await service.shutdown() } のように、必要なデータを明示的にキャプチャして手でタスクを起こす必要があります。self を暗黙にキャプチャしてしまうと runtime で fatal error になります。

@MainActor
class ViewModel {
  let service: Service

  deinit {
    // 誤り: self が暗黙にキャプチャされる
    _ = Task { await service.shutdown() }

    // 正しい書き方
    _ = Task { [service] in await service.shutdown() }
  }
}

将来的には、deinit async として deinit 自体を非同期に書けるようにし、非同期処理の完了後に stored property の破棄やメモリ解放を行う、という案が検討されています。元のオブジェクトをそのままタスクのクロージャ context として再利用できるため、ほぼ全プロパティをコピーするようなケースでは効率も良くなります。

linear type

非同期のクリーンアップは suspension point になるため、await を伴う明示的なメソッド呼び出しの方が deinit よりも自然です。一方で、明示メソッドだと「呼び忘れ」をコンパイラで検出できないという問題があります。non-copyable な型は「consume が高々1回」であることを保証できますが、「ちょうど1回」を強制することはできません。これに対し、@linear のような linear type を導入すれば、consume必ず 1回行われることをコンパイラに要求でき、close() のようなクリーンアップメソッドの呼び忘れをコンパイル時に検出できるようになる、という構想が示されています。

@linear // @moveonly に似るが consume が必須
struct Connection {
  consuming func close() async { ... }
}

func communicate() async {
  let c = Connection(...)
  // error: linear 型の値が consume されていない
}

エグゼキュータ取得処理の最適化

@MainActor な関数や isolated deinit のエントリポイントでは、現状 MainActor.shared.unownedExecutor へのアクセスが2回呼び出され、しかも片方は動的ディスパッチを経由します。これらを swift_task_getMainExecutor() への単一の静的呼び出しに置き換えることで、de-virtualization とインライン化を進められる余地があります。

拡張スタックトレース

isolated deinit にブレークポイントを置いたとき、エグゼキュータの hop が起きていると、最後の release を呼んだ側のコールスタックがデバッガで見えません。これを表示できるようにする改善も今後の検討対象として挙げられています。

アクターに同期的にジョブを積む API

isolated deinit のために追加されたランタイム関数は deinit 用に最適化された呼び出し規約を持ちますが、似たシグネチャのランタイム関数を用意すれば、アクターに対して同期的に任意の仕事を積む API を提供できます。具体的には Actor の拡張として enqueue / executeOrEnqueue のようなメソッドを追加する案が示されています。

extension Actor {
  /// アクターのキューにジョブを積み、self を渡して work を呼ぶ
  nonisolated func enqueue(_ work: __owned @Sendable @escaping (isolated Self) -> Void)

  /// アクターのエグゼキュータが現在のものなら即実行、そうでなければキューに積む
  nonisolated func executeOrEnqueue(_ work: __owned @Sendable @escaping (isolated Self) -> Void)
}

let a = MyActor()
a.enqueue { aIsolated in
  aIsolated.inc() // await 不要
}