For await loop wrapped in a Task { } block

In principle, it is fine. However, I have a couple of comments about the given implementation.

The first is that once you create a Task, it keeps running until it has produced a result or is cancelled. From the documentation:

Tasks can start running immediately after creation; you don’t explicitly start or schedule them. After creating a task, you use the instance to interact with it — for example, to wait for it to complete or to cancel it. It’s not a programming error to discard a reference to a task without waiting for that task to finish or canceling it. A task runs regardless of whether you keep a reference to it. However, if you discard the reference to a task, you give up the ability to wait for that task’s result or cancel the task.

Since these tasks are executing an infinite loop, every time a Model object is instantiated, you create and leak three tasks.

Now, SwiftUI has a particular way of managing the lifetimes of observable objects, so it tries to not repeatedly instantiate @StateObject values if your view's identity does not change, but:

  • If your view's identity does change (for example, because it is used in a ForEach), even @StateObject values will be recreated, and
  • Not all code uses SwiftUI's particular way of managing object lifetimes. It is good engineering practice to clean up any resources you allocate, rather than leak them.

We can simulate this. Here, I've set things up really simply - I've renamed your ContentView to ChildView, and created two of them with explicit IDs. There's a button which changes their IDs.

struct ContentView: View {
  @State var firstID = 0
  @State var secondID = 1

  var body: some View {
    VStack {
      GroupBox {
        ChildView()
          .id(firstID)
          .border(.black)
        ChildView()
          .id(secondID)
          .border(.black)
      }

      Button {
        firstID += 2
        secondID += 2
      } label: {
        Text("Change IDs")
      }
    }.padding()
  }
}

struct ChildView: View {
    @StateObject private var model = Model()
    var body: some View {
        VStack {
            Button("Red") {
                model.postRed()
            }
            Button("Green") {
                model.postGreen()
            }
            Button("Blue") {
                model.postBlue()
            }
        }.padding()
    }
}

image

In Model, we print some logging information in init before we create the tasks, and again inside each worker task when it is about to complete.

@MainActor
class Model: ObservableObject {
    // ...

    init() {
      print("Creating tasks")   // <----

      Task {
        defer { print("Worker task ending") }   // <----
        for await _ in NotificationCenter.default.notifications(named: redNotificationName) {
          await taskRed()
        }
      }

      // ...
    }
}

When I launch the application, I find that it prints "Creating tasks" twice - one for each Model object. That's expected. And when I click the button to change IDs, it prints "Creating tasks" twice again - because that's how @StateObject works. But - here's the critical thing - we never see "Worker task ending". Even worse, if we change IDs a couple of times to create a bunch of tasks, then click "Red" one time, all of those tasks (which are still running) get triggered and all print to the console.

image

So how do we fix this? The last sentence of the documentation snippet tells us - we need Model to keep references to the tasks it creates in init, so it can cancel them in its deinit and stop them living forever:

@MainActor
class Model: ObservableObject {
    let redNotificationName = Notification.Name("red")
    let greenNotificationName = Notification.Name("green")
    let blueNotificationName = Notification.Name("blue")

    var red = 0
    var green = 0
    var blue = 0

    var tasks = [Task<Void, Never>]()    // <----

    init() {
      print("Creating tasks")

      tasks.append(    // <-----
        Task {
          defer { print("Worker task ending") }
          for await _ in NotificationCenter.default.notifications(named: redNotificationName) {
            await taskRed()
          }
        }
      )

      // ... same for the other 2 tasks
  }

  deinit {
    tasks.forEach { $0.cancel() }    // <----
  }
}

What is interesting is that even if you run this, our worker tasks still won't get cleaned up. What gives?

Basically, what is happening is that in order for our tasks to access properties such as redNotificationName, or invoke callbacks such as taskRed(), they need to keep the instance of Model alive which they belong to. But then, that instance of Model also has a reference to the worker task. In other words, we have a retain cycle.

Typically, Swift guards against retain cycles by having you explicitly write self in order to access this data which prolongs the object's lifetime, but Task { ... } has a special exception which allows it to omit that. The reason is that Task { ... } is intended to be used with actors; when you call it from within an actor, it inherits the actor's context, and in that particular situation no lifetime extension actually occurs.

But in this case, we're not calling it from within an actor, and so it does extend the lifetime of our Model instance and create a retain cycle. It is discussed a bit more in this thread: Task.detached doesn’t allow implicit self

The solution is to use Task.detached { ... } instead of plain Task { ... }. If we make that one tiny change, the compiler suddenly and requires us to be explicit when we extend the lifetime of the Model object:

tasks.append(
  Task.detached {
    defer { print("Worker task ending") }
    for await _ in NotificationCenter.default.notifications(named: redNotificationName) {
    //                                                             ^
    // ERROR: Reference to property 'redNotificationName' in closure requires explicit use of 'self' to make capture semantics explicit
    
      await taskRed()
      //    ^
      // ERROR: Implicit use of 'self' in closure; use 'self.' to make capture semantics explicit
    }
  }
)

In general, explicit self is a really nice feature of Swift, and it's a bit of a shame that the compiler lets us make this mistake so easily.

In order to break the cycle, we can use a weak or unowned reference to self. Since the task will be cancelled in the object's deinit, this code should never even try to access properties self after the object's lifetime has ended, so I'm going to use an unowned reference:

tasks.append(
  Task.detached { [unowned self] in
    defer { print("Worker task ending") }
    for await _ in NotificationCenter.default.notifications(named: redNotificationName) {
      await taskRed()
    }
  }
)

And that's it! We're done. Now if we click the "Change IDs" button, we can see that 2 new Model objects are created, which causes the 2 previous objects to be destroyed (there's no retain cycle keeping them alive any more), which causes them to each cancel their 3 worker tasks. We're not leaking any tasks any more.

image

And as a final test - if we click "Change IDs" a bunch of times to remake the Model objects, then click "Red", we can see that there are only 2 worker tasks alive listening to notifications (one per Model object):

image

To summarise, 2 changes were needed:

  1. Hold on to the Task references in an array, use them to cancel the tasks in deinit

  2. Create the tasks using Task.detached { ... } rather than Task { ... }, and use weak/unowned captures of self to avoid the retain cycle

11 Likes