Swift Concurrencyのdeinit問題が解決しました
以前LTで話したStrict Concurrencyのdeinitの話をしました。
この中でdeinitで行っていた
- ViewControllerの親子関係解除
- NotificationCenterのremoveObserver
ができなくなって、困っているという話をしました。
このたび対応できたので、それについて書きます。
deinit問題の詳細
前提がややこしいので、ちょっと詳しく書きます。
Swift Concurrencyの導入前、deinitでこんな処理をやっていました。
// ViewControllerの親子関係解除
class ParentViewController: UIViewController {
private let childViewController = ChildViewController()
deinit {
childViewController.willMove(toParent: nil)
childViewController.removeFromParent()
childViewController.view.removeFromSuperview()
childViewController.didMove(toParent: nil)
}
}
// NotificationCenterのremoveObserver
class ParentViewController: UIViewController {
private var refreshObserver: NSObjectProtocol?
override public func viewDidLoad() {
refreshObserver = NotificationCenter.default.addObserver(forName: .refresh, object: nil, queue: .main, using: { [weak self] _ in
self?.reload()
})
}
deinit {
if let refreshObserver {
NotificationCenter.default.removeObserver(refreshObserver)
}
}
}
//
Swift 6のStrict Concurrencyを導入すると、deinitの処理はエラーになります。
Swift 6以前だとエラー出ずに実行時クラッシュでした。
deinitでself触るとクラッシュになります。
Never extend the life-time of self from within deinit. Doing so will crash at runtime.
これは別にConcurrencyの問題ではなく、元々deinitでself触るのは危険な処理でした。
deinitが呼ばれるのはselfの解放タイミングで、解放タイミングでself触ると、
selfに対する参照カウントが0->1->0になり、結果が不安定になる模様です。
Concurrency前はタイミングによって成功したりクラッシュしたりメモリリークしたりしていた?と思われます。
(ここの理解ちょっとあやふやです)
ともかくConcurrencyでdeinitに制約ができたのではなく、
今までdeinitでよくない処理をしていたのがConcurrency導入で浮き彫りになった、というのが正しい理解です。
対応方針
前提が長くなりましたが、対応方針について書きます。
deinitで解放処理は行わず、ARCの仕組みなどを使って解放されるようにする、というのが基本となります。
ViewControllerの親子関係解除は、適切に参照関係がついてれば、参照カウントが0になって、ARCで自動解放されます。
NotificationCenterのremoveObserverも、addObserver(_:selector:name:object:)
を使っているか、
AnyCancellableで保持していれば、deinitで自動解放されます。
If your app targets iOS 9.0 and later or macOS 10.11 and later, you do not need to unregister an observer that you created with this function. If you forget or are unable to remove an observer, the system cleans up the next time it would have posted to it.
addObserver(_:selector:name:object:): https://developer.apple.com/documentation/foundation/notificationcenter/1415360-addobserver
An AnyCancellable instance automatically calls cancel() when deinitialized.
AnyCancellable: https://developer.apple.com/documentation/combine/anycancellable
ただし、addObserver(forName:object:queue:using:) を使っている場合、
開発者が removeObserver()
を呼ぶ必要があるみたいです。
まず親子VCの解放についての試行錯誤を書きます。
ViewControllerの親子関係解除
サンプルコードとして親子関係をつけただけのViewControllerを2つつくって、
それらが破棄されるような動きを実装してみると、deinitで何もしてなくても、ちゃんと解放されます。
もし確認したければ、deinitにprint文仕込んでみると、両者とも解放されているのがわかると思います。
ただプロダクトコードで確認すると、子VCのdeinitが呼ばれませんでした。
親VCの表示回数ごとに、子VCのインスタンスが増えていきます。
メモリリーク状態です。
強参照を調べる
ARCの仕組み上、参照カウントが0にならないといけないので、どこかに強参照が残っていることになります。
ありがちなのはdelegateで強参照持っちゃって、循環参照になってるパターンです。
var delegate: ChildViewControllerDelegate?
これはわかりやすいです。
weakをつけることで弱参照にできます。
weak var delegate: ChildViewControllerDelegate?
真っ先に疑ってコード見たんですが、残念ながらここは問題ありませんでした。
メモリ情報のデバッグツール
普段あまり使いませんが、Xcodeにはメモリ情報をデバッグできるツールがついています。
まずこれを使って、状況を確認しました。
こんなドキュメントがありまして、いくつか紹介されています。
で、この中のMemory Graph Debuggerを使いました。
Xcodeのここを押します。
こんな図が出て、参照関係が見れます。
メモリリークしていることは確認できたんですが、ゾンビ化しているインスタンスに対する参照が存在するものの、それが何なのかよくわかりませんでした。
CFNotificationCenterは低レイヤーの通知機構らしいんですが、開発者が指定しているものではないので、よくわかりません。
このメモリグラフの結果も不安定で、明らかに関係ないVCから参照されてることもあり、謎でした。
単純な循環参照の検出だったら、グラフ上でわかったんだと思うんですが、今回のケースでは役に立ちませんでした。
ちなみに、deinitの中で CFGetRetainCount(self)
を吐かせると、そのときの参照カウントが見れまして、確かに0になってないことが確認できました。
クロージャーの意図しないselfキャプチャ
クロージャーが原因で強参照を持っていました。
ネストが深くて相当難しいところでしたが、こんな感じの場所でした。
public final class ChildViewController: UIViewController {
private lazy var collectionViewDataSource: UICollectionViewDiffableDataSource<Section, Item> = {
let cellRegistration = UICollectionView.CellRegistration<Cell, Item> { cell, _, _ in
cell.delegate = self
}
return UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
}
}()
}
cellのdelegateは弱参照で定義されてるので問題ないと思っていましたが、どうもここで強参照持ってた模様です。
let cellRegistration = UICollectionView.CellRegistration<Cell, Item> { [weak self] cell, _, _ in
cell.delegate = self
}
このようにweak selfにしてあげると、無事子VCが解放されました。
発見するきっかけは、CFGetRetainCount(self)
で参照カウント吐かせたところ、解放されたタイミングで4つ参照カウントが残っていたので、それでセルを疑いました。
どこまでweak selfしたらいい?
collectionViewDataSource
の最上位クロージャーは [weak self]
してないですが、この状態でdeinit呼ばれてました。
この挙動は謎なんですけど、最適化の結果なのかな?と思っています。
NOTE 最適化として、Swift は、値がクロージャによって変更されていない場合やクロージャの作成後に値が変更されていない場合、代わりに値のコピーをキャプチャして保存する場合があります。 Swiftは、変数が不要になったときの変数の破棄に関わるメモリ管理を全て行ないます。
NotificationCenterのremoveObserver
前述の通り、NotificationCenterの購読解除はdeinitでやらなくても、addObserver(_:selector:name:object:)
か AnyCancellable
で購読していれば、自動解除されます。
ただプロダクトコードでは、Concurrency化によって、Taskベースで保持するようにしていました。
具体的には下記の手法でした。
override func viewDidLoad() {
super.viewDidLoad()
registerNotification()
}
deinit {
notificationsTask?.cancel()
}
private var notificationsTask: Task<Void, Never>?
private func registerNotification() {
notificationsTask = Task {
await withTaskGroup(of: Void.self) { group in
group.addTask {
for await _ in NotificationCenter.default.notifications(named: .refresh).map(\.name) {
await MainActor.run { [weak self] in self?.reload() }
}
}
// …
}
}
}
この処理があるクラスだと、deinitが呼ばれていないことに気づきました。
なので対処する必要がありました。
これも想定しないクロージャーのselfキャプチャ
どうやらfor awaitループの中でselfがキャプチャされてる扱いになっていたみたいです。
元のコードでも[weak self]で持ってきているので安全に見えたんですが、無限ループ文の最初にself
に対する早期退出がないと、キャプチャする仕様のようです。
必要最小限の[weak self]にしたかったんですが、デバッグした結果、ここまで[weak self]をチェーンしてあげないとdeinit呼ばれませんでした。
notificationsTask = Task { [weak self] in
await withTaskGroup(of: Void.self) { group in
group.addTask { [weak self] in
for await _ in NotificationCenter.default.notifications(named: .refresh).map(\.name) {
guard let self else { return }
await MainActor.run {
self.reload()
}
}
}
// …
}
}
場所によってはSwiftFormatでself消されちゃうので、swiftformat:disable redundantSelf
の指定が必要なときもありました。
Taskはselfを強参照するが、実行が終わったらTaskが解放されるので通常は問題なしという扱いになっています。
またselfを明示しなくてもselfにアクセスできる @_implicitSelfCapture
という指定もされています。
(Case7の補註を参照)
これは便利なのですが、その反面selfを強参照しているという意識が薄れそうな仕様ですね。
現状、Appleの公式ドキュメントに @_implicitSelfCapture
の話は出てこず、Swift Evolutionのproposalにあるだけのようです。
Taskの中に無限ループ入れている場合、例外的に [weak self] が必要でした。
Taskのイニシャライザと、addTaskにも両方必要(デバッグ結果から判断)なのは、両者とも @_implicitSelfCapture
が指定されてるからなのかと推測しています。
2 についても同じで、ループの外に guard let self = self else { return } を書いてしまうと、結局そこで self を strong キャプチャすることになります。そうすると、 self はループが終わるまで strong キャプチャし続けられ、循環参照によるメモリリークの原因となります。
終わりに
以上の対応を入れることで、無事メモリリークに対応できました。
Concurrency対応の一般的な話からズレた話題もあったので、どれだけ他の方の役に立つかは定かではないですが、お役に立てば幸いです。
僕自身書きながら、メモリ管理やselfキャプチャの仕様について正確に理解できてないところがあるのを感じたので、
訂正や補足がある方はコメントいただけたら嬉しいです。
(了)
Discussion