The Pragmatic Ball boy

iOSを中心にやってる万年球拾いの老害エンジニアメモ

Cannot access property 'xxx' with a non-sendable type 'XXX' from nonisolated deinitの解決方法の詳解

こちらのコードをSwift6でビルドすると、 deinitのところで Cannot access property 'timer' with a non-sendable type 'Timer' from nonisolated deinit というエラーになります。

class ExampleViewController: UIViewController {
    private let timer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
            print("Hello")
    }

    deinit {
        timer.invalidate() // Cannot access property 'timer' with a non-sendable type 'Timer?' from nonisolated deinit
    }
}

以前の記事でUIViewControllerはMainActorということを書きました。 よってExampleViewControllerはMainActorとは明示的に書いていませんがUIViewControllerを継承しているのでMainActorになります。

クラスがMainActorということはプロパティやメソッドもMainActorなはずですが、エラーは nonisolated deinit と書かれています。

なぜかというと、Swift Concurrency Migration Guideに記載のようにactorにしていてもdeinitは常にnon-isolatedであるからです。 Even if a type has actor isolation, deinitializers are always non-isolated. https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems/#Non-Isolated-Deinitialization

対処方法を考える

1. deinitをMainActorにする

それならMainActorにすればいいじゃんと思うかもしれませんが、Deinitializerはactorにすることはできません。

    @MainActor // Deinitializer cannot have a global actor
    deinit {
        timer.invalidate()
    }

2. MainActor.assumeIsolatedにする

MainActor.assumeIsolated)にすることで、コンパイルは通りますし普通に動くので大丈夫っぽいですが、 deinitはMainActorから呼ばれることは保証されていません。 そのため、場合によってはMain Thread以外からもdeinitは呼ばれます。その場合MainActor.assumeIsolatedはプログラムの実行を終了させるのでアプリはクラッシュします。

    deinit {
        MainActor.assumeIsolated {
            timer.invalidate()
        }
    }

MainActor.assumeIsolatedで大丈夫な場合と、クラッシュする場合。

// このコードの場合、ExampleViewControllerのdeinitはMain Threadから呼ばれるのでMainActor.assumeIsolatedでもクラッシュしない
class ViewController: UIViewController {
    @IBAction func onClickButton(_ sender: Any) {
        let vc = SecondViewController()
        vc.modalPresentationStyle = .formSheet
        vc.view.backgroundColor = .white
        present(vc, animated: true)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        Task.detached {
            let vc = await ExampleViewController()
            print(vc)
        }
   }
// 一方このコードだとHogeのdeinitはMain Thread以外から呼ばれるのでクラッシュ
@MainActor
struct Hoge : ~Copyable{
    let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
        print("Hello")
    }

    func hoge() -> String {
        "Hoge"
    }

    deinit {
        print("deinit")
        MainActor.assumeIsolated {
            timer.invalidate()
        }
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        Task.detached {
            let hoge = await Hoge()
            print(await hoge.hoge())
        }
    }
}

3. deinit内の処理をMainActorで実行する

それならTaskをつかってMainActorで実行すればいいじゃんと思うかもしれません。

しかし Capture of 'self' in a closure that outlives deinit deinitでselfをキャプチャーすると怒られます。 これはdeinitはselfを破棄するときに最後に呼ばれるメソッドなので、deinitを抜けるとselfはメモリから消えるはずです。それなのにselfをキャプチャーすると死んだメモリを参照することになるので駄目ですよね。

    deinit {
        Task { @MainActor in // Capture of 'self' in a closure that outlives deinit
            timer.invalidate()
        }
    }

weak selfにすればワンちゃんあるのでは?と思うかもしれません。 この場合コンパイルは通すことはできるのですが、deinit後にクラッシュします。 この理由は前述のようにdeinitを抜けた時点でselfのメモリは開放されていて、解放済のメモリにアクセスしてしまっているからです。

    deinit {
        Task { @MainActor [weak self] in
            self?.timer.invalidate() // ここでクラッシュ
        }
    }

ということでどのやり方もだめでした。

解決方法

じゃあどうするのかですが、

先程の3. のやり方はこれから消されるメモリであるselfをキャプチャーするのが問題でした。 なぜselfがキャプチャーされるかというとTask.initに渡されるクロージャーに@_implicitSelfCaptureがつくように実現されていて暗黙的にselfがキャプチャーされる仕組みになっているためです。

    deinit {
        Task { @MainActor in // Capture of 'self' in a closure that outlives deinit
            timer.invalidate() // 実際には self.timer.invalidate() と解釈されるためエラーとなる
        }
    }

そこでtimerをキャプチャーするように指定してやります。 これによりselfが消えてもtimerのほうがclosureからのキャプチャーによって参照が残るのでメモリ消されずに残るためクラッシュを回避することができます。

    deinit {
        Task { @MainActor [timer] in // Cannot access property 'timer' with a non-sendable type 'Timer' from nonisolated deinit
            timer.invalidate() // self.timer.invalidate() ではなく、captureしたtimerのinvalidate()を呼んでいると解釈されるのでエラーにはならない
        }
    }

しかし、この例だとエラーになります。 これは、Timerが同じactor(MainActor)かSendableならばこれで解決だったのですが、Timerが@MainActorやSendableではないのでエラーとなります。

TimerはFoundationで提供されているクラスで@MainActorやSendableに変えることはできません。 そこで他のクラスでWrapすることで解決します。

class ExampleViewController: UIViewController {
    @MainActor
    class TimerMainActorWrapper {
        let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            print("Hello")
        }
    }

    private let timerWrapper = TimerMainActorWrapper()

    deinit {
        print("deinit")
        Task { @MainActor [timerWrapper] in
            timerWrapper.timer.invalidate()
        }
    }
}

以上、結構複雑でしたね