swiftswift-concurrency

Delayed Task that can be cancelled in Swift


I want to execute a task with a delay that can be cancelled and recreated on certain events. As a use case example, a value that is updated by an underlying model too often, but on UI, the updates are displayed only every once in a while.

I've seen on the internet many examples of a stored Task with Task.sleep inside, which is cancelled and replaced with a new one (for example, this post, by Swift by Sundell), but I can't make it work.

Here's an MRP of what I'm doing:

struct PlaygroundView: View {

  var body: some View {
    Text("\(countText)")
      .task {
        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) {
          task?.cancel()
          task = Task {
            try? await Task.sleep(for: .seconds(2.0))
            countText = "\(count)"
          }

          count += 1
          if count == 20 {
            $0.invalidate()
          }
        }
      }
  }

  @State private var count = 0
  @State private var countText = "0"
  @State private var task: Task<(), Never>?

}

The timer is triggered every 0.5 seconds. It cancels the previously created Task and creates a new one. Because there's a 2 seconds delay inside, I expect it to be always cancelled except for the very last timer "iteration."

However, it's not the case: countText updates every 0.5 seconds as if there's no cancellation happens.

Curiously enough, if I force-try Task.sleep, it throws CancellationError, which means that the task is cancelled during the delay. But why on earth the code after the delay is still executed? And how to implement it properly?

Thanks for any help.

UPD. After Rob's answer (the accepted one), I fixed the unintentional bug in the example that makes the countText variable unused. It doesn't affect the main message of the question.


Solution

  • You said:

    However, it’s not the case: countText updates every 0.5 seconds as if there’s no cancellation happens.

    There are a few issues:

    The main issue is the use of try?, which effectively tells it to ignore any errors thrown. So, with try? await Task.sleep(…), when the task is canceled, the Task.sleep(…) will immediately return, but try? will ignore the thrown CancellationError and it will not exit the Task {…}. Because no error was caught, it will merely proceed to the next line within that Task {…}, namely the code that updates countText.

    But that is probably not what you intended. If you really want the cancel to stop execution of the entire Task {…}, the easy solution is to use try instead of try?. Then, the Task {…} will catch the error and exit immediately, rather than proceeding to the next line within the Task. And, if you shift from try? to try, you would also need to define the task as Task<(), Error> rather than Task<(), Never>.

    The other issue is that in your original example, the Text("\(count)") isn’t observing countText, but rather count. So you are not watching countText being updated, but rather seeing count get updated. You do not appear to be using countText anywhere.

    So, use Text(countText) instead of Text("\(count)").

    [The question has been edited to remove this issue.]


    Thus, perhaps:

    struct ContentView: View {
        @State private var count = 0
        @State private var countText = "0"
        @State private var task: Task<(), Error>?
        
        var body: some View {
            Text(countText)
                .task {
                    Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) {
                        task?.cancel()
                        task = Task {
                            try await Task.sleep(for: .seconds(2.0))
                            countText = "\(count)"
                        }
                        
                        count += 1
                        if count == 20 {
                            $0.invalidate()
                        }
                    }
                }
        }
    }
    

    That will:


    There is a minor problem that persists. What if the view is dismissed? One could contemplate wrapping it in a withTaskCancellationHandler. Or I guess you could cancel the timer in .onDisappear.

    Personally, I would go a step further and retire Timer entirely.

    The easy solution is to just loop:

    struct ContentView: View {
        @State private var count = 0
        @State private var countText = "0"
        
        var body: some View {
            Text(countText)
                .task {
                    do {
                        for _ in 0 ..< 20 {
                            try await Task.sleep(for: .seconds(0.5))
                            count += 1
                            task?.cancel()
                            task = Task {
                                try await Task.sleep(for: .seconds(2.0))
                                countText = "\(count)"
                            }
                        }
                    } catch {
                        // if this view is dismissed, this whole `.task` will get
                        // canceled automatically; but because you have employed 
                        // unstructured concurrency, we will need to cancel that 
                        // `Task` manually.
    
                        task?.cancel()
                    }
                }
        }
    }
    

    There are other patterns, too (such as the AsyncTimerSequence of Swift Async Algorithms). But the key point is that we want to remain within Swift concurrency, rather than falling back to legacy patterns.


    For what it is worth, we would generally advise against introducing unstructured concurrency from within an asynchronous context. The whole premise of your question was the manual cancelation of unstructured concurrency, so hopefully I have answered that above, but we should note that this pattern is one that we avoid where possible.

    Do not get me wrong. Unstructured concurrency has utility. It gives us the ultimate control within the Swift concurrency system. But, amongst other things, we generally want to avoid it given how brittle it can be (e.g., how easy it is to overlook some path of execution where we failed to cancel manually).