Async Await Cancellation in Swift

In this post, we are going to explore cancellation when working swift async await, starting from basic examples, which are sufficient most of the time, to more complicated cases when we want to perform more complex operations.

For the simple case, we are going to have a button that will present a screen. This screen will perform some asynchronous work, and then we will observe what happens with the task when the view is dismissed.

The asynchronous work will be a timer. We will use the extension values from Combine, which convert a publisher into an async sequence. This sequence will return a value every second indefinitely until the task is canceled.

Swift
let timer = Timer.TimerPublisher(
    interval: 1,
    tolerance: 1,
    runLoop: .main,
    mode: .common
)
.autoconnect()
.values

I haven’t covered the async sequence yet, but that will be a topic for another article. For now, let’s think of it as a regular Swift sequence, which we can iterate, awaiting the next element. We will iterate over the sequence, which returns a value every second, and then update a @State variable displayed on the UI. We’ll also print the value so that we can observe when the task stops producing values.

Swift
for await _ in timer {
    timerValue += 1
    print(timerValue)
}

.task modifier

The .task modifier allows us to perform some asynchronous work when the view appears. Importantly, it also handles cancellation for us. When the view disappears, any task started from the .task modifier will be canceled. Furthermore, as structured concurrency operates hierarchically, it will also cancel any child task created from the top-level task.

Swift
struct ContentView: View {
    @State var showTimerView: Bool = false
    
    var body: some View {
        VStack {
            Button(action: {
                showTimerView.toggle()
            }, label: {
                Text("Show timer view")
            })
        }
        .sheet(isPresented: $showTimerView, content: {
            TimerView()
        })
    }
}

struct TimerView: View {
    @State var timerValue: Int = 0
    
    var body: some View {
        VStack {
            Text(String(timerValue))
        }
        .task {
            let timer = Timer.TimerPublisher(
                interval: 1,
                tolerance: 1,
                runLoop: .main,
                mode: .common
            )
            .autoconnect()
            .values
            
            for await _ in timer {
                timerValue += 1
                print(timerValue)
            }
        }
    }
}

TimerView is presented when tapping a button. The timer starts automatically updating the UI, and then we dismiss the screen. Subsequently, we can check the logs to see if the task is still publishing values or not. Let’s take a look:

As we can see, when the sheet is presented, the UI is updated with the timer values, and the log prints the corresponding values. Once we dismiss the screen, the log stops because the task is canceled.

Creating a Task

Now, let’s say that we want to start the timer after tapping a button. In this case, we cannot use the .task modifier, and we need to create the asynchronous context ourselves using a Task type.

Swift
struct TimerView: View {
    @State var timerValue: Int = 0
    
    var body: some View {
        VStack(spacing: 16) {
            Text(String(timerValue))
            
            Button(action: {
                Task {
                    let timer = Timer.TimerPublisher(
                        interval: 1,
                        tolerance: 1,
                        runLoop: .main,
                        mode: .common
                    )
                    .autoconnect()
                    .values
                    
                    for await _ in timer {
                        timerValue += 1
                        print(timerValue)
                    }
                }
            }, label: {
                Text("Start timer")
            })
        }
    }
}

Let’s try the following code:

As we can see in the logs, after dismissing the screen, the timer is still publishing values.

To stop the task, we need to keep a reference to the task in the view, and by using the onDisappear modifier, we can cancel the task when the view is dismissed.

Swift
struct TimerView: View {
    @State var timerValue: Int = 0
    @State var task: Task<Void, Error>?
    
    var body: some View {
        VStack(spacing: 16) {
            Text(String(timerValue))
            
            Button(action: {
                task = Task {
                    let timer = Timer.TimerPublisher(
                        interval: 1,
                        tolerance: 1,
                        runLoop: .main,
                        mode: .common
                    )
                    .autoconnect()
                    .values
                    
                    for await _ in timer {
                        timerValue += 1
                        print(timerValue)
                    }
                }
            }, label: {
                Text("Start timer")
            })
        }
        .onDisappear {
            task?.cancel()
        }
    }
}

Let’s see what happen now:

As we can see, the logs now stop when the view is dismissed, which means the task was canceled.

Advanced Cancellation

In the previous example, everything looked fine. Whether we handle the cancel ourselves or use the .task modifier, the task stops running. Now, let’s examine a more complex scenario.

We are going to have a screen that will ask for a random number using a free API from randomnumberapi.com. We will use an async function to perform the request.

After that, let’s say the API returns the number 100. We are then going to calculate the 100th prime number. I want our example to involve some heavy computation, so we will calculate this prime number manually inside the asynchronous context.

Now, let’s take a look at the function to request a random prime number. Since we want to have enough time to explore what happens when we cancel the task, we are adding a delay of 3 seconds so we can cancel the task before the request is finished. Also, some printing messages to see in the console.

Swift
func randomNumber() async throws -> Int {
    print("Requesting a random number")
    let url = URL(string: "http://www.randomnumberapi.com/api/v1.0/random?min=50000&count=1")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let model = try JSONDecoder().decode([Int].self, from: data)
    
    guard let number = model.first else {
        throw NSError(domain: "invalid response", code: -1)
    }
    
    try await Task.sleep(nanoseconds: 3_000_000_000)
    print("Random number is \(number)")
    return number
}

