🐥

Swift Concurrencyの Task でのself参照

2022/07/25に公開

Swift ConcurrencyのTask#initのoperationクロージャー内の self 参照や [weak self] は 普通の @escaping なクロージャーとは違った部分があったため、PlayGroundでの挙動確認をメモする。

Closureで循環参照になるパターン

まず、[weak self] を利用するよくあるパターンの @escaping なクロージャーでの循環参照が発生する例について、例えば下記の様なコードで循環参照が発生する。

class Counter {
    var count = 0
    var closure: (() -> Void)?
    
    deinit {
        print("deinit")
    }

    func incrementOnClosure() {
        self.closure = {
            self.increment()
        }
        self.closure!()
    }
    
    private func increment() {
        self.count += 1
        print("count: \(self.count)")
    }
}

var counter: Counter? = Counter()
counter?.incrementOnClosure()
counter = nil

// 出力結果
// count: 1
// -> deinitがコールされない

上記では下記の2つの参照が循環しているため、循環参照する。

  • counter -> counter#closure
  • counter#closure -> self(counter)
    • closure内でselfをコールすると暗黙的にselfが強参照される。

この循環参照の解決策の1つとして [weak self] を用いるよくあるパターンが下記。

class Counter {
    var count = 0
    var closure: (() -> Void)?
    
    deinit {
        print("deinit")
    }

    func incrementOnClosure() {
        // [weak self] 追加
        self.closure = { [weak self] in
            self?.increment()
        }
        self.closure!()
    }
    
    private func increment() {
        self.count += 1
        print("count: \(self.count)")
    }
}

var counter: Counter? = Counter()
counter?.incrementOnClosure()
counter = nil

// 出力結果
// count: 1
// deinit

こうすると、修正前の counter#closure -> self(counter) の方向の強参照がなくなるため、counter = nil のタイミングでcounterが解放される(deinitがコールされる)。

ちなみに、@escaping なクロージャー内では [weak self] (or [onowned self])しないと必ず循環参照になるというわけではなく、@escaping なクロージャーを保持していてかつクロージャー内で self 参照すると循環参照した状態になるので、たとえば下記の様なクロージャーを保持していない場合では循環参照にならない。

class Counter {
    var count = 0
    
    deinit {
        print("deinit")
    }

    func incrementOnClosure() {
        let closure = {
            self.increment()
        }
        closure()
    }
    
    private func increment() {
        self.count += 1
        print("count: \(self.count)")
    }
}

var counter: Counter? = Counter()
counter?.incrementOnClosure()
counter = nil

// 出力結果
// count: 1
// deinit

Task内でのself参照

結論から、自分が試したところでは Taskのoperationクロージャーでは上記の様な循環参照は発生しなかった。だが、operationクロージャー内でselfを参照していると、Task実行中は強参照されるため、特にTask内で awaitする様な処理を行なっていた場合に注意が必要だと思った。

いろいろとコード例と実行結果を記載して行く。

普通にTask内でself参照

class Counter {
    var count = 0
    
    deinit {
        print("deinit")
    }

    func incrementOnTask() {
        Task {
            self.increment()
        }
    }
    
    private func increment() {
        self.count += 1
        print("count: \(self.count)")
    }
}

var counter: Counter? = Counter()
counter?.incrementOnTask()
counter = nil

// 出力結果
// count: 1
// deinit

ちなみに、Taskのoperationクロージャー@_implicitSelfCaptureという属性が付いてて暗黙的にselfを利用できるため、selfを省略できる。

    func incrementOnTask() {
        Task {
            increment() // self. を省略可能
        }
    }
    
    private func increment() {
        self.count += 1
        print("count: \(self.count)")
    }

Task内でawaitがあるときのself参照

class Counter {
    var count = 0
    
    deinit {
        print("deinit")
    }

    func incrementOnAfter3Seconds() {
        Task {
            do {
                try await Task.sleep(nanoseconds: 3_000_000_000)
                self.increment()
            } catch {
                print(error)
            }
        }
    }
    
    private func increment() {
        self.count += 1
        print("count: \(self.count)")
    }
}

Task {
    var counter: Counter? = Counter()
    counter?.incrementOnAfter3Seconds()
    try! await Task.sleep(nanoseconds: 1_000_000_000)
    counter = nil
}

// 出力結果(3秒後に下記が出力される)
// count: 1
// deinit

コード的には、counter?.incrementOnAfter3Seconds() したあと即座に counter = nil しているが、 Taskself をキャプチャしているため、Taskが終わるまで deinitはコールされない。
なのでこの挙動(counter = nilしているのに selfが維持されてTask内のself.increment()が実行される)を防止するための解決策として、 [weak self] が利用できる。
上記のコードのTask部分を下記の様に[weak self] を追記するとこうなる。

    func incrementOnAfter3Seconds() {
        Task { [weak self] in
            do {
                try await Task.sleep(nanoseconds: 3_000_000_000)
                self?.increment()
            } catch {
                print(error)
            }
        }
    }

// 出力結果
// deinit
// -> count: 1 は出力されない。

上記の [weak self] のあとに guard let self = self else { return } をすると、deinitされる前に self.increment() が実行されるので注意が必要です。

    func incrementOnAfter3Seconds() {
        Task { [weak self] in
            guard let self = self else { return }
            do {
                try await Task.sleep(nanoseconds: 3_000_000_000)
                self?.increment()
            } catch {
                print(error)
            }
        }
    }

// 出力結果
// count: 1
// deinit
// ->  selfがTask内で強参照されておりはcount: 1を出力してからdeinitする。

[weak self] 書かずにTaskの処理を止める場合は task.cancel() でもできる。

class Counter {
    var count = 0
    var task: Task<Void, Never>?
    
    deinit {
        print("deinit")
    }

    func incrementOnAfter3Seconds() {
        task = Task {
            do {
                try await Task.sleep(nanoseconds: 3_000_000_000)
                self.increment()
            } catch {
                print(error)
            }
        }
    }
    
    private func increment() {
        self.count += 1
        print("count: \(self.count)")
    }
}

Task {
    var counter: Counter? = Counter()
    counter?.incrementOnAfter3Seconds()
    try! await Task.sleep(nanoseconds: 1_000_000_000)
    counter?.task?.cancel()
    counter = nil
}

// 出力結果
// CancellationError()
// deinit
// -> `await Task.sleep()`中に `Task#cancel()` するとCancellationError()が発生してtaskが終了する。

参考

https://zenn.dev/koher/articles/swift-concurrency-cheatsheet
https://tech.mirrativ.stream/entry/2022/05/31/120125
https://github.com/apple/swift-evolution/blob/main/proposals/0304-structured-concurrency.md#implicit-self

Discussion