Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check cancellation for task #3557

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open

Conversation

junjielu
Copy link

I think it is necessary to check if a task will be cancelled in a task cancellation scenario. The current code needs to actively check for cancellation at the top level, which I don't think is a reasonable approach.

Before:

let content = try await withTaskCancellation(id: CancelID.task, cancelInFlight: true) {
  let result = try await doSomeThing()
  try Task.checkCancellation()
  return result
}
await doSomeThingElse(content)

After:

let content = try await withTaskCancellation(id: CancelID.task, cancelInFlight: true) {
  try await doSomeThing()
}
await doSomeThingElse(content)

@mbrandonw
Copy link
Member

Hi @junjielu, can you provide a bit more context about this change? What problem did you notice that this fixes? And can you write a test that fails without this change and passes with?

As far as I can tell they seem to be about equivalent as long as the work performed in try await operation() participates in cooperative cancellation.

@junjielu
Copy link
Author

junjielu commented Jan 17, 2025

@mbrandonw Yes, I think I should have gone into more detail.
Regarding withTaskCancellation method, I think it makes sense that the method itself would actively throw an exception when the task is cancelled. The following scenario would be confusing for the user:

let taskA = try await withTaskCancellation(id: CancelID.A, cancelInFlight: true) {
  let result = try await doSomeThing() // Method won't check cancellation.
  print("Task A finished")
}

let taskB = try await withTaskCancellation(id: CancelID.B, cancelInFlight: true) {
  try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) // `sleep` will check for cancellations internally.
  print("Task B finished")
}

let taskC = try await withTaskCancellation(id: CancelID.C, cancelInFlight: true) {
  try await doSomeThing() // Method won't check cancellation.
  print("doSomeThing finished")
  let result = try await doSomeThingElse() // Method will check cancellation.
  print("doSomeThingElse finished")
  return result
}
...

Task.cancel(id: CancelID.A) // "Task A finished" still printed
Task.cancel(id: CancelID.B) // "Task B finished" will not print
Task.cancel(id: CancelID.C) // "doSomeThing finished" will print, but "doSomeThingElse finished" won't

In this example you can see the significant difference, some async methods check for cancellation internally and some don't. So putting the matter of checking for cancellations inside the withTaskCancellation method reduces user misunderstandings.

After:

let taskA = try await withTaskCancellation(id: CancelID.A, cancelInFlight: true) {
  let result = try await doSomeThing() // Method won't check cancellation.
  print("Task A finished")
}

let taskB = try await withTaskCancellation(id: CancelID.B, cancelInFlight: true) {
  try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) // `sleep` will check for cancellations internally.
  print("Task B finished")
}

let taskC = try await withTaskCancellation(id: CancelID.C, cancelInFlight: true) {
  try await doSomeThing() // Method won't check cancellation.
  print("doSomeThing finished")
  let result = try await doSomeThingElse() // Method will check cancellation.
  print("doSomeThingElse finished")
  return result
}
...

Task.cancel(id: CancelID.A) // "Task A finished" won't printed
Task.cancel(id: CancelID.B) // "Task B finished" won't print
Task.cancel(id: CancelID.C) // Nothing will print

@mbrandonw
Copy link
Member

Hi @junjielu, I still do not think there is anything to fix here. As long as the async work you are invoking inside withTaskCancellation respects cooperative cancellation, everything should already work just fine. In your examples you seem to be theorizing calling functions that do not respect cooperative cancellation in order to show bad behavior.

However, even Swift's own async tools do not behave the way you want them to. They behave like our withTaskCancellation does.

For example, consider a non-cooperative sleep function:

func nonCooperativeSleep(duration: Duration) async throws {
  await Task { try? await Task.sleep(for: duration) }.value
}

This behaves like Task.sleep, except it does not cancel itself when the surrounding async context is cancelled.

If you then use this non-cooperative sleep in a Task and cancel that task like so:

let task = Task<Int, any Error> {
  try await nonCooperativeSleep(duration: .seconds(1))  // Does not cancel
  return 42
}
try? await Task.sleep(for: .seconds(0.5))  // Allow time to start the task
task.cancel()
let value = try await task.value
print(value)  // 42

…you will find that the value is returned from the task just fine and printed to the console.

If we were to take the change that you are proposing, then it would be the case that we need to litter try Task.checkCancellation() between essentially every line of every async context throughout our entire code base. And that is not how cooperative cancellation is meant to be dealt with in Swift. Instead, your async methods should take on the responsibility of checking for cancellation (such as the doSomeThing in your code above), rather than forcing that responsibility on library code that provides async tools.

So, I personally believe that things are correct as they are right now. If there is something I am not understanding though please do let me know.

@junjielu
Copy link
Author

Hi @mbrandonw , I can see what you mean, I think where we're not agreeing right now is whether checkCancellation should happen inside the withTaskCancellation function or whether we should say let the external code use it manually.

I would think it depends on the expectations of the caller. For example with the function Task.sleep, the vast majority of coders would expect it to throw an error when the task is cancelled and not need to check for cancellation after await. For example, in Swift, when we use withTaskCancellationHandler function, the onCancel is executed immediately when the task is cancelled.

Then, when using withTaskCancellation I think the coder will also assume that it throws an error when the task is cancelled and does not need to check for cancellation multiple times when using it because not all async functions need to check for cancellation and that would make the code less readable.

try await withTaskCancellation(id: CancelID.Task, cancelInFlight: true) {
  let valueA = try await nonCooperativeSleep(duration: .seconds(1))
  try Task.checkCancellation()
  let valueB = try await nonCooperativeSleep(duration: .seconds(2))
  try Task.checkCancellation()
  let valueC = try await nonCooperativeSleep(duration: .seconds(3))
  try Task.checkCancellation()
  return valueA + valueB + valueC // The above three check cancellations I think would make boilerplate code
}

So it would be more in line with the coder's expectations if the cancellation error was thrown at the point of use, rather than checking for cancellation after every function that doesn't support task cancellation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants