Swiftの場合、特に意識せずとも各オブジェクトに対してARCによるメモリ管理が行われ、コンパイル時にreference countのincrement/decrementの処理(retain
/release
)が挿入されます。
ほとんどの場合、retain cycleを避けることなどのわずかなことを意識しておけば、メモリ管理はARCに任せておいて問題ないはずです。
逆に、後述のUnmanaged
等を利用した、ARCに任せないメモリ管理を行うと、メモリ安全性を損ねることになります。
ただし、ごくまれに、意識外でのオブジェクト解放と実行スレッドの兼ね合いでクラッシュするケースがあり、何度か応急処置した記憶があるので、読み物として書いておきます。
releaseのタイミングと動作スレッドに関連するバグの例
Hoge
というクラスがあって、サブスレッドからの操作でreference countが0になるケースを考えます。
final class Hoge { // ... deinit { print("deinit (\(Thread.isMainThread))") } } var dict: [Int: Hoge] = [1: .init()] let queue = DispatchQueue(label: "hoge", qos: .background) let queue2 = DispatchQueue(label: "hoge", qos: .background) let hoge = Hoge() queue.async { print("Async Start") queue2.sync { print("Sync Start") dict[1] = hoge print("Sync End") } print("Async end") }
dict[1] = hoge
で上書きしたタイミングでreference countが0になるので、Hoge
のdeinit
が呼ばれます。
コンパイラによりrelease
が呼ばれるタイミングが決められるのですが、この場合dict[1] = hoge
の直後か、ブロックを抜ける直前になります。
したがって、release
の処理はqueue2
で処理されることになります。
Async Start Sync Start deinit (false) Sync End Async end
一例ですが、Hoge
がUIView
などだったり、あるいはその中で強参照を持っていると、
UIView
のrelease
メインスレッドで動かないことになり、このコードはクラッシュを引き起こします。
このようなケースの実践的な解決策としては、弱参照を活用したり、UIKit
関連のクラスは状態管理する部分から分けるなど、分かりづらいタイミングで暗黙的にUIView
を解放するコードを書くのを避けるのが良いです。
ただ、実際にこのようなバグに遭遇したケースもそうなのですが、SDK経由のオブジェクトでそこまで修正が届かないという場合もあるので、他の解決策も考えてみます。
変数に代入してreference countを増やす解決策
sync
の意図を維持するため、dict
への操作はatomicのままにしつつ、releaseの処理だけ後回しすることを考えます。
無難かつ愚直に実装するなら、別の変数でreferenceを持ってあげて、main threadで解放してあげるなどでしょうか。
queue.async { print("Async Start") var tmp: Hoge? = dict[1] queue2.sync { print("Sync Start") dict[1] = hoge print("Sync End") } DispatchQueue.main.async { _ = tmp // warning抑制 tmp = nil } print("Async end") }
例えばこのように別の参照を作ってあげれば、reference countが2になるので、dict[1] = hoge
でcountが1つ減っても、オブジェクトは破棄されなくなります。
Async Start Sync Start Sync End Async end deinit (true)
わざわざ参照カウントを増やすためだけに変数宣言と代入をしているため、読みづらさはあるものの、全体としてはARCの管理から外れていないので、比較的安全と言えます。
Unmanagedを使う解決策(非推奨)
ARCの管理に頼らず、手動でreference countを操作するためにUnmanaged
というクラスも用意されています。Objective-Cのmanual reference countingとほぼ同じです。
Reference countを増やしておく考え方は上のやり方と同じですが、こちらはARCのようなreference countの整合性は保証されないので、retain
とrelease
を呼び間違えると別のバグの原因にもなります。
https://developer.apple.com/documentation/swift/unmanaged
わざわざメモリ安全性を壊すようなコードを書く必要もなく、プロダクトでは非推奨ではあるものの、せっかくクラスやドキュメントがあるので、勉強のためにも実装してみます。
var dictUnmanaged: [Int: Unmanaged<Hoge>] = [1: .passRetained(.init())] queue.async { print("Async Start") queue2.sync { print("Sync Start") DispatchQueue.main.async { dictUnmanaged[1]?.release() } dictUnmanaged[1] = .passRetained(hoge) print("Sync End") } print("Async end") }
Async Start Sync Start Sync End Async end deinit (true)
Unmanaged
でwrapした場合、dictUnmanaged[1] = .passRetained(hoge)
のように上書きをしても中のインスタンスのreference countは減りません。
このため、リークや二重解放を引き起こす危険性と引き換えではあるものの、任意のタイミングでインスタンスを解放することができます。
関連
WWDC21でARCについてのセッションがありました。こちらも参考にしてみてください。