16
$\begingroup$

I noticed that many languages' built-in asynchronous Future cannot be canceled once called.

Although many third-party libraries provide this ability, it is not convenient to introduce a third-party library in small scripts.

In fact, there are some languages that do provide a cancel interface, but it does not guarantee that the coroutine will be canceled.

Languages that must cancel

unknown

Languages that may cancel

  • System.Threading.Tasks.Task in C#
  • java.util.concurrent.Future in Java
  • concurrent.futures.Future in Python
  • Concurrent::Promises::Future in Ruby

Languages that cannot cancel

  • Promise in JavaScript
  • std::future::Future in Rust

I want to know why these languages do not support cancelling (and guarantee cancelling) the built-in Future?

$\endgroup$
9
  • 4
    $\begingroup$ In Rust, cancellation is done by dropping the future, so you don't need a separate interface for that. $\endgroup$
    – Bubbler
    Jul 14, 2023 at 4:19
  • 2
    $\begingroup$ @Bubbler Not everything is cancellation-safe though right? And I don't think there's a built-in way to mark that as the case, you just have to hope the docs are good $\endgroup$ Jul 14, 2023 at 5:01
  • 2
    $\begingroup$ Java and C# provide mechanisms for canceling the asynchronous computation associated with a given future, but these are not guaranteed to be effective, so Java and C# would be better placed in the "Languages that may cancel" category. $\endgroup$ Jul 14, 2023 at 15:04
  • 1
    $\begingroup$ Just to add to what @JohnBollinger said, C# (and presumably Java) have a way to ask a task to cancel, but no way to force it to do so. $\endgroup$
    – Bobson
    Jul 14, 2023 at 18:51
  • 2
    $\begingroup$ You should have a look at discussions on whether or not to cancel Threads in Java. I've seen it once in practice and we never got it to work completely because the entire code base needs to be aware of cancellation. $\endgroup$
    – feldentm
    Jul 14, 2023 at 19:15

5 Answers 5

23
$\begingroup$

The problem with canceling a concurrent execution that runs on another thread is that you don't know what state it is in. It is very well possible that it is currently in a phase where just interrupting it without warning can lead to bad stuff happening. Half-written files, hanging database transactions, leaked memory, locks remaining taken etc.

The naive solution to this problem would be to give futures the ability to perform cleanups when canceled. But that's not unproblematic either:

  1. You no longer know if canceling a future really cancels it. The cleanup procedure of the future will keep running, and might continue doing work you actually wanted to prevent from happening.
  2. The implementation of the future doesn't know when it might get canceled. So it would have to be able to handle being canceled in every conceivable step of its execution. That's often not feasible.

However, if someone really needs canceling, then they can usually implement that on their own using whatever features your language supports for communicating between threads. In that case the canceling needs to be implemented on the side of the future. The future needs to actively listen to a cancellation signal, and then actively end itself in a controlled manner. So the programmer should know what state they are in and what they need to do if canceled in that state.

$\endgroup$
7
  • $\begingroup$ Although it is impossible to cancel when the operation is blocked, can I check at each await point $\endgroup$
    – Aster
    Jul 15, 2023 at 3:05
  • 1
    $\begingroup$ @Aster I would not recommend it, because an operation that must not be interrupted might include several await-points internally. $\endgroup$
    – Philipp
    Jul 15, 2023 at 8:20
  • 2
    $\begingroup$ "The cleanup procedure of the future will keep running, and might continue doing work you actually wanted to prevent from happening." - well, did you actually want to prevent that work from happening? It certainly depends on what you want to achieve by canceling. Naively, I'd say that at least one use case is "I don't care for this action anymore. Make it do as little work as is still necessary before terminating it." If that is the chosen semantic for canceling, then, yes, the future is really canceled even if this means it keeps running for another minute (instead of the 5 it would ... $\endgroup$ Jul 15, 2023 at 20:54
  • $\begingroup$ ... have taken, hadn't it been canceled). Likewise, problem 2 would be a moot point, because depending on the type and state of the future, it would indeed be expected that canceling doesn't actually have an effect on the future itself. The point would be "Do what you must, but try to stop as early as possible." $\endgroup$ Jul 15, 2023 at 20:54
  • 2
    $\begingroup$ Trying to have a "cancel" method which is guaranteed to leave things in a consistent state would likely be expensive, but I would think it might still be useful to have a means of telling a future that the task it was created to perform has become useless, and additional effort expended performing that task will be wasted. $\endgroup$
    – supercat
    Jul 16, 2023 at 17:28
14
$\begingroup$

