Panic on slice, no way to handle

thats the C# nightmare right there - all those unhandled exceptions

Moderator note: This really isn't adding anything to the conversation. It's pure flame bait. If you want to be critical, try to be more constructive and substantial.

4 Likes

Coming from a C# developer with over 20 years of experience: this is false. C# is prone to multithreading issues and data races that Rust deals with easily. Blindly catching and resuming after all exceptions leads to weird bugs at best or worse: data corruption. And throwing more hardware at a problem because your code is slow doesn't always help.

Abort cleanly with a useful error message: that's what a panic is. Catching exceptions on the other hand can be like playing whack-a-mole when third party dependencies change and suddenly throw new exceptions that weren't possible before, or leave dead code where exceptions they used to produce no longer happen. Catching blanket exceptions in main [if you even have a practical main loop to (ab)use - think webapi] is not really useful beyond blindly plowing onward with a diseased process containing who knows what kind of mangled state stored in singletons and service classes, or just logging and exiting anyway, a la panic.

If I had someone submit a PR with a blanket try/catch to log/swallow all exceptions and continue regardless, I'd reject it. If they persist, I'd have them removed from my team.

In a nutshell I don't think anyone here has misrepresented C#. It's a decent language, as far as it goes. But Rust's protections go beyond those which C# offers, namely in the areas of concurrency and data races.

10 Likes

C# is prone to multithreading issues and data races that Rust deals with easily

Never use threads, one can not test them sufficiently, there may be a one in a million race condition. Multiple processes and machines are the way to go. Distributed software does benefit from more hardware, typically the more nodes the more throughput.

One does not blindly catch and resume, one designs the handling which is usually one place or pattern. The error raising code is then very tidy, just throw new X anywhere. Rust seems to require two or so lines to unwrap the Result everywhere one calls a library method that may fail or a method that calls one or calls one of those?

Abort cleanly with a useful error message

I was referring to the exception is a fatal error situation, in a library where one throws ones does not know if it is a fatal error or not.

third party dependencies change and suddenly throw new exceptions that weren't possible before ...

In the vast majority of situations one catches the root Exception. It is the same in Rust where unwrapping of the Result needs to change as there are new say error.kind()?

main() for those who know C#. If the exception can only be handled as a fatal error then catching it there is fine.

If I had someone submit a PR with a blanket try/catch to log/swallow all exceptions and continue regardless, I'd reject it. If they persist, I'd have them removed from my team.

A fatal error does not continue.