We want the random number to be at least 50000 so that the operation for calculating the prime number is a heavy one.

Calculate Prime number

The code for calculating it is pretty simple. We will calculate the Xth prime number by checking one by one. This operation for a value like 50000 can take a few seconds, making it perfect for our case.

Swift
func nthPrime(_ n: Int) async -> Int {
    var count = 0
    var number = 2

    while true {
        print("Checking if \(number) is the \(n) prime")
        if isPrime(number) {
            count += 1
            if count == n {
                return number
            }
        }
        number += 1
    }
}


func isPrime(_ number: Int) -> Bool {
    if number <= 1 {
        return false
    }
    
    if number == 2 || number == 3 {
        return true
    }

    for i in 2...Int(sqrt(Double(number))) {
        if number % i == 0 {
            return false
        }
    }

    return true
}

Example

Now, let’s examine what our screen does. First, we tap a button to initiate the network request for the random prime number. After that, we calculate the prime number based on the random number. All this code is within an asynchronous context, so we keep a reference to the task. When the view disappears, we cancel the task.

Additionally, we have different ProgressView and Text elements for displaying the random and prime numbers.

Swift
struct CalculatePrimeNumber: View {
    @State var numberText: String = ""
    @State var primeNumber: String = ""
    @State var task: Task<Void, Error>?
    @State var isRequestingPrimeNumber: Bool = false
    @State var isCalculatingPrimeNumber: Bool = false
    
    var body: some View {
        VStack(spacing: 16) {
            Button(action: {
                task = Task {
                    do {
                        isRequestingPrimeNumber = true
                        let rndNumber = try await randomNumber()
                        numberText = "Calculating prime number \(rndNumber)"
                        isRequestingPrimeNumber = false
                        
                        isCalculatingPrimeNumber = true
                        let value = await nthPrime(rndNumber)
                        primeNumber = "The \(rndNumber) prime is \(value)"
                        isCalculatingPrimeNumber = false
                    } catch {
                        // Handle error
                    }
                }
            }, label: {
                Text("Request Random Prime number")
            })
            
            if isRequestingPrimeNumber {
                ProgressView()
            } else {
                Text(numberText)
            }
            
            if isCalculatingPrimeNumber {
                ProgressView()
            } else {
                Text(primeNumber)
            }
            
        }
        .onDisappear {
            task?.cancel()
        }
    }
}

If we run our project, we can observe every iteration printed in the console, taking about 16 seconds to calculate the prime number.

Now, if we dismiss the screen before the network request finishes, we can observe that there are no logs in the console about calculating the prime number. This is because the task is canceled when the view disappears.

And now let’s see what happens if we cancel the task while we are already calculating the prime number:

We can observe that even if we dismiss the screen and the task is canceled, the console logs never stop after the prime number is calculated. So, what’s going on then?

When does a task check if it is canceled?

A task will check if it is canceled at every suspension point. This means in every instruction where we use await, a task checks if it is canceled and, if so, will stop. However, in our previous example, when we are calculating the prime number, we don’t have any await instructions. So, if the task is canceled when the prime number calculation has already started, then the task is not canceled at all.

Luckily for us, we have ways to check if a task is canceled. For example, if we want to check on every iteration whether the task is canceled and, if so, throw an error, we can use Task.checkCancellation.

Swift
func nthPrime(_ n: Int) async throws -> Int {
    var count = 0
    var number = 2

    while true {
        print("Checking if \(number) is the \(n) prime")
        if isPrime(number) {
            count += 1
            if count == n {
                return number
            }
        }
        
        try Task.checkCancellation()
        
        number += 1
    }
}

We can also use Task.isCancelled, which will return a boolean value but will not throw any error. This can be useful if we want to perform some cleanup before canceling the task. Additionally, we have the option of throwing a cancellation error to finish the task using CancellationError.

Swift
if Task.isCancelled {
      throw CancellationError()
}

Now, let’s see what happens when we dismiss the screen in the process of calculating the prime number:

As we can see, when we dismiss the screen, the log stopped because now we are manually checking on every iteration if the task was canceled.

Free Resources on Heavy Computation

One last tip: our code for calculating the prime number is quite heavy, and we don’t have any suspension points. It’s a good idea to free up resources every few iterations so the thread can be used for more priority operations. We can use Task.yield() for this, which creates a suspension point. In this code, we yield every 100 iterations so the thread can be used for more priority operations and then continue.

Swift
func nthPrime(_ n: Int) async throws -> Int {
    var count = 0
    var number = 2

    while true {
        print("Checking if \(number) is the \(n) prime")
        if isPrime(number) {
            count += 1
            if count == n {
                return number
            }
        }
        
        if Task.isCancelled {
            throw CancellationError()
        }
        
        if count % 100 == 0 {
            print("task will yield to free resources")
            await Task.yield()
        }
        
        number += 1
    }
}

Conclusion

We explored the simple case for canceling tasks, which is sufficient most of the time, and some more complex tasks. It’s a good idea to understand how cancellation works so we can ensure we don’t have tasks running when they shouldn’t be. There is still way more to cover in the async/await world, so in the next article we explore async sequences.