What is a future exactly and what does it mean to cancel it? In C++ you have std::future objects which merely provide storage for the result of an asynchronous task, and some way to wait for that task to finish. You can just let the std::future object go out of scope without waiting for its result, but that doesn't cancel the task itself.

If you mean "why can many languages' asynchronous tasks not be canceled?", then again you have to first discuss what a task exactly is. I'll simplify and say there's basically two types: threads and coroutines.

Threads run independent from the main thread. Just cancelling them from the outside is hard, because it is difficult or impossible to predict what that thread is doing at the time it is being cancelled. Maybe it was just locking a mutex? Or it opened a file? You have to ensure that atomic actions are completed, and that any resources that are in use are released correctly. That might require unwinding the stack (as if an exception was thrown). POSIX threads can be cancelled, but only at cancellation points; C++11 threads don't even bother and don't provide a way to cancel them.

Coroutines are easier to cancel, as there are natural cancellation points from the language itself: every time await or yield is used. They also often run in the same thread, so there are much less thread-safety issues to worry about. For stackless coroutines like in C++20, a coroutine's state is stored in something that is just a class, with that state being stored in member variables. Since destroying objects is well-defined in most languages, destroying an object of that class is easy as well. So in C++ you can just cancel a coroutine by letting the object that stores its state (like C++23's std::generator) go out of scope.

So in summary: future ≠ task, and whether cancellation is possible depends on the nature of the task. I've also simplified here, there are many variations on threads and coroutines, each with their own implementation details, so you have to look into your language's implementation to find out why cancellation is possible or not.

$\endgroup$
1
  • 2
    $\begingroup$ There are more possible kinds of task attached to a Future too: you can use them for waiting for operating-system provided asynchronous I/O operations (which are neither threads nor coroutines), Unix signals, or any other kind of asynchronously generated and delivered event. Whether it is possible to cancel such a Future depends on the type of event source and whether the underlying operating system provides a way of doing so. $\endgroup$
    – occipita
    Jul 23, 2023 at 19:02
9
$\begingroup$

Requires extra work for implementers of futures

In languages with futures which aren't expected to be cancellable by default, implementing a future/promise is pretty easy. On the other hand, if cancellation could happen and you're required by the language to handle that correctly, you have to figure out how to properly do that at every point you're waiting, even when it would be a huge problem to cancel there (e.g., in the middle of a filesystem operation that would leave things in an unsafe state; you would have to ignore the cancel and keep working for long enough to fix that, when just mandating "don't cancel during this operation" is far simpler).

It might make sense to have an opt-in way to handle cancelling, which both JS and Rust have, but making futures universally cancellable would be a burden for implementers of futures, especially ones internal to a program/library which the implementer knows for sure won't be cancelled.

$\endgroup$
2
  • $\begingroup$ I think you are misunderstanding how cancellation works. You can't, in general, cancel any IO operation without specific code to handle that. Instead, promise cancellation normally works by simply discarding the result of the IO operation inside user code. Race conditions can happen but only inside user code not IO, and then only at the end point of an IO operation. $\endgroup$
    – mousetail
    Jul 14, 2023 at 5:48
  • $\begingroup$ Additionally, languages that allow cancellation typically have something like python's shield() that allow precise control over when a task is allowed to cancel and when it is not $\endgroup$
    – mousetail
    Jul 14, 2023 at 5:52
2
$\begingroup$

Destroy at distance is hard, and probably wrong

Take for example this: cat file | uniq | sort.

That is, cat output is a future of uniq, and uniq output is a future of sort. So now sort wants to immediately interrupt and destroy all its pending futures. In this simple example there is no problem.

But if instead of cat there is malloced structure, passed to the kernel, where you are writing (cancel or freeing) at the same time the kernel is writing. Boom happens.

It would be very simple to reason if cancellation acted immediately, but this really means that the future needs to be guarded against this type of "two writers at same time" type of problem.

This is possible in a MutexFuture, but... futures are normally utilized on code where performance is paramount. You are already trading performance for a heap allocation. Adding a lock in every action will cancel these performance gains (pun intended).

Because of these trade offs that cancel only "signal" the cancellation of one writer, leaving runtime or the other writer to do the right thing later on, with no locks in the hot path.

$\endgroup$
1
$\begingroup$

Briefly: Cancellation done that way is a global contract because the asynchronous operation can be anywhere in your code base. If you wan't a local contract, you need to use some other communication channel and something like safe zones where you check as asynchronous operation if you should continue. In that way, there is no difference between Futures and Threads. You can start digging here.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .