swiftswift-concurrency

Why Swift Task is being suspended after finished?


I found that the Task was being suspended even if the closure of the Task had been done.

So I make a test code like below.

struct ContentView: View {
    let testClass = TestClass()

    var body: some View {
        Button(action: {
            testClass.start()
        }, label: {
            Text("button")
        })
    }
}

class TestClass {
    var task: Task<Void, Never>?

    func start() {
        task?.cancel()
        task = Task {
            try? await Task.sleep(nanoseconds: 5_000_000_000)
        }
    }
}

And instrument is showing like below.

enter image description here

My assumption was that the task will have a status like finished, because the closure was done already. But it's not. It's showing suspended and still alive.

Is this a right concept or I made a wrong example?

I want to get a reference of the task for cancel, and don't want to see a count on Alive Tasks. If I add a code like task = nil after sleep, it would work as I expected.

Could it be a right workaround?


Solution

  • If you save the Task in a property, the underlying task still exists (even if it finished running its closure), including the task state (i.e., its value or result).

    Now, in your example, the Task returns a Void, so it is hard to see this behavior. But, imagine a Task that returned a value or throws errors. You can still fetch [try] await task?.value even after the Task is done, and you will see the result of the Task closure.

    Consider this variation, where we have a separate button that verifies the result of the Task:

    import SwiftUI
    import os.log
    
    struct ContentView: View {
        let experiment = Experiment()
    
        var body: some View {
            VStack {
                Button {
                    Task { await experiment.start() }
                } label: {
                    Text("start")
                }
    
                Button {
                    Task { await experiment.verify() }
                } label: {
                    Text("verify")
                }
            }
            .padding()
        }
    }
    
    actor Experiment {
        var task: Task<Int, Error>?
        let signposter = OSSignposter(subsystem: "Experiment", category: .pointsOfInterest)
    
        func start() async {
            signposter.emitEvent(#function, "start")
            let state = signposter.beginInterval(#function, id: signposter.makeSignpostID())
    
            task?.cancel()
            let task = Task {
                try await Task.sleep(for: .seconds(5))
                return Int.random(in: 0..<100)
            }
            self.task = task
    
            do {
                let result = try await task.value
                signposter.endInterval(#function, state, "result = \(result)")
            } catch {
                signposter.endInterval(#function, state, "error = \(error)")
            }
    
            // Really, we should wrap the above `do`-`catch` in a cancellation handler,
            // as we’re responsible for that when introducing unstructured concurrency,
            // but I was trying to keep it simple. But, you’d probably do something like:
            //
            // await withTaskCancellationHandler {
            //     do {
            //         let result = try await task.value
            //         …
            //     } catch {
            //         …
            //     }
            // } onCancel: {
            //     task.cancel()
            // }
        }
    
        func verify() async {
            signposter.emitEvent(#function, "start")
            let state = signposter.beginInterval(#function, id: signposter.makeSignpostID())
    
            guard let task else {
                signposter.endInterval(#function, state, "no task")
                return
            }
    
            do {
                let result = try await task.value
                signposter.endInterval(#function, state, "result = \(result)")
            } catch {
                signposter.endInterval(#function, state, "error = \(error)")
            }
        }
    }
    

    If I profile that (with not only the “Swift Tasks” tool, but also the “Points of Interest” tool that captures my OSSignposter events) and first tap “start” (the first ⓢ signpost) and then tap “verify” before start finishes (the second ⓢ), we see:

    profile w verify before first task finishes

    So, we see that as soon as the “start” task finishes, the “verify” task does, too. And the “verify” task shows the same result of the Task as we saw in the “start” task. This is interesting, but perhaps unsurprising.

    But, if we instead defer the tap on “verify” until the “start” task finishes, we still see the “verify” task show the result for the “finished” task:

    profile w verify after first task finishes

    Now in this case, the “verify” task runs nearly instantaneously (because it does not have to wait for the “start” Task to finish), but, still, although the “start” Task was already “finished”, the “verify” routine still returns the value of the saved Task.


    But, I agree with you that referring to a finished Task as “suspended“ because it still exists, is arguably a curious choice of terminology. But you can also see why it might still be considered “alive”, as it still exists and you can still fetch its value (and or determine if it was cancelled or threw other error) even though its closure has finished execution.


    You said:

    I want to get a reference of the task for cancel, and don't want to see a count on Alive Tasks. If I add a code like task = nil after sleep, it would work as I expected.

    Could it be a right workaround?

    Yes, that is a fine solution if you really do not need the task, anymore.

    For what it is worth, the amount of memory consumed by this Task that has finished execution is fairly minimal. Any locals or variables captured by the closure are released: It is largely just the task state and its results that are saved once execution finishes.