If you need to propagate the error further (i.e. if in C# you wouldn't use catch here), just use the question mark.

If you catch the root Exception, then you don't know what kind of exception exactly you catch - just that some kind of error happened. In Rust, that would correspond to Result<_, Box<dyn std::error::Error>> or anyhow::Result<_>.

2 Likes

If you can't test threads, you can't test processes either.

6 Likes

If you can solve the problem with multiple processes or machines without any kind of synchronization you can also solve it with multiple threads without any race condition. And if you can't solve the problem with multiple processes unless you perform some kind of synchronization then I can't see how you are getting rid of the race conditions just by using processes instead of threads.

3 Likes

third party dependencies change and suddenly throw new exceptions that weren't possible

Third parties code changing requires a regression test as the behavior has changed, suddenly throwing new exceptions that weren't possible before is only one example of that

It is the same in Rust where unwrapping of the Result needs to change as there are new say error.kind() ?

Here was actually referring a method in a third party library like File::open() adding a new error.kind() say ErrorKind::NewError.

Both scottmcm and eko:
Processes do not require "any kind of synchronization" as there is no concurrent access to memory, you can not have race conditions as you do with threads. async code may require a very occasional lock{}. FYI This part of the discussion is about C#.

So you catch Exception, print the stack trace, and exit the program? That's exactly what panic does.

3 Likes

lock is usually the wrong choice in async C# code, since it blocks the thread pool's thread. You're usually better off using SemaphoreSlim.WaitAsync, or one of the various things from NuGet like Nito's AsyncLock.

1 Like

This is only true if your code does nothing interesting...for example, it does not access a database, does not send/receive messages on a message bus, does not perform timed jobs, etc. Otherwise you haven't prevented any race conditions, you've just pushed them into nearly impossible to debug runtime behavior spread across multiple processes.

That is not a good way to deal with synchronization in async code.

I just want to state something explicitly that may have been missed:

panic!s are for fatal errors. If you panic!, that's the "kill the entire task" kind of error, that goes back to the main loop to continue on, hoping that any shared state wasn't corrupted (I hope you write exception/unwind correct code!)

These are exceptions in C# like IndexOutOfRangeException, NullReferenceException, ArgumentException. The exceptions you aren't expected to handle except at a high level catch-retry loop, or at a thread/task boundary.

For expected program errors in Rust, you use Result. Result is kind of like checked exceptions, in that you specify the exact expected error conditions, and the caller has to acknowledge the presence of the error conditions. Either they handle them (pattern matching), pass them on to the caller (?), or promote them to a fatal error (.unwrap()). It's a feature that if you decide to handle the errors, the compiler makes sure you handle every potential error case. (Thus, adding new error cases is an API breaking change, unless you explicitly marked your error enum as open-world with #[non_exhaustive] so callers are forced to write a fallback case.)

You can recover from panics, for the purpose of resuming a main loop. First off, whenever you have a thread or an async task boundary, that serves as an unwind boundary by default. If you use a join handle to join the thread/task, that gives you a Result which you can then handle like any other result; unwrap to propogate the panic into the orchestrating thread/task, or pattern match it to handle (or even just drop) the panic payload.

Without a thread/task boundary, you have std::panic::catch_unwind to catch a panic and turn it into Result. Reiterating the point, though,

It is not recommended to use [catch_unwind] for a general try/catch mechanism. The Result type is more appropriate to use for functions that can fail on a regular basis.

Part of Rust's selling points is fearless concurrency, and while Rust cannot prevent deadlocks and other concurrency based logic errors, it does prevent unsafe data races (those which cause truly Undefined Behavior) and allows thoughtful API design which makes it easier to write correct threaded code in both scatter/reduce style (rayon is amazing for such embarrassingly parallel workloads) and channel driven work queues. Your clean multi-process design can be replicated with just as much separation between threads as between processes (a panic! only takes down a single thread!) enabling you to scale down just as well as up.

If the error is a fatal "kill the task" error, .unwrap() is fine. If the goal is to propogate the error to your caller to handle, ? does that exactly[1]. If you're going to handle the errors here, pattern matching makes sure that you acknowledge every error condition and address it in some way.

With catch_unwind or a thread/task boundary, this is the behavior you get: everything is just bundled into a Box<dyn Any> which you can toss. With Result, you can pattern match Result::Err(_) to match an error case without caring what exact error is there.

With Result, a library adding a new error case is either an API breaking change, and the compiler will tell you about it, or it's not strictly because #[non_exhaustive] was used and you were forced to already write a fallback case. (Of course, if your fallback case is just an unreachable! panic, that's on you for writing an obvious logic error when it blows up.)

io::Error is an interesting case, because it's actually impossible to enumerate the possible errors from any given IO operation. A favorite example is that filesystem access can return network errors if you have the fortune of using a network drive.

io::Error is also the go-to example of a nonexhaustive enum because of this; you always have to write some fallback "IO failed for some other reason" case; usually just a report and abort (the thread/task/job/etc) since you can't address an IO problem you don't understand.

This is another point I want to make extra clear.

There's two classes of "race condition."

The prior class is what I call an "unsafe race condition," and is what causes Undefined Behavior in the C++ memory model, and thus completely arbitrary results, from working correctly to memory corruption to causing your CPU to halt and catch fire due to time traveling nasal demons eating your laundry[2]. This is prevented in memory-unsafe languages only by a process barrier, but Rust prevents unsafe race conditions at a source level. Unless you write unsafe and break Send/Sync invariants, it is impossible to write such an unsafe data race in Rust.

The latter class is not prevented by Rust, but it's also not prevented by process boundaries. It's in this class where time-of-check-time-of-use style errors live, as well as deadlocks and and other concurrency logic errors. These issues are endemic to multitasking, and there is No Silver Bullet that completely eliminates this class of error.

Certain practices can certainly reduce them — and Rust is very good at expressing them, from scatter/reduce to actor patterns — but such practices are necessarily applicable across languages.


  1. With the theoretical future feature of anonymous enums, you can get the ergonomics of checked exceptions, listing every concrete error type you can bubble in your function signature. Today, with data-carrying enum and a simple library derive(Error), you can give a name to a set of errors that your functions tend to produce and deal in that. ↩︎

  2. Did I miss any UB idioms? ↩︎

8 Likes

At risk of going further off-topic into C#, the following link describes a great way to look at different kinds of exceptions. It is by Eric Lippert, formerly a Principal Developer at Microsoft on the C# compiler team and member of the C# language design team.

2 Likes

Threads don't inherently require any kind of synchronization either. Certain solutions do. I think this was clear from my post.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.