1

According to Apple's documentation, a task{} will automatically cancel when a view disappears.

SwiftUI will automatically cancel the task at some point after the view disappears before the action completes.

Why, in this scenario, is the Task in the 'loadData' method not cancelled when the ChildView disappears? (When navigating back to the 'ParentView')

struct ChildView: View {
    
    @State private var data: [Double] = []
    
    private func loadData() async {
        // Why isn't this Task automatically cancelled when the ChildView disappears?
        // The Task is still executing in the background.
        Task(priority: .background) {
            // Simulating heavy data processing.
            self.data = (1...3_000_000).map { _ in Double.random(in: -10...30) }
            print("Task ended")
        }
    }
    
    var body: some View {
        Text("This is a child view")
            .task { await loadData() }
    }
}

struct ParentView: View {
    var body: some View {
        NavigationStack {
            NavigationLink(destination: { ChildView() }) {
                Text("Show child view")
            }
        }
    }
}

The task in 'loadData' continues executing even when the ChildView disappears. It also executes multiple times when the ChildView is initialized multiple times in a row.

This could potentially lead to memory leaks, especially when using a class with a @Published 'data' property instead of the @State property in the ChildView. What is the correct implementation for using a Task() in such a scenario?

Hollycene
  • 287
  • 1
  • 2
  • 12

2 Answers2

4

SwiftUI does cancel the task created implicitly by the task view modifier, but that task doesn't do the "heavy data processing". That task only creates a subtask to run loadData. This subtask completes almost immediately.

This is because all loadData does is it creates a top level task by using Task { ... } and does nothing else. By the time your view disappears, the loadData task would have already completed. The top level task, however, does all the "heavy data processing", and because it is a top-level task (i.e. not a child of the loadData task), it doesn't get cancelled when loadData is cancelled.

You should not create a top level task here. Put the heavy data processing directly in loadData.

Also, task cancellation is cooperative - loadData should also check Task.isCancelled and stop what it's doing.

private func loadData() async {
    for _ in 1...3_000_000 {
        if Task.isCancelled { // for example
            break
        }
        self.data.append(Double.random(in: -10..<30))
    }
    print("Task ended")
}
Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • As you mentioned above, the execution of the '.task{}' modifier, as well as 'loadData', completes immediately after creating a top-level 'Task()'. The issue arose from using an unstructured top-level 'Task' in 'loadData' without any cancellation policy. Thank you for clarification. – Hollycene Aug 23 '23 at 06:12
1

Sweeper nailed it on the head. (+1)

A few additional observations/clarifications:

  1. The first problem is that Task {…} is unstructured concurrency. In structured concurrency, you can “let Swift handle some behaviors like propagating cancellation for you”, but in unstructured concurrency, you bear this responsibility yourself. For example, you would have to employ withTaskCancellationHandler:

    private func loadData() async throws {
        let task = Task(priority: .background) { [iterations] in
            try (1...iterations).map { index in
                try Task.checkCancellation()
                return Double.random(in: -10...30)
            }
        }
    
        data = try await withTaskCancellationHandler {
            try await task.value
        } onCancel: {
            task.cancel()
        }
    }
    
  2. The second issue is that a computationally intensive routine must check to see if the task was canceled. I generally prefer try Task.checkCancellation(), as shown in my example above, which not only checks for cancelation, but also throws a CancellationError if canceled. (This way, the caller also knows it was canceled and can act accordingly.) But you can also use a manual test for if Task.isCancelled {…}, as shown by Sweeper.

    Obviously, if you’re going to throw the CancellationError with checkCancellation (as shown in the examples above), the caller would presumably catch it:

    var body: some View {
        Text("This is a child view")
            .task {
                do {
                    print("starting")
                    try await loadData()
                    print("finished")
                } catch {
                    print("caught error", error)
                }
            }
    }
    
  3. Obviously, in point 1, above, you can see that unstructured concurrency complicates our code a little. But if all you want is to get this off the main actor, but remain within structured concurrency, you can use a non-isolated async function (which loadData already is).

    Thanks to SE-0338 - Clarify the Execution of Non-Actor-Isolated Async Functions, a non-isolated async function already runs off the current actor. So you get this off the main actor, but by remaining within structured concurrency, it significantly simplifies our code:

    private func loadData() async throws {
        data = try (1...iterations).map { _ in
            try Task.checkCancellation()
            return Double.random(in: -10...30)
        }
    }
    

    Obviously, if you prefer to use unstructured concurrency (e.g., to specify the priority), then feel free. But make sure you understand what you are signing up for (e.g., manually handling of cancelation).


As an aside, you should avoid blocking the Swift concurrency task with a computationally intensive routine. You can resolve this by periodically calling Task.yield():

private func loadData() async throws {
    var array: [Double] = []
    array.reserveCapacity(iterations)

    for index in 1 ... iterations {
        if index.isMultiple(of: 1_000) {
            await Task.yield()
            try Task.checkCancellation()
        }
        array.append(.random(in: -10...30))
    }

    data = array
}

Now, this pattern of periodically yielding is generally only a concern when writing long-running, computationally intensive routines. If you simply await some other async function, then this is not necessary. It is only a concern for long-running or otherwise blocking functions.

See Integrate a blocking function into Swift async or How do I run an async function within a @MainActor class on a background thread?

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Thank you for the thorough answer! It truly addresses all my questions. The third point seems to be the optimal solution for me. However, I will delve deeper into the other options to gain a better understanding of the topic. The example of a heavily computational task was used merely to illustrate that the Task in the 'loadData' method doesn't cancel when a view disappears. In a real-world scenario, the task would likely be simpler and less intensive. My goal was to find a proper solution that prevents any potential memory leaks. Once again, thank you. – Hollycene Aug 23 '23 at 05:57