How to properly cancel Swift async/await function
Asked Answered
C

3

20

I have watched Explore structured concurrency in Swift video and other relevant videos / articles / books I was able to find (swift by Sundell, hacking with swift, Ray Renderlich), but all examples there are very trivial - async functions usually only have 1 async call in them. How should this work in real life code?

For example:

...
        task = Task {
            var longRunningWorker: LongRunningWorker? = nil

            do {
                var fileURL = state.fileURL
                if state.needsCompression {
                    longRunningWorker = LongRunningWorker(inputURL: fileURL)
                    fileURL = try await longRunningWorker!.doAsyncWork()
                }

                let urls = try await ApiService.i.fetchUploadUrls()

                if let image = state.image, let imageData = image.jpegData(compressionQuality: 0.8) {
                    guard let imageUrl = urls.signedImageUrl else {
                        fatalError("Cover art supplied but art upload URL is nil")
                    }

                    try await ApiService.i.uploadData(url: imageUrl, data: imageData)
                }

                let fileData = try Data(contentsOf: state.fileUrl)
                try await ApiService.i.uploadData(url: urls.signedFileUrl, data: fileData)

                try await ApiService.i.doAnotherAsyncNetworkCall()
            } catch {
                longRunningWorker?.deleteFilesIfNecessary()
                throw error
            }


        }
...

Then at some point I will call task.cancel().

Whose responsible for cancelling what? Examples I've seen so far would use try Task.checkCancellation(), but for this code that line should appear every few lines - is that how it should be done?

If API service uses URLSession the calls will be cancelled on iOS 15, but we don't use async variant of URLSession code so we have to cancel the calls manually. Also this applies to all the long running worker code.

I am also thinking that I could add this check within each of async functions, but then basically all async functions would have the same boilerplate code which again seems wrong and I haven't seen that done in any of the videos.

EDIT: I have removed callback calls as those are irrelevant to the question.

Commissariat answered 14/4, 2022 at 12:46 Comment(0)
G
18

There are two basic patterns for the implementation of our own cancelation logic:

  1. Use withTaskCancellationHandler(operation:onCancel:) to wrap your cancelable asynchronous process.

    This is useful when calling a cancelable legacy API and wrapping it in a Task. This way, canceling a task can proactively stop the asynchronous process in your legacy API, rather than waiting until you reach a manual isCancelled or checkCancellation call. This pattern works well with iOS 13/14 URLSession API, or any asynchronous API that offers a cancelation method.

  2. Periodically check isCancelled or try checkCancellation.

    This is useful in scenarios where you are performing some manual, computationally intensive process with a loop.

    Many discussions about handling cooperative cancelation tend to dwell on these methods, but when dealing with legacy cancelable API, the aforementioned withTaskCancellationHandler is generally the better solution.

So, I would personally focus on implementing cooperative cancelation in your methods that wrap some legacy asynchronous process. And generally the cancelation logic will percolate up, frequently not requiring additional checking further up in the call chain, often handled by whatever error handling logic you might already have.

Germain answered 14/4, 2022 at 17:14 Comment(2)
Thank you for your answer. Is the handler part of withTaskCancellationHandler only used for cleanup or can that part throw cancellation error too? I guess the main problem I am seeing now is that my parent VC is waiting on child VC's task to finish to dismiss the child vc. Also this modal needs to be dismissed when user taps cancel. If I use checkCancellation(), the modal only gets dismissed after current network call ends. Cancellation handler fires immediately, but wonder if there's a way for it to stop parent task execution immediately as well by throwing an error.Commissariat
I can’t say without an example. I’d suggest that you create a minimal, completely, and reproducible example of the problem and post that as a new question.Germain
C
4

I'd like to answer my own question with 10 extra months of experience with async/await.

If I were to write such function today, I wouldn't check for cancellation within this task block. I'd call this "manager" task and all the functions it relies on I would call "worker" tasks, as those do the actual long running work.

