[Pitch] Task Cancellation Shields

Thinking about it just a little, I would find both of these at lease a little bit odd:

  • If a task can't observe its own cancellation, it would seem fairly surprising to me that it still caused child tasks to get cancelled (and creates a sort of 'backdoor' for observing the parent task's cancellation in the first place).
  • If one doesn't want cancellation propagation, why are they using a child task to begin with instead of an unstructured task?

While I agree that it might be kind of strange to introduce the concept of a 'shield' I think it does communicate the right metaphor—it's an effect that happens 'at the boundary' of the scope (by 'blocking' cancellation signals coming from the outside) but does not really drastically affect the semantics of what happens inside the scope (since child tasks can still be cancelled).

Like @FranzBusch my mind drifts to "barrier" rather than "shield" as a perhaps slightly more idiomatic term for this sort of effect in computing, but I'm similarly fairly ambivalent about it

1 Like

Perhaps I’m mis-parsing who’s replying to who here? [edit: seems I understood now who’s replying to who adjusting the reply]

I do think these go hand-in hand. I don’t think you want this fine grained control, you usually just want to ensure “some code will actually run”. If you don’t want this property in “current task” just don’t include such piece of code in the with {}.

The child tasks taking into account shielding of the parent is necessary because child tasks are part of the “shielded work” structurally. If i’m creating child tasks while in a shield, I want them to be able to perform the work – otherwise I could be creating child tasks to perform a part of my cleanup, yet it would never run – including in code that may not even know it needed to shield itself, because it was part of some cleanup (calling it).

To be clear, the proposed semantics of a shielded parent task mean that a cancelled-but-shielded task creates non-cancelled child tasks. This is necessary because the sub-tasks would be performing parts of a cleanup etc, so they must not inherit the cancellation that was shielded to begin with.

Task {
  withUnsafeCurrentTask { $0?.cancel() } // immediately cancel the Task
  
  // with shields:
  await withTaskCancellationShield { 
    async let a = compute() // 🟢 async let child task is NOT cancelled immediately
    await withDiscardingTaskGroup { group in // 🟢 not cancelled
      group.addTask { compute() } // 🟢 not cancelled
      group.addTaskUnlessCancelled { compute() } // 🟢 not cancelled
    }
  }
}

Child tasks spawned in a cancelled but shielded task are not cancelled from the get-go. Otherwise they would not be able to perform potential sub-tasks of a cleanup.

2 Likes

Ah, but the model presented above is that there is a difference between being canceled and the effects of being canceled—which, it would seem, leaves room for exactly these sorts of distinctions [edit: I see @ktoso has given some examples that speak to essentially that]. I am not sure that the mental model for the pitched API is necessarily clearer on those grounds, at least at first blush.

I think the fundamental design being pitched is fairly clear: that since there is a bit for being canceled that propagates to child tasks, now we want another bit for if-canceled-we'll-still-pretend-it's-not that (as pitched) also propagates to child tasks, as well as additional APIs to allow us to know if we're currently canceled-even-if-we're-pretending-otherwise.

Conceivably, an opposite approach would be to have some sort of API (propagating perhaps) for super-duper-canceled-for-realsies and a way of designating some code locally as "resistant" to ordinary cancellation and only susceptible to super-duper-cancellation-for-realsies. Presumably, such a design would lend itself to some but not all use cases contemplated in this pitch, but it'd be interesting to better understand the strengths and weaknesses of such alternatives in the design space before we go down the rabbit hole of workshopping names for the current design.

2 Likes

I’d prefer to not have n-layers of super-cancellation, and then super-super-cancellation. No-one is asking for this and it can only go infinitely complicated from there onwards (“turtles all the way down”).

We need to keep this simple, and the fact of even observing a shield is already skirting very close to what I’m comfortable to exposing – but that one I believe is necessary for debugging purposes. This can easily escalate into the CSS hell of what is “more important” and then !important workarounds when people give up but then need to shield from that anyway etc :sweat_smile:

