29
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Taskで[weak self]を使わないで!

Posted at

概要

  • Task は循環参照が発生しないので、中で self を通さなくてもオブジェクトの自分自身のインスタンスにアクセスできてデフォルトで強参照でキャプチャされる
  • 強参照で self がキャプチャされているならインスタンスがタスクが完了するまで保持されるが、タスクが完了すると正常に解除される
  • タスク完了までインスタンスの寿命を伸ばしたくないときに [weak self] を使うと実装ミスが発生しやすいしタスク自体がリークしやすい
  • タスクのキャンセルで対応しましょう

そもそもどういうときに [weak self] を使いたいのか?

クロージャを書くときに弱参照と強参照を意識しないといけません。参照型オブジェクトは強参照で保持しているところがないときに破棄されるが、もしオブジェクト同士がお互いのインスタンスを保持したりとかクラス自体が直接的・間接的に自分のインスタンスを保持している場合は循環参照が発生してそのオブジェクトがリークしてしまいます。

class Foo {
    var counter = 0

    var action: (() -> Void)?

    init() {
        setup()
    }

    func setup() {
        action = {
            self.counter += 1
        }
    }

    // リークすると破棄されないから一生呼ばれない
    deinit {
        print("deinit Foo")
    }
}

この形では循環参照が発生し Foo のインスタンスは永久的に破棄されないからメモリリークが発生します。

そこでキャプチャリストに [weak self] を指定すると弱参照に変わりメモリリークが解消されます。

    func setup() {
        action = { [weak self] in
            self?.counter += 1
        }
    }

Swift の言語方針として、こういうふうに循環参照が発生しうるクロージャでは self を明示的に指定しないといけなく省略ができないようになっています。逆にいえば、循環参照が発生しないクロージャは self の指定を省略してもいいようになっています。

Task はどう違うのか?

Taskを使うとすぐに発見するのは、参照型オブジェクトで使ったときでも明示的の self が省略できてキャプチャリストでも指定しなくてもいいようになっています。

なぜそうなっているかというと、たとえそのタスクのインスタンスをオブジェクトで保持してもTask 自体では循環参照が発生しないからです。Swift の言語方針に沿って self 省略の条件がクリアされているから特別に@_implicitSelfCapture という隠し属性が付けられています。

class Bar {
    var counter = 0

    var task: Task<Void, Never>?

    init() {
        setup()
    }

    func setup() {
        task = Task {
            counter += 1 // self はなくてもいい
        }
    }

    // 循環参照が発生しないからちゃんと呼ばれる
    deinit {
        print("deinit Foo")
    }
}

でも、こういうときに必要なんじゃ?

ここで落とし穴が一つあります。Task の中で自分自身へのアクセスがあると、そのインスタンスの寿命がタスクが完了するまで延長されます。

例えば、タスクが実行されている時間は10秒だった場合は、10秒後にインスタンスが破棄されます。

    func setup() {
        task = Task {
            try? await Task.sleep(nanoseconds: 10 * 1_000_000_000) // 10秒待つ
            counter += 1
        }
    }

さらにいうと、自分で完了しない非同期処理があると永久的に寿命が延ばされ実質ではオブジェクトがリークします。

    func setup() {
        task = Task {
            let dates = Timer.publish(every: 1, on: .main, in: .common).autoconnect().values // Combine の Publisher から AsyncSequence を作成
            for await _ in dates  {
                counter += 1
            }
        }
    }

そこで今まで通りに completionHandler の実装経験にも基づいてクロージャのキャプチャリストに[weak self] を設定する形で対応したとします。

    func setup() {
        task = Task { [weak self] in
            let dates = Timer.publish(every: 1, on: .main, in: .common).autoconnect().values
            for await _ in dates  {
                self?.counter += 1
            }
        }
    }

よし、これでリークが解消された!と思いきや果たしてこの方針で対応すればいいのでしょうか。

問題点1

weak を付けただけで実行されるタスクがキャンセルされるわけでもなく、リークする可能性があります。

    func setup() {
        task = Task { [weak self] in
            let dates = Timer.publish(every: 1, on: .main, in: .common).autoconnect().values
            for await date in dates  {
                print(date) // self が nil になった後でも `dates` は生き続けるからこのタスクは永久的に走る
                self?.counter += 1
            }
        }
    }

問題点2

よくある guard でオプショナルをアンラップして使いやすくした場合、その場所によって対処策となりません。

    func setup() {
        task = Task { [weak self] in
            // guard let self else { return } // ❌ ここで置くと dates を await する前にアンラップされてオブジェクトがリークしてしまう
            let dates = Timer.publish(every: 1, on: .main, in: .common).autoconnect().values
            for await date in dates  {
                print(date)
                guard let self else { return } // ⚠️ ここなら OK。早期リターンがあることで、問題点1も解消される
                self.counter += 1
            }
        }
    }

ちゃんと場所も意識して guard をつけると対応できなくはないが、実装・レビューで常にその観点で見ないといけないのは割としんどく運用コストが高いように見えます。

問題点3

キャプチャリストで [weak self] を指定したとしても、self を通さない自分自身のインスタンスへのアクセスは引き続き可能でコンパイルエラーになりません。

        task = Task { [weak self] in
            let dates = Timer.publish(every: 1, on: .main, in: .common).autoconnect().values
            for await date in dates  {
                print(date)
                guard let self else { return }
                self.counter += 1
            }

            counter += 1 // ‼️ self を付けなくてもアクセスできる。せっかくの weak self / guard でリーク回避したコードは台無しになる。
        }

これも実装・レビューの負担がかかる要因となります。

じゃ、どう対応すればいい?

積極的にタスクキャンセルの仕組みを使いましょう。

上記のサンプルコードでは task を保持しているからそれに対して .cancel() を呼び出せばいいです。

Bar().task?.cancel()
    func setup() {
        task = Task {
            let dates = Timer.publish(every: 1, on: .main, in: .common).autoconnect().values
            for await date in dates  {
                print(date)
                guard !Task.isCancelled else { return } // 必須ではないが、キャンセルしたら以下の処理を実行させたくないなら付けてもいい
                counter += 1
            }
        }
    }

当然、オブジェクトの寿命が長いのは本来の問題だからオブジェクト自体の deinit からは呼び出しできなくどうしても外部からの制御が必要になってきます。実際にどう制御するかは、使う場面によって違ってきますが iOS 開発でよくありそうなのは画面のライフサイクルに紐づく形です。例えば VC の viewDidLoad で処理を開始して deinit でキャンセルするみたいな実装を想定しています。

extension Task: Cancellable { } // 標準でも準拠してもおかしくないが、していないので手動でする

class Baz {
    var counter = 0

    private var tasks: [any Cancellable] = []

    func setUp() {
        tasks.append(
            Task {
                let dates = Timer.publish(every: 1, on: .main, in: .common).autoconnect().values
                for await date in dates  {
                    print(date)
                    guard !Task.isCancelled else { return }
                    counter += 1
                }
            }
        )
    }

    func tearDown() {
        tasks.forEach { $0.cancel() }
    }

    deinit {
        print("deinit Baz")
    }
}

class VC: UIViewController {
    let baz = Baz()

    override func viewDidLoad() {
        super.viewDidLoad()
        baz.setUp()
    }

    deinit {
        print("deinit VC")
        baz.tearDown()
    }
}
29
21
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
29
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?