Cancel waiting for the value of a top-level Task

I know the following is due to only partially using structured concurrency, but it's a problem I've stumbled across several times over the years and I have not yet found a simple answer with the tools Swift concurrency provides.

How do you cancel the wait for another top-level task without having to cancel the other top-level task?

Let's take a look at the following simple example using a standalone top-level task doing some expensive work:

let sharedTask = Task.detached {
    // some long running expensive work
    try await Task.sleep(for: .seconds(10))
    return 42
}

The result of this task is to be used in various other asynchronous methods:

func addMagic() async throws -> Int {
    let magicNumber = try await sharedTask.value
    // do something with the number and potentially wait for other async methods before we return a result
    try await Task.sleep(for: .seconds(1))
    return magicNumber + 2
}

func multiplyMagic() async throws -> Int {
    let magicNumber = try await sharedTask.value
    // do something with the number and potentially wait for other async methods before we return a result
    try await Task.sleep(for: .seconds(2))
    return magicNumber * 2
}

These methods are otherwise used in a structured concurrency context and ultimately have a top-level task under which they are executed.

For the sake of simplicity, let's look at this as follows:

let task1 = Task {
    defer { print("Task1 done") }
    let result = try await addMagic()
    print("The new magic number is \(result)")
}

let task2 = Task {
    defer { print("Task2 done") }
    let result = try await multiplyMagic()
    print("The new magic number is \(result)")
}

The problem we get at this point is with cancellation behavior.

For example, if we try to cancel task2 with the cancel method of the task, the multiplyMagic method won't return until the sharedTask instance returns a value or is canceled.

// This should cause "Task2 done" to be printed immediately and not wait for the sharedTask to complete.
task2.cancel()

And since we are talking about a shared task whose ownership does not lie with the callers, the callers should not cancel the shared task just because they are canceled.

What would be desirable at this point is that when task2 is canceled, the wait for the value of sharedTask is canceled but not the sharedTask itself.

What I have done in these cases so far is to use methods from Combine and publish/subscriber patterns to achieve the appropriate result.

How do you achieve this behavior with the tools Swift Concurrency provides?
Or is the simple answer: this cannot be achieved with Swift Concurrency alone?

2 Likes

The only way you can do is to wrap it with unstructured Concurrency.

below is just a pseudo-code.


func discardableMagicOperation() async throws -> Int? {
val statefulLock = ...
try await withTaskCancellationHandler {
    try await withUnsafeThrowingContinuation { continuation in 
          // critical region store Contination if onCancel is not called
          if statefulLock.consumeIfNotCancelled(continuation) {
             // statefulLock has contination
             Task {
                    do {
                           let value = try await sharedTask.value
                           // critical region
                           statefulLock.resumeIfPossible(value)
                   } catch {
                          // critical region
                          statefulLock.resumeIfPossible(error)
                   }
               }
          } else {
              // tested lock and it is already cancelled, resume right now
              continuation.resume(returning: nil)
          }
    }
} onCancel {
    // critical region, mark state cancel and resume the continuation if presented
    statefulLock.markCancelAndResumeNil()
}

}

Swift Concurrency is cooperative model, so if one does not support cancelling there is no way to break the link in structured Concurrency

2 Likes

Maybe you meant structured? In OP there are a bunch of unstructured tasks, to handle cancellation properly shared task needs structured concurrency on top of it, which will handle cancellation properly and hold shared state via actor or locks (what’s your pseudo-code is actually doing)

You see it correct. unstructered.
OP, want to wait or return early on cancel. Which is awaiting for unstructered Task. However, in structured Task you can't not await or return early from unstructered Task, because Unstructered Task will never resume until it self is ready. Even though awaiter is cancelled.

So in this case, we need to spawn Unstructered Task which we can use as a delegate.

Best way is to make some sort of StateMachine, to react for OP's design.

maybe like Async Algorithms AsyncChannel, or callback based AsyncStream proposal?

1 Like

below is just a pseudo-code.

An interesting approach, but if I understand it correctly, it has one issue:
The 'inner' top-level task is still waiting for the sharedTask to return and therefore does not release its allocated resources until the sharedTask.value method signals completion.

This means that even if a request is canceled, resources that could actually be released immediately may still be used unnecessarily for quite some time.

Or am I missing something?

This may be acceptable depending on the use case, but it certainly is not an ideal solution.

maybe like Async Algorithms AsyncChannel, or callback based AsyncStream proposal?

It definitely feels like something fundamental is missing at the language level or in the core libraries.

As mentioned, this not uncommon problem can be solved relatively easily and straightforwardly with a publisher/subscriber pattern (e.g. via Combine).

With Swift concurrency means, on the other hand, the problem seems to be anything but trivially solvable.
In other words, it seems to be as I have long suspected: I have not overlooked anything essential in this regard.

1 Like

That’s incomplete solution, just an idea that needs to be finished. You clearly want to keep a reference to a task and cancel it internally if nobody waits for any anymore, as one of things that is required. But you pretty confidently make it generic and reusable for any shared task in the end.


Yeah, probably as part of stdlib that would be useful addition.

Swift Concurrency isCooperative. Which means it's the target who has the responsibility to react for cancellation.
Target, can decide whether it should react to Cancellation or ignore it until it reaches the designed check points.

there is a case to ignore cancellation.

  • client code can access UnsafeCurrentTask and call cancel but you want to ignore it.
  • bridging between Concurrency and other async programming, and the other interface is responsible for Cancellation

Anyway, current Problem is Task.value does not react to Cancellation of Caller.
I agree that, it could be pretty useful if it have variant that support consumer only Cancellation from stdlib, like AsyncChannel of Async Algorithms

Best way, for now is to implement it on your own. wrapping Top Level Task

///pseudo-code

final class SharedRef {
      private let task:Task<Void,Never>
      private let imp:SharedTaskImplementation
      init() {
         let imp = SharedTaskImplementation(...)
         let task = Task.detached{
               await imp.run()
         }
         self.imp = imp
         self.task = task
      }
      deinit {
            imp.tearDown()
            task.cancel()
      }

     func reactiveMagic() async -> Int? {
           return await imp.magic(reactive:true)
     }
}

That's true, but a general change to the Task.value behavior would probably not be a good idea.
Breaking the suspension point is one thing, but cancelling the top level task we're waiting for is another.

This imho means that a better solution would allow the suspension point to be canceled via a mechanism using its own syntax.

I totally agree! I was quite surprised not to find a corresponding solution even in the Async Algorithms package.

This is the (probably naive) solution I’ve been using to await the completion of a task, but also allow cancellation without cancelling the awaited Task.

extension Task where Failure : Error {

    /// Waits for the result of the task, or for the enclosing scope to be cancelled.
    ///
    /// The task is not cancelled when the enclosing scope is cancelled.
    func valueOrCancellation() async throws -> Success {
        let stream = AsyncThrowingStream<Success, Error> { continuation in
            Task<Void, Never>.detached {
                do {
                    let value = try await self.value
                    continuation.yield(value)
                    continuation.finish()
                } catch {
                    continuation.finish(throwing: error)
                }
            }
        }

        for try await value in stream {
            return value
        }

        try Task<Never, Never>.checkCancellation()
        preconditionFailure("Stream finished without emitting value or cancelling")
    }
}

It does mean that if the consuming scope is cancelled, there is a dangling detached task holding a reference to the stream that hangs around until the Task actually finishes. I don’t know how much of a problem this is however.