Skip to content
academia.sh

Lesson 10 / 19

Memory Leak Detection

How a retain cycle forms, how a weak reference breaks it, and testing whether an object is really released; measuring whether two leak checks tell the correct version from the faulty one.

Published Updated

In this lesson

  • Explains that a retain cycle forms when two objects hold each other strongly
  • Explains how a weak reference breaks the cycle and why a strong capture creates one
  • Measures that a check on whether the screen shows the right data cannot see the leak while a check on release can
  • Shows that which object the counter counts determines whether the leak is seen
Contents

In the previous lesson (Profiling Tools) we measured a cell whose visible output was correct but which did more work than it needed to. In this lesson the fault is not in time but in memory. A screen closes, the user never returns to it, but the screen’s model stays in memory. The screen showed the right data and no test complains; only the app’s memory use grows a little with every screen opening.

The measurement axis is separation again: we apply the same check to the correct version and to the faulty version. A separating check passes on the correct version and fails on the faulty one. A blind check passes on both. A noisy check fails on both. An inverted check fails on the correct version and passes on the faulty one. The core is the same as in the previous lessons; we give it again because each lesson runs on its own.

// Separation.swift — classifies whether a check tells the correct version from the faulty one
struct Contrast<Output: CustomStringConvertible> {
    let label: String
    let correct: Output
    let faulty: Output
    let passesCorrect: Bool
    let passesFaulty: Bool

    var separation: String {
        switch (passesCorrect, passesFaulty) {
        case (true, false): return "separating"
        case (true, true): return "blind"
        case (false, false): return "noisy"
        case (false, true): return "inverted"
        }
    }

    func describe() -> String {
        let onCorrect = passesCorrect ? "pass" : "fail"
        let onFaulty = passesFaulty ? "pass" : "fail"
        print("\(label) | correct=\(correct)/\(onCorrect) faulty=\(faulty)/\(onFaulty) -> \(separation)")
        return separation
    }
}

func contrast<Output>(_ label: String, correct: Output, faulty: Output, check: (Output) -> Bool) -> Contrast<Output> {
    Contrast(label: label, correct: correct, faulty: faulty, passesCorrect: check(correct), passesFaulty: check(faulty))
}

func countSeparations(_ results: [String]) {
    for name in ["separating", "blind", "noisy", "inverted"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}

Contrast holds the output of the two versions and the check’s verdict on each; the separation label derives from these two verdicts. contrast applies the same closure to both outputs, and countSeparations counts the four categories. The reason for the rule: a check’s value lies not in passing but in telling the two versions apart.

The Retain Cycle

In Swift, reference counting determines the lifetime of objects. Each object counts how many places hold it strongly, and when the count drops to zero its memory is freed. If one object holds another strongly and the second also holds the first strongly, neither count ever drops to zero. This is called a retain cycle. Nobody from the outside can reach the objects, but the objects keep holding each other: this is a memory leak.

By default, a closure captures the objects it uses strongly. If a screen model stores a closure in its own property and the closure uses self, the model holds the closure and the closure holds the model.

// screen model — SOURCE
final class OrderDetailViewModel {
    var onUpdate: (() -> Void)?

    func bind(service: OrderService) {
        service.observe { order in
            self.apply(order)
        }
        onUpdate = {
            self.refresh()
        }
    }
}

Because the onUpdate closure calls self.refresh(), it captures self strongly; and self holds onUpdate strongly. Even when the screen closes, the count does not drop to zero. The fix is to give the closure a capture list and capture self weakly.

// screen model, fixed — SOURCE
final class OrderDetailViewModel {
    var onUpdate: (() -> Void)?

    func bind(service: OrderService) {
        service.observe { [weak self] order in
            self?.apply(order)
        }
        onUpdate = { [weak self] in
            self?.refresh()
        }
    }
}

[weak self] gives the closure a weak reference to self. A weak reference does not increase the object’s count; when the object is freed, the reference becomes nil on its own. This is why self is an optional value inside the closure. There is also unowned: it does not increase the count either, but using it crashes if the object is gone. It is used when it is certain that the object will outlive the closure; when you are not sure, weak is chosen.

Not every closure creates a cycle. A closure passed as a function parameter that runs immediately and finishes does not store the object anywhere. A cycle forms when the closure is stored and the one storing it is the captured object.

Finding the Leak

There are two ways to find a leak. The first is the memory graph view in the debug area of the development environment: it pauses the running app and shows the live objects and the links between them as arrows. The second is done inside a test. The test builds an object, attaches a weak observer to it, drops the object’s strong reference and checks that the observer is nil.

// leak test — SOURCE
import XCTest

final class LeakTests: XCTestCase {
    func testViewModelIsReleased() {
        var model: OrderDetailViewModel? = OrderDetailViewModel()
        weak var observer = model
        model?.bind(service: StubOrderService())
        model = nil
        XCTAssertNil(observer)
    }
}

This fragment is not run; the types above and StubOrderService are not defined here. The test builds a model, gives it a second reference with weak var observer, and drops the only strong reference with model = nil. If there is no cycle, the object is freed at that moment and observer becomes nil. If there is a cycle, the object lives and XCTAssertNil fails. To repeat the same check in several tests, addTeardownBlock(_:) can be used: a closure that checks whether the object was released runs at the end of every test when the test finishes.

The Leak Model

The model below sets up what release says about the two versions; it is not the memory graph view itself. The model uses real objects and real reference counting: the retain cycle really forms in this measurement and is really broken. No bytes, addresses or amounts of memory are printed; only whether an object is released and how many objects are alive are counted.

The screen model stores a closure with bind. The correct version uses [weak self] in the closure. The faulty version captures self strongly.

// Models.swift — correct and faulty versions of the screen model, with a live object counter
final class LiveCount {
    var screens = 0
    var loaders = 0
}

final class LoaderModel {
    let counts: LiveCount
    var completion: (() -> Void)?

    init(counts: LiveCount) {
        self.counts = counts
        counts.loaders += 1
    }

    deinit { counts.loaders -= 1 }

    func arm(leaking: Bool) {
        if leaking {
            completion = { _ = self.counts }
        } else {
            completion = { [weak self] in _ = self?.counts }
        }
    }
}

final class DetailViewModel {
    let title: String
    let counts: LiveCount
    var onUpdate: (() -> Void)?

    init(title: String, counts: LiveCount) {
        self.title = title
        self.counts = counts
        counts.screens += 1
    }

    deinit { counts.screens -= 1 }

    func bind(captureStrongly: Bool) {
        if captureStrongly {
            onUpdate = { _ = self.title }
        } else {
            onUpdate = { [weak self] in _ = self?.title }
        }
    }

    func startLoading(leaking: Bool) {
        let loader = LoaderModel(counts: counts)
        loader.arm(leaking: leaking)
    }
}

func shownTitle(captureStrongly: Bool) -> String {
    let model = DetailViewModel(title: "order", counts: LiveCount())
    model.bind(captureStrongly: captureStrongly)
    return model.title
}

func closeScreen(captureStrongly: Bool) -> Bool {
    weak var observer: DetailViewModel?
    do {
        let model = DetailViewModel(title: "order", counts: LiveCount())
        model.bind(captureStrongly: captureStrongly)
        observer = model
    }
    return observer == nil
}

func closeAndCount(leaking: Bool) -> LiveCount {
    let counts = LiveCount()
    do {
        let model = DetailViewModel(title: "order", counts: counts)
        model.startLoading(leaking: leaking)
    }
    return counts
}

closeScreen is the test’s model: weak var observer is the weak observer, the end of the do block is the closing of the screen, and the returned value is whether the observer is nil. shownTitle returns the title the screen shows. The first looks at release, the second at visible data. LiveCount and startLoading are for this lesson’s second measurement.

// main.swift — visible data and release
let title = contrast("screen-data", correct: shownTitle(captureStrongly: false), faulty: shownTitle(captureStrongly: true), check: { $0 == "order" }).describe()
let released = contrast("screen-released", correct: closeScreen(captureStrongly: false), faulty: closeScreen(captureStrongly: true), check: { $0 }).describe()

countSeparations([title, released])
screen-data | correct=order/pass faulty=order/pass -> blind
screen-released | correct=true/pass faulty=false/fail -> separating
separating: 1
blind: 1
noisy: 0
inverted: 0

screen-data is blind: both versions show the same title. The screen’s visible behavior is exactly the same in the two versions. screen-released is separating: in the correct version the weak observer becomes nil, in the faulty version the object stays alive and the observer still points to an object.

This is why a memory leak is invisible to ordinary behavior tests. No check that looks at what the screen shows says where the screen is after it closes. The cycle is real in the model too: in the faulty version the object stays until the process ends, because nobody is left to release it.

TP1. The model measures a single closure capture. The same cycle can also form through strong properties two objects give each other, or through a timer; these should be measured separately.

TP2. In the faulty version closeScreen really leaves a leak. Since the measurement runs in a single short-lived process it does no harm; in a real app every screen opening adds another leak.

What Is Counted

A second form of finding a leak in a test is to look at the live object count: it should be zero before the screen opens and zero again after it closes. The counter looks simple and strong. But what the counter counts determines the result.

Closures do not capture only the screen itself. The screen can start a loader object, and the loader’s completion closure may capture the loader. In that case the screen is freed but the loader leaks.

The measurement below sets up this situation. The screen starts a loader with startLoading. In the faulty version the loader’s closure captures the loader strongly; in the correct version it captures it weakly. The screen is freed on its own in both versions. Two values are read from the same counter: the number of live screens and the number of live loaders.

// main.swift — same counter, two counted objects
let correct = closeAndCount(leaking: false)
let faulty = closeAndCount(leaking: true)

let screens = contrast("counted-screen", correct: correct.screens, faulty: faulty.screens, check: { $0 == 0 }).describe()
let loaders = contrast("counted-loader", correct: correct.loaders, faulty: faulty.loaders, check: { $0 == 0 }).describe()

countSeparations([screens, loaders])
counted-screen | correct=0/pass faulty=0/pass -> blind
counted-loader | correct=0/pass faulty=1/fail -> separating
separating: 1
blind: 1
noisy: 0
inverted: 0

The live screen count is zero in both versions, because the screen was freed. This check is blind: it cannot see the leak. The live loader count is zero in the correct version and one in the faulty version, and the check separates. The counter is the same, the fault is the same; only the counted object changed.

The lesson here is this: a leak test that does not count the leaking object cannot find the leak. It is hard to know in advance which object may leak; this is why the memory graph view, by looking at all live objects, shows the places the test skipped. A test answers the question it is asked; a view can also show the question that was not asked.

TP3. The model sets the loader up as a helper of the screen. In a real app the leak is mostly born in a layer the screen knows nothing about.

TP4. The counter counts only two types of objects. A leak of another type is not seen in this measurement.

Common Mistakes

  • Using self in a closure without a capture list and storing the closure in a property.
  • Writing [weak self] in every closure. It is unnecessary in a closure that runs immediately and finishes.
  • Using unowned when the object’s lifetime is uncertain. If the object is gone, the app crashes.
  • Checking only that the screen was released and skipping the helper objects the screen started.
  • Looking for leaks only with behavior tests. Memory grows while the behavior stays correct.

Summary

  • A retain cycle is two objects holding each other strongly; the reference count never drops to zero and memory leaks.
  • A closure captures self strongly; [weak self] breaks the cycle, while unowned crashes if the object is gone.
  • Of two checks, one came out blind and one separating: the visible data was the same in both versions, and release happened only in the correct version.
  • The same live object counter was blind when it counted the screen and separating when it counted the leaking loader.
  • A test that finds a leak must count the leaking object; the memory graph view shows the objects that were skipped.
  • This lesson’s separation: separating.

Next Step

The leak was one fault; unnecessary work done at startup is another. In the next lesson, Launch and Scrolling Performance will measure how a performance check compared with a recorded baseline can turn inverted depending on which version the baseline was taken from.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close