LoginSignup
1
1

More than 1 year has passed since last update.

unowned/weak selfしているクロージャでTaskをつくると暗黙的キャプチャしない?

Posted at

Swift Concurrencyは Task が作られたときや await したタイミングで暗黙的に self をキャプチャするようになっています。
このおかげで例えば非同期処理中に画面を閉じたとしても、先にリソースが解放されてしまうような事態が起きないようになっています。

しかし [unowned self][weak self] でキャプチャしているクロージャ内では、この暗黙的キャプチャがされないケースがありました。

  • Xcode 13.4.1 (Swift 5.6)
  • Xcode 14.0.1 (Swift 5.7)

でこの現象が確認できましたが、今後のバージョンでは変わるかもしれません。

問題を再現するコード

モーダル画面が表示されてから約3秒後、アラートが表示されるコードを用意します。

ModalViewController.swift
final class ModalViewController: UIViewController {
    private let viewModel = ModalViewModel()
    private var cancellables = Set<AnyCancellable>()

    deinit {
        print("deinit")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        bind()
        Task {
            // echo()は3秒後、viewModel.messageにエコーバックする
            await viewModel.echo(message: "ALERT!!")
        }
    }

    private func bind() {
        // メッセージが返ってきたらアラート表示
        viewModel.$message
            .filter { !$0.isEmpty }
            .sink { [unowned self] message in
                Task {
                    await showMessage(message)
                }
            }
            .store(in: &cancellables)
    }

    // アラートでメッセージを表示する
    private func showMessage(_ message: String) async {
        return await withCheckedContinuation { continuation in
            let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
            alert.addAction(.init(title: "OK", style: .default, handler: { _ in continuation.resume() }))

            present(alert, animated: true)
        }
    }
}

このコードを実行してモーダル画面を表示した後すぐ、 アラートが表示される前に画面を閉じると、アプリがクラッシュ します!

このとき Fatal error: Attempted to read an unowned reference but the object was already deallocated というエラーログが出力されているので、どうも解放済の self 参照が発生していそうです。
もう少し詳細を知るために参照カウントを出力してみます。

    private func bind() {
        viewModel.$message
            .filter { !$0.isEmpty }
            .sink { [unowned self] message in
                print("ref count(begin): \(CFGetRetainCount(self))")
                Task {
                    // 🧨すぐにモーダル画面を閉じると、ここでクラッシュ
                    print("ref count(task): \(CFGetRetainCount(self))")
                    await showMessage(message)
                }
                print("ref count(end): \(CFGetRetainCount(self))")
            }
            .store(in: &cancellables)
    }

この状態で手順を踏むと、

ref count(begin): 2
ref count(end): 2
deinit
Fatal error: Attempted to read an unowned reference...

というログが出力されており、

  • ref count(end)2 のままで、Task が作られた後に参照カウントが増えていない
  • 先に deinit された後、 Task 内のクロージャを実行している

という挙動になっているので、意図しないクラッシュが起きてしまいそうです。

問題の原因と解決方法

先に述べたとおり [unowned self] でキャプチャしているクロージャ内 Task を生成すると、この問題が発生するようです。試しに以下のようなコード変更してみるとクラッシュが発生しません。

    private func bind() {
        viewModel.$message
            .filter { !$0.isEmpty }
            .sink { [unowned self] message in
                // メソッドを通じてTaskを実行する
                showMessage(message)
            }
            .store(in: &cancellables)
    }

    private func showMessage(_ message: String) {
        print("ref count(begin): \(CFGetRetainCount(self))")
        Task {
            print("ref count(task): \(CFGetRetainCount(self))")
            await showMessage(message)
        }
        print("ref count(end): \(CFGetRetainCount(self))")
    }

このときのログ出力は

ref count(begin): 3
ref count(end): 4
ref count(task): 2
deinit

となり、 Task が作られた後に参照カウントが増え、Task 内の非同期処理が実行されてから deinit されています。
一見すると同じ挙動になると思われるコードですが、書き方次第で最悪クラッシュしてしまうのはちょっと怖いですね...

この参照カウントが増えない問題はコンパイラのバグなのか仕様なのか分からないので、何かご存知の方いましたら教えていただけると🙇‍♂️

おまけ

アラートが表示される前にモーダル画面を閉じても、 Task はキャンセルせずインスタンスも生きているため、アラートを表示するロジックは実行されます。
しかし presentingViewController が存在しない状態になっているため、内部的に Attempt to present ... whose view is not in the window hierarchy. エラーが起き continuation.resume() がされず、メモリリークが発生してしまいます。

なので presentingViewControllernil でないことをチェックしておいたほうが良さそうです。

    private func showMessage(_ message: String) async {
        // これ何気に大事
        guard presentingViewController != nil else { return }

        return await withCheckedContinuation { continuation in
            let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
            alert.addAction(.init(title: "OK", style: .default, handler: { _ in continuation.resume() }))

            present(alert, animated: true)
        }
    }
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1