しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

ARCによるオブジェクトの破棄タイミングをずらす

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になるので、Hogedeinitが呼ばれます。

コンパイラによりreleaseが呼ばれるタイミングが決められるのですが、この場合dict[1] = hogeの直後か、ブロックを抜ける直前になります。
したがって、releaseの処理はqueue2で処理されることになります。

Async Start
Sync Start
deinit (false)
Sync End
Async end

一例ですが、HogeUIViewなどだったり、あるいはその中で強参照を持っていると、 UIViewreleaseメインスレッドで動かないことになり、このコードはクラッシュを引き起こします。

このようなケースの実践的な解決策としては、弱参照を活用したり、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の整合性は保証されないので、retainreleaseを呼び間違えると別のバグの原因にもなります。

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についてのセッションがありました。こちらも参考にしてみてください。

https://developer.apple.com/videos/play/wwdc2021/10216/