All of the "worker" functions should check for cancellation before doing work. If any of those functions notices that task is cancelled, then it should throw cancellation error essentially terminating parent task (unless caller uses try/catch block and requires fallback when error is thrown).

I would only explicitly check for cancellation in "manager" task only if completing the "worker" task post-cancellation could somehow corrupt the state.

Commissariat answered 2/3, 2023 at 13:40 Comment(5)
That is exactly what my answer said. "Note that if, within your task, you are calling (with try) any async material that throws when cancelled, you do not need to any cancellation work with regard to that material, because when it throws due to cancellation, you will throw due to cancellation automatically" Those who have ears to hear, let them hear.Shampoo
Correct, but given that question has more upvotes than the answer, I'd say that people don't find that answer very helpful and this is my take on it.Commissariat
Totally. I'm just pointing out that an attempt was made to point you in the right direction. You are completely free to add your own answer! It's encouraged. I'm a little torn, though, because of the principle that "Stack Overflow is not your blog". Since the question has been answered correctly, nothing is gained by your repeating the correct answer in the form of an autobiographical anecdote. Your tale of lessons learned is valuable, but I'm suggest that this is not the place for it. The question-and-answer cycle is complete.Shampoo
Also, if "given that question has more upvotes than the answer" is supposed to be an argument, it's wrong. Those upvotes mean either "well put" or "yes, I had the same question, thank you for asking it for me". It does not mean "I'm still confused about this" — because the answers on the page also answer the question. Counting the votes on the question vs. the votes on the answers tells you nothing.Shampoo
Final thought: in condensed form, this might make a more appropriate comment rather than a separate answer. Okay, I'm done giving you a hard time! :) Sorry about that.Shampoo
S
1

Examples I've seen so far would use try Task.checkCancellation(), but for this code that line should appear every few lines - is that how it should be done?

Basically yes. Cancellation is a totally voluntary venture. The runtime doesn't know what cancellation means for your particular task, so it just leaves it up to you. You look at Task.isCancelled, or, if your intention is to throw just in case the task is cancelled, you can call Task.checkCancellation.

Note that if, within your task, you are calling (with try) any async material that throws when cancelled, you do not need to any cancellation work with regard to that material, because when it throws due to cancellation, you will throw due to cancellation automatically.


Having said all that, I have to add, as a footnote, that your code is extremely strange. Callbacks and async/await are opposites; the idea that you would do a do/catch and call a callback within a Task is extremely weird and I would advise against it. You are basically negating all the advantages of a Task by doing that, as well as making untrue the thing I just said about the throw trickling up and out of your task.

Shampoo answered 14/4, 2022 at 12:59 Comment(5)
Thanks for the reply. Callbacks in that particular code are there to avoid refactoring whole codebase at once. I do agree that mixing callbacks and async/await isn't a textbook way of doing things. So in in this particular code, would you simply add "Task.isCancelled" checks after every await statement? That seems like a very ugly solution.Commissariat
"Callbacks in that particular code are there to avoid refactoring whole codebase at once" Understood, but that is not how you rejigger code to be compatible with both new async/await and old callback architecture. There is a right way to do that, involving wrapping in a continuation function, and you are not doing that.Shampoo
"So in in this particular code, would you simply add "Task.isCancelled" checks after every await statement?" That's the exact opposite of what I said. I suggested you make every await statement a try await, i.e. everything should throw, and have each async method that you call do the checkCancellation call.Shampoo
"There is a right way to do that, involving wrapping in a continuation function". I am aware of that. Not have figured a right way to do this in combination with ui code. This task is executed within child view controller and parent view controller needs to know the result. Looking into async properties now as maybe this might be an answer how to lose callbacks in this code.Commissariat
"have each async method that you call do the checkCancellation" Ok, so this was one of the approaches I thought about and possibly it is more elegant than having all the checks in just one function (and I suppose it's more of the co-operative approach promoted by async/await)Commissariat

© 2022 - 2025 — McMap. All rights reserved.