The specific use case is clear: making sure a piece of code, can execute and perform e.g. cleanups, including its child tasks. Otherwise we have problems with resource leaks or similar.

Naming wise either the shields of “suppressing cancellation” are right IMHO. “Suppressing” is different than “ignoring” in its meaning, because it means the thing has happened and has taken an effect, we’re just suppressing it from manifesting (observing the isCancelled).

3 Likes

Perhaps I’m misunderstanding you or the proposal (or did not totally grok the preceding discussion), but I thought that it was quite specifically not being proposed that the “shielded” bit would propagate to child tasks:

However if a child task were to be cancelled explicitly the shield of the parent, has no effect on the child itself becoming cancelled

IOW, the cancellation shield blocks cancellation of the parent from propagating to children, but does not cause children to have the “cancellation shielded” behavior themselves. Is this a misreading?

You're right--sorry, it is difficult to describe the model in a non-misleading way!

My mental model of it, at least, is that the child setting the cancellation bit also clears the propagated shield bit. It's not clear to me how else to think about the fact that cancellation-under-a-shield is observable (IIUC, even from a child task) without invoking propagation.

1 Like

Again, unless I’m misreading you or misreading the proposal, I don’t think this is correct!

At the same time, the child tasks which are running within the task cancellation shield will not become canceled automatically, as would be otherwise the case:

IOW—it is not that “cancellation-shielded” propagates to child tasks, it is that “cancellation-shielded” blocks actual cancellation propagation to children. Within a canceled-but-shielded scope, it is not the case that child tasks are also canceled-but-shielded; they are actually, really, not-yet-cancelled and (unless we introduce a new shielded scope within that task), we observe the real, actual cancellation state of that child task from within when invoking Task.isCancelled.

Interesting: then I must admit I do not understand the rationale for this. Why do we want to introduce this subtle distinction between "actually canceled" and "the effect of being canceled," then additionally have the current task behave differently from child tasks in this respect?

1 Like

My mental model of the feature here is that withTaskCancellationShield does something ‘at the boundary’ of the scope, rather than doing something ‘within’ the scope. Namely, the leading and trailing boundaries act as an (outward-facing) shield protecting the contained scope from the effects of cancellation which would otherwise propagate inward. One such effect is the change in value of isCancelled. Another such effect is the cancellation of child tasks.

That this API effect itself does not propagate to children (such that their cancellation, if triggered explicitly, is immediately observable) is explainable by the fact that it happens entirely within the boundaries defined for the shield scope so that there is no occasion for the shield to ‘block’ anything (and I think the metaphor is apt here—a shield is not typically intended to interact at all with things entirely within the shielded area). That this API does not prevent explicit cancellation from propagating ‘outward’ can also be understood metaphorically in that this API is ‘directed’ like a shield: it protects the interior from some effects (those of cancellation) which would otherwise be caused some action in the exterior (cancellation), but makes no effort to ‘protect’ the exterior from such actions taken in the interior.

11 Likes

Adding to the questions about the "composability" of these APIs... are cancellation shields *the* fundamental tool developers should use when creating "work that must not be cancelled" from now on?

For example, with the current proposal, if I want to create an unstructured task that "can't be cancelled", I would be compelled to write this:

let myUncancellableTask = Task {
    withTaskCancellationShield { 
        doThing()
    }
}

Maybe even write a convenience wrapper around these two APIs. Would this be an encouraged solution? That kind of task strikes me as a really confusing thing to "hold": it can be cancelled, and I'm told the mental model must be that the task is technically cancelled, yet that has no impact inside the task whatsoever, and only external observers see it as cancelled (which can only lead to bugs).

I wonder if a more fundamental building block could be some sort of truly uncancellable task:

let myUncancellableTask = UncancellableTask {
    doThing()
}

Something like that doesn't immediately solve the problem of cleanup handlers. But could other features already in the language do the rest? For example, using task groups to remain within structured concurrency seems to compose well:

await withTaskGroup { group in
    group.addUncancellableTask { 
        cleanup() 
    }
}

It would be self-evident that the task cancellation is not propagated to cleanup(), I think. As for the scheduling overhead, there's Task.immediate:

UncancellableTask.immediate {
    cleanup()
}

So a lot of the building blocks are already there. I realize there's a gap in functionality as to how such a thing would work for synchronous wrappers[1], but maybe exploring that would uncover other building-blocks the language would benefit from having.

The obvious benefit of such a model is that Task.isCancelled truly tells you whether the task is cancelled or not, so there's no longer a need for isReallyCancelled-like methods.


  1. Maybe it would be possible for UncancellableTask.immediate to have an overload that takes a synchronous closure, essentially executing code inside an uncancellable task with no suspension points. ↩︎

I think it helps to approach this by looking at the intended use case as the actual semantics fall out fairly naturally from that in my opinion.

We have some code that needs to complete even if the current task is cancelled, the most frequent example of which is cleanup code.

In simple cases we might know for sure that the cleanup code doesn't react to cancellation but often we might call code that:

  • is also used in non-cleanup circumstances and we don't want to duplicate it to have cancelling and non-cancelling variants
  • calls third party code that reacts to cancellation
  • calls third party code that doesn't document if it reacts to cancellation

So primarily we want to ensure that the cleanup code is shielded from cancellation, that is, when it checks if the current task is cancelled, it will always see false and continue as if the current task was not cancelled.

The cleanup code might internally use child tasks, so the shield also needs to prevent cancellation propagation to such child tasks from the current task. Otherwise child tasks would be immediately cancelled, likely defeating our original goal.

Finally, the cleanup code might also internally cancel child tasks as part of its regular operations. We don't want to interfere with that so the child tasks shouldn't themselves inherit the shielded status and be cancellable as usual. For example, imagine multiple child tasks racing to completion with the first to finish cancelling all the other ones.

8 Likes

For example, with the current proposal, if I want to create an unstructured task that "can't be cancelled", I would be compelled to write this:

let myUncancellableTask = Task {
    withTaskCancellationShield { 
        doThing()
    }
}

I would argue that this is something that you would only need to do very infrequently, and so having to nest the cancellation shield directly in the Task is fine. A cancellation shield applies to a scope and while that scope might be an entire unstructured Task, I don't expect that would happen often in practice.

Something like that doesn't immediately solve the problem of cleanup handlers. But could other features already in the language do the rest? For example, using task groups to remain within structured concurrency seems to compose well:

await withTaskGroup { group in
    group.addUncancellableTask { 
        cleanup() 
    }
}

Firstly, this is now a lot more code and an extra level of indentation that is needed when compared with withTaskCancellationShield.

Secondly, this is not equivalent in a couple of ways. As you mention, the scheduling issue can be solved using an immediate task. But you still pay the cost of creating a child task so it has some additional overhead. Also, this would make it impossible to cancel the current task from the shielded region. Admittedly this is not a very frequent scenario but with withTaskCancellationShield you can cancel the current task from the shielded region, which would be suppressed while shielded, and then become visible when exiting the shield. That no longer works if we are in a child task.


More generally, I think it makes a lot of sense to separate Task creation options that need to be applied at creation time (like immediate) from scoped "effects" (like task locals, cancellation handlers, cancellation shields).
I wouldn't want to create a new (child) task to install a cancellation handler or TaskLocal either.

4 Likes

Well, sure… you can write this, it’s because this API composes with all other existing ones; Is it pretty weird and you likely shouldn’t? But yeah, you can.

Again, the goal never is to make it absolutely impossible to write weird things. You can abuse a lot of APIs. In the same way, you can write while true { await Task.yield() } which is a horrible idea, but we’re not going to stop you from doing that.

