Interesting C++ proposal for interruptible threads

Thanks to an interesting blog post, I discovered a proposal for the C++ standard library, about interruptible threads.

The basic idea is pretty simple: a std::interrupt_token parameter is passed to the callable object used to spawn the thread, and this token can be used to check a request of interruption. A C++ implementation is already available, and it looks like it is easily portable to native Rust.

Do you think that it could be worth implementing this? Not necessarily in the std lib, but maybe crossbeam could gain a nice additional behaviour.

(I wanted to check is something similar was already available, but it looks like docs.rs is not loading, at least for me)

This sounds like essentially the same approach as the “cancellation tokens” from .NET https://docs.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads, and last I heard Javascript does not have cancellation yet but it’s widely agreed that cancellation tokens are the least bad approach for that language too. So this has plenty of precedent.

There is limited experimental support for AbortController which is basically identical to cancellation tokens at first glance.

1 Like

I’ve not looked in detail, but we have to be very careful with this in Rust. If this would permit a function to simply suspend itself and disappear, it will not compose well with safe abstractions like Rayon, that rely on unwinding to enforce invariants.

See my “Observational Equivalence and unsafe code” blog post for more details.

That said, I think it’d be a good thing for us to start thinking about.

4 Likes

Typically, under C# when you use Cancellation Token (with either async/await or normal threads) you either detect that the cancellation has been invoked and then exit whatever you are doing and return normally or throw a cancellation exception. In either cases, the function either returns/ends normally or unwinds. The API on the Cancellation Token allows for easily throwing the “Canceled Exception” through the “ThrowIfCanceled” method. Or you can simply check if canceled.

For a thread, if the cancellation exception is thrown, then the parent thread can introspect the exception by joinging/asking for it. For a “Cancellation Thrown” task, what was waiting on the task can either throw the exception as well, or, introspect the exception.

I would think Rust would work similarly. Either return a Result(CancellationError) (or something like that) or simply Panic/Unwind (depending on use-case).

As long as the Futures/Async/Await and Normal Threads can handle both these cases, should be golden (I would think).

4 Likes

It’s not just about the parent detecting the cancellation. Rayon involves a lot of “scoped” calls, where a job may reference data that lives on the stack. If such a job is executed on a different thread in the pool, and the source thread disappears, then that remaining job will be executing with dangling references.

Clarification request:

Is the discussed scheme cooperative or mandated? So long as cancellation is cooperative, the “worst” that could happen with a non-cancellation-aware library is ignoring the cancellation.

1 Like

If the proposal were to follow the model of C#, it would be cooperative.

EDIT: There are two models under C# for cancelling threads/tasks:

  • Call “cancel” on the Thread or Task (this is mandatory, uncooperative, and by-and-large considered deprecated)
  • Create a CancellationTokenSource/CancellationToken pair before starting the task/thread and passing the CancellationToken to the task/thread. The task thread checks the Cancellation token between units of work and performs whatever clean-up it desires before returning or throwing a “Canceled Exception”. This is cooperative and the preferred mechanism.
1 Like

The cooperative library would essentially have to make a safety assertion about its entire call stack.

How so/why? The cancellation token can be invoked, and the running tasks/threads check the value of the cancellation token periodically and clean-up themselves and early return with an error status. Why would the “Cancellation Library” need to make assertions about the safety of the stack in that case?

2 Likes

An early return is OK, even by panic / exception unwinding. I was thinking along the lines of what Niko said, “If this would permit a function to simply suspend itself and disappear…”

OK, I may have missed where that was an option. That, to my mind, is kind of an uncooperative option whereby the thread can just be cancelled without even unwinding. As far as I know, that isn’t even supported by the uncooperative modes of Java/C# for cancelling threads/tasks.

1 Like

Sounds good. The uncooperative operation has generally speaking been a failure, I think, for a variety of reasons (it’s not just about Rayon of course – you may hold locks etc too). A cooperative thing makes perfect sense.

3 Likes

AFAIK the jthread proposal is exactly to allow a cooperative and safe way to allow a basic communication between the thread and the joiner.

In practice is nothing more that what someone would do by hand to allow a clean premature exit from a thread. The good thing about the proposal is maybe it is not an uncommon feature and would avoid to reinvent the wheel every time.

But I want to play devil’s advocate. The reason I consider jthread an important feature for C++ is because we are missing a lot of features in the stdlib (std:future::then for example), and this won’t change a lot until 2023.

In Rust we already have a very powerful multithreading, and I am wondering if an implementation could be worth it. Moreover, the interruptibility is orthogonal to the scopability (given by crossbeam), and probabily a consistent implementation should provide all the four combinations. What is your point of view?

1 Like

@dodomorandi am I correct that these interrupts amount to a Boolean flag that is shared between the parent and the child, and which is set automatically on scope exit in the parent?

I’ve been using crossbeam channels for a similar purpose recently:

The child thread’s body looks like for msg in receiver. The parent thread holds a JoinHandle and a Sender, and it makes sure that the Sender is dropped before the child is joined. If at some point parent needs to terminate the child prematurely, it can just drop the `Sender.

2 Likes

Yes, pretty much. But, multiple children can share the token from the same parent so the parent can easily signal for them all to stop cooperatively.

2 Likes

@gbutler already replied, the behaviour is exactly that. :wink:

One interesting problem about cancellation in such synchronous API is that, if a thread blocks, it can’t really check for cancellation. That is, while

loop {
    if token.is_canceled() {
        break
    }
    /* compute */
}

works, something like

loop {
    if token.is_canceled() {
        break
    }
    some_file.read()?;
}

doesn’t: if a thread blocks in the read call, it won’t be cancelled.

While it doesn’t solve all such issues, basing cancellation on crossbeam channel helps, because you can select! over a set of channels.

When doing this kind of “cooperative async task/thread” programming it is considered a programmer error to do something blocking inside an ostensibly non-blocking call. You can do it, but, it can result in arbitrary wait times and potential dead-lock and is considered a programmer error. It is “Safe” though as far as I can reason.

It would be nice if Rust could somehow detect calls to blocking methods/functions inside an async method/function and alert you at compile time.

2 Likes

Another point on this: It will be cancelled, just not immediately. As soon as the read call returns (either timeout or gets some data), it will cancel on the next iteration of the loop. That is considered OK provided the delay isn't too long. So, as long as you use time-outs, it's OK. It is better to use non-blocking async I/O though. Ideally, the I/O calls you are making take as a parameter the Cancellation Token as well, so they can be prematurely ended by the token flagging as well.