How to unwind stack like C setjmp/longjmp or C++ throw

Hello,

I'm migrating a C application to rust that makes use of setjmp/longjmp in a similar way that C++ might take advantage of a try / catch / throw exception pattern and looking for a good Rust approach.

It's an implementation of classical (Koza style) Genetic Programming whereby random programs (individuals) represented by tree shaped Lisp S-Expressions are interpreted using recursive descent. A global integer "max_duration" acts like a timer ticking off each encountered ( interpreted ) node and when it hits zero the individual's interpretation (execution) needs to abort and perform a return to the top of the recursion with one control branch that should unwind stack no matter how deep, and drop the stack content (and any orphaned artifacts) to the point at which the top level recursive call is made. As mentioned, this is very easy to do with C using setjmp / longjmp or C++ try / catch / throw. ( of course with these languages much of the cleanup work (i.e garbage collection) is up to the code author to implement.

I see there is an experimental crate for using setjmp/longjmp
https://github.com/rust-lang/rfcs/issues/2625

However I'm dubious about the disclaimer on needing to expect undefined behavior.

Is there any way in rust to get an equivalent sort of branch that unwinds the stack like C longjmp or, similar to C++ throw?

Short of this I'm thinking I would need to do this by killing a running worker thread when the time is out, but that would be more complex, for one, because clean up logic better left in the worker thread would need to be moved to the primary thread.

If the only solution to unwinding the stack requires threading I do see this article might be useful.
https://doc.rust-lang.org/book/ch20-03-graceful-shutdown-and-cleanup.html
Again, it does appear to be lot more involved using this approach.

It turns out my C program uses threads for each of these tree descents however the abort logic does not kill the thread. Rather the worker thread clean up after itself instead of having yet another thread clean up after killing it.

Thank you for any insights on this you may have!

Rust has an unwinding mechanism in the form of panics, and you can catch these, but it's not really encouraged to use it as a "normal" control-flow construct. It's more common to have your functions or threads return conventional values with Result and friends.

3 Likes

What do you mean by migrating? If rewriting in Rust, then use panic! instead.

If you're mixing C and Rust, then you should leave all uses of setjmp and longjmp to pure C code, and not let it jump over Rust's stack frames. Jumping over Rust code will leak memory (except Windows, where it happens to work by accident). It's officially an Undefined Behavior. It kinda works in practice (apart from leaking), but may be enforced harder by Rust in the future.

3 Likes

If these sorts of aborts are uncommon then you generally want to use panics. They are actually implemented using exceptions by LLVM, it's just a convention that Rust code should prefer Result-based error handling instead of panicking.

Something like this should work assuming you don't call any other code which tries to catch panics.

/// Private type we can use as a token to indicate whether an abort was
/// requested or if there was a normal panic.
struct AbortToken;

pub fn abort() {
    std::panic::panic_any(AbortToken);
}

/// Execute a function which may trigger [`abort()`] to unwind the stack,
/// catching any aborts and translating them to an [`AbortResult`].
pub fn run_abortable<F, T>(func: F) -> AbortResult<T>
where
    F: FnOnce() -> T + UnwindSafe,
{
    match std::panic::catch_unwind(func) {
        // the happy case
        Ok(value) => AbortResult::Ok(value),
        Err(payload) if payload.is::<AbortToken>() => AbortResult::Aborted,
        // This was a normal panic.
        Err(payload) => std::panic::resume_unwind(payload),
    }
}

(playground)

As a single data point, I know rust-analyzer uses this strategy to abort calculations whenever it gets input that would render the results useless (e.g. typing another letter while rust-analyzer is calculating code completions). If it happens infrequently then using unwinding like this can help keep your program's happy path clean without needing to propagate results to the caller.

It can also sometimes be more performant because you only pay the cost of unwinding when you need to bail, whereas Result-based code needs to move that extra data around (Result<T, E> is normally larger than T) and constantly branching if there was an error.

6 Likes

kornel, Thank you for your reply.

By migrating I mean rewriting in Rust. I will look at using panic! with the catch as described. If that is the only way to unwind the stack, I'll take what I can get. I do hope that an unwind without panic becomes something that is formally implemented. There are use cases for this that are valid.

cole-miller, Thank you. Very interesting. I do hope that in the future a "normal" control-flow construct becomes available that unwinds the stack. In general, do prefer Rust's preference for returning results, but sometimes the throw pattern has it's place. Agreed more rare, but there are cases.

It does exist, that's what ? does. The slightly increased ceremony and static typing of Result and ? is what makes it more useful than untyped panics, and is the reason why this is the normal way of handling errors in Rust.

3 Likes

Michael-F-Bryan, Thank you for the detailed reply-- with code!

I agree the usage here would be rare.. My particular case is to control other running programs (in affect an interpreter of my own S-expressions.) There may be millions at a time. Some will time out and I wish to have them gracefully abort. It's not an unhappy path exactly-- A perfectly expected occurrence deep in a call stack for which I do not care about how it got that deep. I just wish to go to the top, clean up-- log some metrics, and end gracefully (not the application, just this particular execution item.) If there were a way to have a thread signal itself to clean up and exit, that would be a good too. My C code simply used longjmp, and I was looking for the closest thing in Rust.

trentj, Thank you.

I mean a "normal" control-flow that actually performs the equivalent of a C longjmp and stack unwind to a place in the control that was previously "bookmarked" (in C this part is done with setjmp).

The ? is mainly syntactic sugar for a way to get the unwind job done but in effect it will be performing an unnecessary conditional (match) all the way up the stack unwind. I'm extremely performance focused and looking to aggressively remove unnecessary instructions in my current use case. It is possible in some cases the optimizer might be smart enough extract all the redundant conditionals up the stack with ?, but I don't think that is always possible.

First of all, have you measured it? Branch prediction is like a magic in most cases.

Also if you're focused on performance you may need to avoid unwind as much as possible. Modern compilers tends to implement "zero cost exception", which means it's near zero cost on happy path but thousands times slower on unwinding path.

2 Likes

If this is what you legitimately need, you should just use panic/catch_unwind and not worry about whether it's "normal" or not.

If your use case is so performance-focused to give up typed errors, you are not doing something that is "normal" in Rust. And that's fine! You should not hope for a "normal" way to do it, or for panicking to become normalized. Just do what you need to do, and forget about whether it's "normal" or not. Rust provides this capacity specifically for people who need it, even though it's not the normal way of error handling.

Of course, if you don't have a legitimate need to use panicking, there are concrete advantages to using Result, which is the whole point.

3 Likes

Hyeonu,

Thank you.

I have not bench marked, and I agree I will before employing one of these options-- I'll even let you know my results if interested.

However "unwind" in this case is a misleading term (perhaps the wrong term) -- Take the C longjmp ( if you're using linux "man longjmp") -- they use the term "rewind" -- it is not expensive in they way you are suggesting. It is not duration proportional to the stack depth because it simply resets the stack to the earlier "setjmp" point.

Here's a relavant description in the man page:
...
"The longjmp() function uses the information saved in env to transfer
control back to the point where setjmp() was called and to restore
("rewind") the stack to its state at the time of the setjmp() call. In
addition, and depending on the implementation (see NOTES), the values
of some other registers and the process signal mask may be restored to
their state at the time of the setjmp() call."

I wasn't mentioned C/setjmp/longjmp. They're not unwinding as they don't unwind anything. Unwinding is a mechanism which powers both C++ exceptions and Rust panic!(). It unwind the stack up to some position and drop all the variables unwinded. To do so the "zero cost exception" loads giant jump table on panic!() - practically near guaranteed cache miss - to find the unwinding instructions.

2 Likes

Hi trenj,

Thanks again. All good points. I hope you forgive me, this has become a bit longer of a discussion than I expected. However, I think you are seeing my use case "not normal" for Rust, at least in part, based on classifying the case as being an "unhappy" path error condition.

Here's my opinion, take it or leave it. I have never liked the try / catch / throw pattern either, and prefer Rust's approach to error handling. That said, the exception "throwing" pattern does provide a "typed" error that panic does not. So perhaps a typed panic would be nice here-- but now you're in the world of try / catch / throw error handing. And, again, I don't think my case is an unhappy case, so panic or try / catch / throw is not a great match.

I simply believe that there are valid use cases where the setjmp / longjmp pattern does make sense. Perhaps I'm misguided but I think Rust should replace everything that I can do in C because it brings safety and all the other great things to system programming. C was invented to replace assembly, and Rust seems well poised to replace C.

As stated previously -- thread control may be a good solution for me here as well. Perhaps most would even prefer that one. I do not know about that.

I (perhaps dangerously) feel-- if you can do it in C (not C++) --- you should be able to (eventually) do it in Rust -- and elegantly.

Yup, that’s true, this strategy worked splendidly for us. We use resume_unwind to initiate the panic (sic) as that avoids printing message to the stderr: resume_unwind in std::panic - Rust

6 Likes

That's really interesting. Why not propagate a result upwards? Does the code end up being cleaner because most of it doesn't need to deal with the abort?

We did use Result early on. Changing that to unwinding simplified the code a lot. More or less every function was changed from Result<T> to just T and the ergonomic difference between one possible error and zero possible errors is huge.

1 Like

I don't think there are any plans for supporting any other unwinding mechanism in Rust. Limitations of panic and lack of "normal" exceptions is intentional.

Rust relies a lot on Drop. Not just for memory management, but also things like mutex guards, and draining iterators. In Rust Drop is implicit and hard to avoid, so support for longjmp that jumps without unwinding is also not a good idea.

3 Likes

It seems like unwinding without handling drop it would be super-easy to create undefined behavior.

2 Likes

I find that request kind of odd. There is nothing "normal" about setjmp/longjmp"

Whatever it is that bails one out instantly from wherever one is, not only has to unwind the stack, it also has to deallocate the memory allocated to every object created on the way down the stack.

Back in the day, about the time C was devised, the big idea in software engineering, such as it was, was "structured programming".

Structured Programming can be boiled down to the fact that anything can be computed with sequential instructions, conditional execution and iteration. Which is why high level languages support things like "if" and "for". But they look down on "goto" which totally messes up such logical structure and makes things impossible to reason about.

Meanwhile real programmers, as opposed to CS theorists and language designers, love their "GOTO". So the Structured Programming guys had to invent ways to introduce "GOTO" into their languages in a structured way. This is why we have "switch" constructs, "break" and "continue" from loops, early "return"s from functions.

The "real" programmers who devised C ignored all that and provided "goto" and the mother of all goto's in setjmp/longjmp.

C++ came along and tried to organise that better with exceptions.

Anyway with my programming upbringing, in the days of "structured programming" none of this is "normal control-flow". Rather it is very abnormal.

I have no suggestions here. I only feel that emulating C's setjmp/longjmp is not the answer to the problem. Neither the exceptions of C++.

2 Likes