No, the fundamental building block cannot be limited to creating tasks. This specifically breaks ordering, and is inefficient. For something to be fundamental you need to be able to construct other things on top of it – as is the case with the proposed APIs, and would not be with a new task initializer.

In addition to that, you would not have the ability to create such section in a defer without creating new tasks, and incuring scheduling (unless again specifically using Task.immediate…). This really would complicate the model, rather than simplify it.

I’m also opposed to this idea on sheer API surface and discovery grounds; It would mean around ~6 task initializers, ~8 task group child apis, and no way to spell this in an async let child task at all (!).

I’m happy to add this to the alternatives considered section though, with this argumentation why it’s not the right way here.

5 Likes

I think this is an excellent summary of how I think about this API as well. It uses the closure based scoping to convey the boundaries of the shield/barrier.

Similar, this is exactly the use-case that I want to use this API for and exactly why the proposed semantics must be this way. If we were to split the semantics into multiple building blocks then the very next thing developers need to do is re-compose them into a single API since they need all three behaviors at the same time to truly shield a scope for cancellation.
Now it might be worth it to separate it if we believe that the individual behaviors are meaningful on their own but I fail to see how they are.

2 Likes

I'd argue that's a good thing. Being able to cancel the current task from within the shielded region —yet that cancellation being unobservable from within the scope were you cancelled it— is one of the most confusing semantics in this API, and it doesn't seem to be needed for the motivating case of ensuring teardown/cleanup code isn't cancelled.


It troubles me a bit that you'd compare this with something cartoonishly bad like while true { await Task.yield() }, because I sincerely expect a decent amount of people will reach for this exact pattern if this is the only API the language offers to ensure a block of code isn't affected by task cancellation. So I'd hope it's not that awful to write it.

I think it's fair to ask what other things people may do with this new API, other than the specific use case being targeted. I'm surprised by how controversial it's been to say some people may put this inside a Task to make it uncancellable.

I fail to see how a variant of Task that can't be cancelled is something you can't build on top of, more so given that you seem to agree that it'd be possible to achieve the desired behavior by combining it with the existing Task.immediate.

I won't push on it, though. My intention wasn't to propose a specific API, but rather to try and spark some discussion around an alternative that didn't muddle the waters as to what Task.isCancelled means, and uncancellable tasks seemed like a reasonable avenue (again, inspired on Kotlin's withScope(.nonCancellable), which sidesteps this problem).

Okay, but isn't this exact argument, word for word, true for Task.immediate, which was recently added to the language?

2 Likes

mostly out of ignorance on my part – just how untenable is the 'do nothing' approach to this issue? since people are already functionally able to get much (most?) of this behavior today via unstructured Tasks, does that leave performance (and maybe ergonomics/convenience/aesthetics) as the primary motivator? personally i still don't feel i have a good sense of how pressing the need is after reading the proposal. can anyone speak to that a bit more? the language will take on more complexity to support this right – how does that stack up against the status quo?


one thing i think was mentioned earlier, which i will repeat here – if cancellation 'shields' are introduced, then the semantics of all existing Task.*cancel* methods will be implicitly & subtly altered. Task.isCancelled, for example, will no longer necessarily 'do what it says on the tin', which seems rather unfortunate. additionally, i'm not sure that's the sort of thing that lends itself to being 'progressively disclosed' particularly well – how will a developer transition from thinking isCancelled means 'is cancelled' to thinking it means 'is probably cancelled, unless some other (maybe unusual?) condition is true' without a period of confusion/annoyance where their original mental model is broken? perhaps put another way – how will the documentation of Task.isCancelled et al. be changed to explain the interactions with these capabilities? as an aside: i also think such documentation changes should be bundled with shipping a feature like this.


it seems the 'Trio' python library has been a source of various inspiration for Swift's concurrency design. i've seen it mentioned a number of times now, and suggested that it contains things Swift may wish to adopt in the future (this being one of them). my naive understanding of its API is that there are cancellation 'scopes' and these scopes can be initialized with certain values that can be customized (e.g. whether or not there is a 'shield' active).

