A screen stores a DispatchWorkItem whose closure uses self. Does cancelling the item fix the leak, and what does?

27.3.3 · L3 · Interview

Predict first

Four screens each schedule a work item for 0.2 s later. What prints?

import Foundation

final class Screen {
    var item: DispatchWorkItem?
    let id: String
    init(_ id: String) { self.id = id }
    deinit { print("deinit", id) }

    func start(_ q: DispatchQueue) {
        item = DispatchWorkItem { print("running for", self.id) }
        q.asyncAfter(deadline: .now() + 0.2, execute: item!)
    }
    func startWeak(_ q: DispatchQueue) {
        item = DispatchWorkItem { [weak self] in print("running for", self?.id ?? "nobody") }
        q.asyncAfter(deadline: .now() + 0.2, execute: item!)
    }
    func stop() { item?.cancel(); item = nil }
}

let q = DispatchQueue(label: "w")
var a: Screen? = Screen("strong")
a!.start(q); a = nil
print("dropped strong")
Thread.sleep(forTimeInterval: 0.5)
var b: Screen? = Screen("cancelled")
b!.start(q); b!.item!.cancel(); b = nil
print("dropped cancelled")
Thread.sleep(forTimeInterval: 0.5)
var c: Screen? = Screen("stopped")
c!.start(q); c!.stop(); c = nil
print("dropped stopped")
Thread.sleep(forTimeInterval: 0.5)
var d: Screen? = Screen("weak")
d!.startWeak(q); d = nil
print("dropped weak")
Thread.sleep(forTimeInterval: 0.5)
  • dropped strong / running for strong / dropped cancelled / dropped stopped / deinit stopped / deinit weak / dropped weak / running for nobody
  • dropped strong / deinit strong / running for strong / dropped cancelled / deinit cancelled / dropped stopped / deinit stopped / dropped weak / deinit weak
  • dropped strong / running for strong / deinit strong / dropped cancelled / deinit cancelled / dropped stopped / deinit stopped / dropped weak / deinit weak / running for nobody

Mental model. Two people each holding a rope tied to the other, standing at opposite ends of a field. Telling one to stop pulling (cancel) does not make them let go; they have to drop the rope (clear the property or capture weakly).

The object owns the item (a stored property) and the item’s closure owns the object (it uses self strongly): a retain cycle, and dropping your last outside reference does not break it. In the first run the work item still fires (“running for strong”) and the screen is never deallocated: no “deinit strong” appears. cancel() is not the fix on its own: it stops a not-yet-started item from running (“running for cancelled” never prints) and cannot interrupt one that is mid-run, but the property still holds the item, and the item still holds the closure and self, so “deinit cancelled” never appears either.

Two things break the cycle. Clear the property after cancelling (stop()): the only remaining owner of the item is now the queue, which releases a cancelled item when its time comes, so “deinit stopped” appears about 0.2 s later, after “dropped stopped”. Or capture weakly ([weak self]): the object dies immediately when you drop it (“deinit weak” before “dropped weak”), and the item later runs and finds “nobody”. In practice use both: a weak capture so the screen can go away, and a cancel() in deinit or viewDidDisappear so nobody does pointless work. The same shape appears with Timer, Combine sinks and Tasks stored on the object that they use (Q140, Q143).

Only clearing the property after cancelling, or capturing weakly, frees the screen; a cancelled item that is still stored keeps its screen alive.

Common traps

  • Assuming cancel() releases the closure. It sets a flag; the references stay until the item is released.
  • Fixing the cycle but leaving the work scheduled. A weakly capturing item still runs; cancel it so it does nothing at all.

They might follow up with

  • Does cancel stop a running item? No: check isCancelled inside long work and return early.
  • How do you debug this? The memory graph debugger shows the cycle, and a log in deinit tells you quickly whether the screen is freed (Q159).

Related questions

  • 27.1.3 A closure uses [weak self] and the owner is released while the work is running. What is the difference…
  • 27.3.2 On a serial queue, which prints are guaranteed to be ordered, what does a barrier guarantee, and what happens…
  • 3.3.1 What is a retain cycle, and how do you find and fix one?
  • 3.3.3 When does a closure create a cycle, and why doesn’t a lazy var closure always?
  • 3.3.6 Can a Task cause a retain cycle, and how do you avoid it?
  • 3.7.2 How do you find a retain cycle with the Memory Graph debugger?

← On a serial queue, which prints are guaranteed to be ordered, what…