Memory Management
Memory Management
Section titled “Memory Management”Use this when:
- A task or async sequence is keeping objects alive longer than expected.
- You suspect a retain cycle between a task and its owner.
- You need to verify deallocation behavior or use
isolated deinit.
Skip this file if:
- You mainly need to protect mutable state from races. Use
actors.md. - You are debugging slow async code. Use
performance.md.
Jump to:
- Core Concepts (Task Capture)
- Retain Cycles
- One-Way Retention
- Async Sequences and Retention
- Isolated Deinit (Swift 6.2+)
- Detection and Testing
- Common Patterns
Core Concepts
Section titled “Core Concepts”Tasks capture like closures
Section titled “Tasks capture like closures”Tasks capture variables and references just like regular closures. Swift doesn’t automatically prevent retain cycles in concurrent code.
Task { self.doWork() // ⚠️ Strong capture of self}Why concurrency hides memory issues
Section titled “Why concurrency hides memory issues”- Tasks may live longer than expected
- Async operations delay execution
- Harder to track when memory should be released
- Long-running tasks can hold references indefinitely
Retain Cycles
Section titled “Retain Cycles”What is a retain cycle?
Section titled “What is a retain cycle?”Two or more objects hold strong references to each other, preventing deallocation.
class A { var b: B?}
class B { var a: A?}
let a = A()let b = B()a.b = bb.a = a // Retain cycle - neither can be deallocatedRetain cycles with Tasks
Section titled “Retain cycles with Tasks”When task captures self strongly and self owns the task:
@MainActorfinal class ImageLoader { var task: Task<Void, Never>?
func startPolling() { task = Task { while true { self.pollImages() // ⚠️ Strong capture try? await Task.sleep(for: .seconds(1)) } } }}
var loader: ImageLoader? = .init()loader?.startPolling()loader = nil // ⚠️ Loader never deallocated - retain cycle!Problem: Task holds self, self holds task → neither released.
Breaking Retain Cycles
Section titled “Breaking Retain Cycles”Use weak self
Section titled “Use weak self”func startPolling() { task = Task { [weak self] in while let self = self { self.pollImages() try? await Task.sleep(for: .seconds(1)) } }}
var loader: ImageLoader? = .init()loader?.startPolling()loader = nil // ✅ Loader deallocated, task stopsPattern for long-running tasks
Section titled “Pattern for long-running tasks”task = Task { [weak self] in while let self = self { await self.doWork() try? await Task.sleep(for: interval) }}Loop exits when self becomes nil.
One-Way Retention
Section titled “One-Way Retention”Task retains self, but self doesn’t retain task. Object stays alive until task completes.
@MainActorfinal class ViewModel { func fetchData() { Task { await performRequest() updateUI() // ⚠️ Strong capture } }}
var viewModel: ViewModel? = .init()viewModel?.fetchData()viewModel = nil // ViewModel stays alive until task completesExecution order:
- Task starts
viewModel = nil(but object not deallocated)- Task completes
- ViewModel finally deallocated
When one-way retention is acceptable
Section titled “When one-way retention is acceptable”Short-lived tasks that complete quickly:
func saveData() { Task { await database.save(self.data) // OK - completes quickly }}When to use weak self
Section titled “When to use weak self”Long-running or indefinite tasks:
func startMonitoring() { Task { [weak self] in for await event in eventStream { self?.handle(event) } }}Async Sequences and Retention
Section titled “Async Sequences and Retention”Problem: Infinite sequences
Section titled “Problem: Infinite sequences”@MainActorfinal class AppLifecycleViewModel { private(set) var isActive = false private var task: Task<Void, Never>?
func startObserving() { task = Task { for await _ in NotificationCenter.default.notifications( named: .didBecomeActive ) { isActive = true // ⚠️ Strong capture, never ends } } }}
var viewModel: AppLifecycleViewModel? = .init()viewModel?.startObserving()viewModel = nil // ⚠️ Never deallocated - sequence continuesProblem: Async sequence never finishes, task holds self indefinitely.
Solution 1: Manual cancellation
Section titled “Solution 1: Manual cancellation”func startObserving() { task = Task { for await _ in NotificationCenter.default.notifications( named: .didBecomeActive ) { isActive = true } }}
func stopObserving() { task?.cancel()}
// UsageviewModel?.startObserving()viewModel?.stopObserving() // Must call before releaseviewModel = nilSolution 2: Weak self with guard
Section titled “Solution 2: Weak self with guard”func startObserving() { task = Task { [weak self] in for await _ in NotificationCenter.default.notifications( named: .didBecomeActive ) { guard let self = self else { return } self.isActive = true } }}Task exits when self deallocates.
Isolated deinit (Swift 6.2+)
Section titled “Isolated deinit (Swift 6.2+)”Clean up actor-isolated state in deinit:
@MainActorfinal class ViewModel { private var task: Task<Void, Never>?
isolated deinit { task?.cancel() }}Limitation: Won’t break retain cycles (deinit never called if cycle exists).
Use for: Cleanup when object is being deallocated normally.
Common Patterns
Section titled “Common Patterns”Short-lived task (strong capture OK)
Section titled “Short-lived task (strong capture OK)”func saveData() { Task { await database.save(self.data) self.updateUI() }}When safe: Task completes quickly, acceptable for object to live until done.
Long-running task (weak self required)
Section titled “Long-running task (weak self required)”func startPolling() { task = Task { [weak self] in while let self = self { await self.fetchUpdates() try? await Task.sleep(for: .seconds(5)) } }}Async sequence monitoring (weak self + guard)
Section titled “Async sequence monitoring (weak self + guard)”func startMonitoring() { task = Task { [weak self] in for await event in eventStream { guard let self = self else { return } self.handle(event) } }}Cancellable work with cleanup
Section titled “Cancellable work with cleanup”func startWork() { task = Task { [weak self] in defer { self?.cleanup() }
while let self = self { await self.doWork() try? await Task.sleep(for: .seconds(1)) } }}Detection Strategies
Section titled “Detection Strategies”Add deinit logging
Section titled “Add deinit logging”deinit { print("✅ \(type(of: self)) deallocated")}If deinit never prints → likely retain cycle.
Memory graph debugger
Section titled “Memory graph debugger”- Run app in Xcode
- Debug → Debug Memory Graph
- Look for cycles in object graph
Instruments
Section titled “Instruments”Use Leaks instrument to detect retain cycles at runtime.
Decision Tree
Section titled “Decision Tree”Task captures self?├─ Task completes quickly?│ └─ Strong capture OK│├─ Long-running or infinite?│ ├─ Can use weak self? → Use [weak self]│ ├─ Need manual control? → Store task, cancel explicitly│ └─ Async sequence? → [weak self] + guard│└─ Self owns task? ├─ Yes → High risk of retain cycle └─ No → Lower risk, but check lifetimeBest Practices
Section titled “Best Practices”- Default to weak self for long-running tasks
- Use guard let self in async sequences
- Cancel tasks explicitly when possible
- Add deinit logging during development
- Test object deallocation in unit tests
- Use Memory Graph to verify no cycles
- Document lifetime expectations in comments
- Prefer cancellation over weak self when possible
- Avoid nested strong captures in task closures
- Use isolated deinit for cleanup (Swift 6.2+)
Testing for Leaks
Section titled “Testing for Leaks”Unit test pattern
Section titled “Unit test pattern”func testViewModelDeallocates() async { var viewModel: ViewModel? = ViewModel() weak var weakViewModel = viewModel
viewModel?.startWork() viewModel = nil
// Give tasks time to complete try? await Task.sleep(for: .milliseconds(100))
XCTAssertNil(weakViewModel, "ViewModel should be deallocated")}SwiftUI view test
Section titled “SwiftUI view test”func testViewDeallocates() { var view: MyView? = MyView() weak var weakView = view
view = nil
XCTAssertNil(weakView)}Common Mistakes
Section titled “Common Mistakes”❌ Forgetting weak self in loops
Section titled “❌ Forgetting weak self in loops”Task { while true { self.poll() // Retain cycle try? await Task.sleep(for: .seconds(1)) }}❌ Strong capture in async sequences
Section titled “❌ Strong capture in async sequences”Task { for await item in stream { self.process(item) // May never release }}❌ Not canceling stored tasks
Section titled “❌ Not canceling stored tasks”class Manager { var task: Task<Void, Never>?
func start() { task = Task { await self.work() // Retain cycle } }
// Missing: deinit { task?.cancel() }}❌ Assuming deinit breaks cycles
Section titled “❌ Assuming deinit breaks cycles”deinit { task?.cancel() // Never called if retain cycle exists}Examples by Use Case
Section titled “Examples by Use Case”Polling service
Section titled “Polling service”final class PollingService { private var task: Task<Void, Never>?
func start() { task = Task { [weak self] in while let self = self { await self.poll() try? await Task.sleep(for: .seconds(5)) } } }
func stop() { task?.cancel() }}Notification observer
Section titled “Notification observer”@MainActorfinal class NotificationObserver { private var task: Task<Void, Never>?
func startObserving() { task = Task { [weak self] in for await notification in NotificationCenter.default.notifications( named: .someNotification ) { guard let self = self else { return } self.handle(notification) } } }
isolated deinit { task?.cancel() }}Download manager
Section titled “Download manager”final class DownloadManager { private var tasks: [URL: Task<Data, Error>] = [:]
func download(_ url: URL) async throws -> Data { let task = Task { [weak self] in defer { self?.tasks.removeValue(forKey: url) } return try await URLSession.shared.data(from: url).0 }
tasks[url] = task return try await task.value }
func cancelAll() { tasks.values.forEach { $0.cancel() } tasks.removeAll() }}actor Timer { private var task: Task<Void, Never>?
func start(interval: Duration, action: @Sendable () async -> Void) { task = Task { while !Task.isCancelled { await action() try? await Task.sleep(for: interval) } } }
func stop() { task?.cancel() }}Common Mistakes Agents Make
Section titled “Common Mistakes Agents Make”- Forgetting
[weak self]in stored tasks: Whenselfowns the task and the task capturesself, a retain cycle prevents deallocation. - Strong capture in infinite
AsyncSequenceloops:for awaitover an infinite sequence with a strongselfcapture keeps the object alive forever. - Not cancelling stored tasks on cleanup: If the task outlives its owner, it retains captured objects indefinitely.
- Assuming
isolated deinitbreaks retain cycles:isolated deinitruns cleanup on the correct actor, but if a cycle preventsdeinitfrom being called at all, the cleanup never executes. - Using
try?in loops withTask.sleep:try?can swallowCancellationError, causing the loop to continue running after cancellation. Always checkTask.isCancelledexplicitly.
Debugging Checklist
Section titled “Debugging Checklist”When object won’t deallocate:
- Check for strong self captures in tasks
- Verify tasks are canceled or complete
- Look for infinite loops or sequences
- Check if self owns the task
- Use Memory Graph to find cycles
- Add deinit logging to verify
- Test with weak references
- Review async sequence usage
- Check nested task captures
- Verify cleanup in deinit