the pitch here seems pretty focused on solving this one issue, but is there a broader vision to (eventually?) support some of the other affordances which that library offers? i think timeouts have already been raised, and not too long ago there was this post from Joe regarding cancellation scopes/tokens: [Pitch] Structured Task Cancellation Tokens - #9 by Joe_Groff.


on the matter of naming... i think Freddy made a good summary case for the current choice, and i think it would be a reasonable pick. personally i think 'barrier' or something with the word 'scope' might have some potential, but it might not be that great an improvement.

click for pedantry one thing that does irk me a bit with the name is that it has a slightly positive valence, since shields 'protect' things (that's good!). but by implication this means the things from which they offer protection should have a somewhat negative valence (otherwise why would you need the protection?). but... cancellation is pretty neutral – it's just a thing that might happen, so it seems a little odd to suggest code should need to be 'defended' from it.
3 Likes

My opinion is that it basically ought not to change at all. Code that is 'unaware' of the cancellation shield (e.g. library code being invoked down the call tree) should never need to differentiate between isCancelled and isCancelled(ignoringCancellationShield: true) for the purposes of actual functionality. In some sense the code up the call stack 'owns' the execution environment of its callees, and so I think it makes sense to give the caller 'final say' over what execution environment its callees can observe.

To put it another way, the caller already had the ability to wrap its callee in an (unstructured) task in order to 'hide' the 'real' cancellation state—the choice to use a cancellation shield is specifically a choice to say "I want this called code to behave exactly as if it were not cancelled". Downstream code should not need to reason about whether it's 'really' cancelled or not.

5 Likes

Yeah the last thing we want is people actually “just in case” littering their code with the “isCancelled(ignoring…)" version of the API everywhere. This is a special trick for special times.

We should and will absolutely mention and link to the new APIs because they’re related. But the behavior “expectations” of isCancelled should really remain the same.

I'd argue that's a good thing. Being able to cancel the current task from within the shielded region —yet that cancellation being unobservable from within the scope were you cancelled it— is one of the most confusing semantics in this API, and it doesn't seem to be needed for the motivating case of ensuring teardown/cleanup code isn't cancelled.

While I agree that it can be confusing on paper, I don't think it's really an issue in practice.

Regular code just checks for cancellation, gets a binary yes/no result and reacts accordingly. It generally does not (and should not) care how the current status came to be. This works exactly the same if we have cancellation shields or not.

Cancelling a TaskGroup or unstructured Task works as usual and is unaffected by cancellation shields as well.

Cancelling the current task (which you get from withUnsafeCurrentTask) is a much less frequent occurrence. But when you do, often no further action is necessary at all and one can just let code that runs afterwards check cancellation as usual. Or you might need to perform additional work when cancelling the current Task but it doesn't depend on the Task's status it can simply be run unconditionally as well. Again, cancellation shields don't change this either.

The only new case cancellation shields introduce is cancelling the current Task and then doing related work only if we are actually cancelled and not in a cancellation shielded scope. Which can be solved by simply checking Task.isCancelled after cancelling the current Task.

That is a fairly niche case in the first place and IMO isn't even all that complicated.

And I don't see how uncancellable Tasks would change this. After cancelling the current Task you would not know if that had any effect or if the Task was uncancellable, so you might need to check the actual status, ending up with the exact same code.

As mentioned in the proposal isTaskCancellationShielded and isCancelled(ignoringCancellationShield:) aren't really strictly necessary and are more intended for debugging, writing tests, etc.
Task.isCancelled is really what you want to be using as you shouldn't have to think about cancellation shields at all at the points where you check for cancellation in normal code.

This is a long thread, so please forgive me if the question was already asked: is "cancellation shield" an established term of art for this sort of functionality in other languages or libraries?