Search code examples
iosswiftswiftuiasync-awaitswift-concurrency

Difference of TaskPriority for Task.cancel()


I tried this post https://www.swiftbysundell.com/articles/building-an-async-swiftui-button/ where the code is from but have added the priority constant in the struct. The Button is doing what it should be (only show the ProgressView when the sleep time is over and then only so long until the action is done) if I use the priority of .utility but not if I use .userInitiated. Why is that? Shouldn't both be cancelled by the .cancel() function before the showProgressView = true statement gets fired?

When tapping on the button ".userInitiated" the console output is:

  1. task cancelled
  2. task created

video: https://i.sstatic.net/zhsnQ.jpg

struct ContentView: View{
    @State var count = 0
    @State var showProg = false
    func heavyTask() async throws ->Int{
        var k = count
        for _ in 0..<5_000_000{
            k += 1
        }
        return k
    }
    var body: some View{
        VStack{
            AsyncButton(action: {try? await count = heavyTask()},actionOptions: [.showProgressView], label: {
                Text(".utility")
            }, priority: .utility)
            AsyncButton(action: {try? await count = heavyTask()},actionOptions: [.showProgressView], label: {
                Text(".userInitiated")
            }, priority: .userInitiated)
            Text("result: \(count)")
        }
    }
}

struct AsyncButton<Label: View>: View {
    var action: () async -> Void
    var actionOptions = Set(ActionOption.allCases)
    @ViewBuilder var label: () -> Label
    let priority: TaskPriority

    @State private var isDisabled = false
    @State private var showProgressView = false

    var body: some View {
        Button(
            action: {
                if actionOptions.contains(.disableButton) {
                    isDisabled = true
                }
            
                Task {
                    var progressViewTask: Task<Void, Error>?

                    if actionOptions.contains(.showProgressView) {
                        progressViewTask = Task(priority: priority) {
                            try await Task.sleep(nanoseconds: 900_000_000)
                            showProgressView = true
                            print("task created")
                        }
                        
                    }

                    await action()
                    progressViewTask?.cancel()
                    print("task cancelled")
                    isDisabled = false
                    showProgressView = false
                }
            },
            label: {
                ZStack {
                    label().opacity(showProgressView ? 0 : 1)

                    if showProgressView {
                        ProgressView()
                    }
                }
            }
        )
        .disabled(isDisabled)
    }
}


extension AsyncButton {
    enum ActionOption: CaseIterable {
        case disableButton
        case showProgressView
    }
}

Solution

  • The Swift concurrency system can prioritize tasks. But, the priority behavior you are experiencing is distracting us from deeper issues. Once you fix those issues, the priority issue becomes largely irrelevant.

    So, a few considerations:

    1. The heavyTask is not asynchronous. It is marked async, but in the absence of an await somewhere in that method, it simply is not asynchronous. Adding an async qualifier, alone, does not change the underlying behavior.

    2. Another concern is that you never want to run “heavy tasks” on the current actor, as it can block that actor. This is the source of the behavior you are experiencing.

      To avoid this, you want to run your heavy task in a detached Task (or on its own actor), which will let the cancelation logic happen concurrently. E.g.:

      func heavyTask() async -> Int {
          await Task.detached { [count] in
              var k = count
              for _ in 0 ..< 50_000_000 {
                  k += 1
              }
              return k
          }.value
      }
      
    3. In a more advanced observation, you may also want to make it cancelable. You should try Task.checkCancellation() (or check for Task.isCancelled()).

      func heavyTask() async throws -> Int {
          let task = Task.detached { [count] in
              var k = count
              for _ in 0 ..< 50_000_000 {
                  try Task.checkCancellation()
                  k += 1
              }
              return k
          }
      
          return try await withTaskCancellationHandler {
              try await task.value
          } onCancel: {
              task.cancel()
          }
      }
      

      The Task.detached() gets it off the current actor, but because that opts out of structured concurrency, I wrap it in a withTaskCancellationHandler so that I handle cancelation. And I try Task.checkCancellation() so that it will actually stop if it is canceled.

    But in your case, the key observation is that you have to move the heavyTask off the current actor.