How to cancel an async func

Hi,

I have an async func f1, how can I cancel it?

My attempt

  • To call it inside a Task { }
  • Assign the task to a variable t1 and call t1.cancel()

Code

nonisolated func f1() async throws {
    try Task.checkCancellation()
    print("inside f1")
}

let t1 = Task {
    try? await f1()
}

t1.cancel()

Question

  • Is there a better way to cancel the async func or is this the proper way?

LGTM, just beware it only cancels when reaches the try Task.checkCancellation() opt-in.

2 Likes

Thanks @tera for confirming.

That is a good point regarding the cancellation checking.

Not sure if there is any example shown in the documentation of such a pattern (let t1 = Task { }; t1.cancel())

Wrapping an async operation into a task essentially turns it into a future, allowing you to pass it around before awaiting on its result, as well as cancel it at will from outside. This is a perfectly valid way of using Tasks (actually, this is the primary reason to even save a task after initializing it).

As for handling cancellation inside the task, you can do it reactively without having to rely on reaching a point where you can do a check: withTaskCancellationHandler(handler:operation:)

2 Likes

thanks a lot @technogen,

withTaskCancellationHandler(handler:operation:) looks very interesting, I will read more about it.

1 Like

The problem is not cancellation, per se, but timing: f1 passed the checkCancellation line (and possibly even finished) before you cancelled t1. f1 is an async function, but isn’t really doing anything asynchronous within it, so it finishes execution exceptionally quickly. There is a race between t1 being canceled and f1 checking for cancellation.

However, if your function does something that really is asynchronous (and does something that responds to cancellation), then you see the behavior you are looking for. For example, below, f2 takes 10 seconds, but t2 is cancelled after one second, so we can see cancellation in action:

nonisolated func f2() async throws {
    try await Task.sleep(for: .seconds(10))
    print("inside f2")
}

let t2 = Task {
    try await f2()
}

Task {
    try await Task.sleep(for: .seconds(1))
    t2.cancel()
    print("cancelled before `f2` got to `print` statement")
}

FWIW, the withTaskCancellationHandler solves a very different problem: Itā€˜s for propagating cancellation when using unstructured concurrency:

func foo(url: URL) async throws {
    let task = Task {…}
    
    return try await withTaskCancellationHandler { 
        try await task.value
    } onCancel: { 
        task.cancel()
    }
}

This way, if you cancel foo, it will cancel the unstructured concurrency within foo. If you don’t use withTaskCancellationHandler, the cancellation of foo would not cancel the unstructured concurrency within.

(As an aside, this is one of the reasons why we generally prefer structured concurrency, because cancellation is propagated for us automatically. But where we really need unstructured concurrency, we always use this withTaskCancellationHandler pattern, to remedy this lack of automatic cancellation propagation.)


As to your broader question, ā€œHow to cancel an async funcā€, there are a ton of different cancellation patterns:

  1. The cancelling of the unstructured task with asynchronous work, as outlined at my f2 example, above, is a fine way to do it (with the typical caveats about unstructured concurrency, notably that you have to handle cancellation propagation manually, that the called function supports cancellation, etc.).

  2. If you are using SwiftUI, it does some automatic cancellation for you. For example, this will start foo when the view is presented, but the .task {…} view modifier will cancel it for you when the view is dismissed:

    import SwiftUI
    
    struct ContentView: View {        
        var body: some View {
            HStack {…}
            .task {
                await foo()
            }
        }
        
        func foo() async {…}
    }
    

    There is no need to manually cancel foo in this case.

  3. If you use task groups, you can cancel the group when one of them fails. Or if you use a discarding task group, it will automatically do this for you:

    func foo() async throws {
        try await withThrowingDiscardingTaskGroup { group in
            group.addTask { try await f3() }
            group.addTask { try await f4() }
        }
    }
    

    In this case, if either f3 or f4 throws an error, the other will be cancelled (if it hasn’t finished already).

  4. If you are using async let, if you let the task fall out of scope without awaiting it, it will be cancelled. For example, this example is adapted from the one in SE-0317:

    func go() async { 
        async let f = fast() // 300ms
        async let s = slow() // 3seconds
        return "nevermind..."
        // implicitly: cancels f
        // implicitly: cancels s
        // implicitly: await f (giving it a chance to respond to the cancellation)
        // implicitly: await s (giving it a chance to respond to the cancellation)
    }
    

So, if you really are just trying to cancel some unstructured concurrency, then option 1 is sufficient. But for the broader question of how to cancel an async function, as you can see, there are a plethora of alternatives. It boils down to the particular problem that you are trying to solve.


Implicit in all of these examples is that the function you are calling must respond to cancellation. Fortunately, most of the async functions we call (e.g., Task.sleep, URLSession, etc.) already do this for us. As a result, we don’t tend to use Task.checkCancellation (which checks for cancellation, and if not cancelled, just proceeds to the next line) very often: Its use is largely limited to our own custom compute tasks where we periodically would check for cancellation inside a loop, or what have you. But that’s a bit of an edge case.

8 Likes

Thanks @robert.ryan for the detailed explanation.