.agents/skills/swift-concurrency/references/memory-management.md
Use this when:
isolated deinit.Skip this file if:
actors.md.performance.md.Jump to:
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
}
Course Deep Dive: This topic is covered in detail in Lesson 8.1: Overview of memory management in Swift Concurrency
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 = b
b.a = a // Retain cycle - neither can be deallocated
When task captures self strongly and self owns the task:
@MainActor
final 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.
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 stops
task = Task { [weak self] in
while let self = self {
await self.doWork()
try? await Task.sleep(for: interval)
}
}
Course Deep Dive: This topic is covered in detail in Lesson 8.2: Preventing retain cycles when using Tasks
Loop exits when self becomes nil.
Task retains self, but self doesn't retain task. Object stays alive until task completes.
@MainActor
final class ViewModel {
func fetchData() {
Task {
await performRequest()
updateUI() // ⚠️ Strong capture
}
}
}
var viewModel: ViewModel? = .init()
viewModel?.fetchData()
viewModel = nil // ViewModel stays alive until task completes
Execution order:
viewModel = nil (but object not deallocated)Short-lived tasks that complete quickly:
func saveData() {
Task {
await database.save(self.data) // OK - completes quickly
}
}
Long-running or indefinite tasks:
func startMonitoring() {
Task { [weak self] in
for await event in eventStream {
self?.handle(event)
}
}
}
@MainActor
final 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 continues
Problem: Async sequence never finishes, task holds self indefinitely.
func startObserving() {
task = Task {
for await _ in NotificationCenter.default.notifications(
named: .didBecomeActive
) {
isActive = true
}
}
}
func stopObserving() {
task?.cancel()
}
// Usage
viewModel?.startObserving()
viewModel?.stopObserving() // Must call before release
viewModel = nil
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.
Clean up actor-isolated state in deinit:
@MainActor
final 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.
func saveData() {
Task {
await database.save(self.data)
self.updateUI()
}
}
When safe: Task completes quickly, acceptable for object to live until done.
func startPolling() {
task = Task { [weak self] in
while let self = self {
await self.fetchUpdates()
try? await Task.sleep(for: .seconds(5))
}
}
}
func startMonitoring() {
task = Task { [weak self] in
for await event in eventStream {
guard let self = self else { return }
self.handle(event)
}
}
}
func startWork() {
task = Task { [weak self] in
defer { self?.cleanup() }
while let self = self {
await self.doWork()
try? await Task.sleep(for: .seconds(1))
}
}
}
deinit {
print("✅ \(type(of: self)) deallocated")
}
If deinit never prints → likely retain cycle.
Use Leaks instrument to detect retain cycles at runtime.
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 lifetime
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")
}
func testViewDeallocates() {
var view: MyView? = MyView()
weak var weakView = view
view = nil
XCTAssertNil(weakView)
}
Task {
while true {
self.poll() // Retain cycle
try? await Task.sleep(for: .seconds(1))
}
}
Task {
for await item in stream {
self.process(item) // May never release
}
}
class Manager {
var task: Task<Void, Never>?
func start() {
task = Task {
await self.work() // Retain cycle
}
}
// Missing: deinit { task?.cancel() }
}
deinit {
task?.cancel() // Never called if retain cycle exists
}
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()
}
}
@MainActor
final 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()
}
}
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()
}
}
[weak self] in stored tasks: When self owns the task and the task captures self, a retain cycle prevents deallocation.AsyncSequence loops: for await over an infinite sequence with a strong self capture keeps the object alive forever.isolated deinit breaks retain cycles: isolated deinit runs cleanup on the correct actor, but if a cycle prevents deinit from being called at all, the cleanup never executes.try? in loops with Task.sleep: try? can swallow CancellationError, causing the loop to continue running after cancellation. Always check Task.isCancelled explicitly.When object won't deallocate:
For migration strategies, real-world examples, and advanced memory patterns, see Swift Concurrency Course.