Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why isn't a Task automatically cancelled when a View disappears?

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?

like image 575
Hollycene Avatar asked Sep 06 '25 00:09

Hollycene


2 Answers

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")
}
like image 157
Sweeper Avatar answered Sep 07 '25 20:09

Sweeper


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. (Prior to iOS 18, the methods of a View were not actor isolated, but now they are, so we must add the nonisolated declaration.)

    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 nonisolated func loadData() async throws {
        data = try (1...iterations).map { _ in
            try Task.checkCancellation()
            return Double.random(in: -10...30)
        }
    }
    

    Note that by virtue of SE-0461, as of Swift 6.2, nonisolated tasks are no longer automatically run on a generic executor (i.e., moved off the current thread). If you want this traditional behavior, you may need to explicitly mark the function as @concurrent.

    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 nonisolated 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?

like image 30
Rob Avatar answered Sep 07 '25 19:09

Rob