Is 'unsafe' code a good thing?

I have heard this kind of statement many times and it has always puzzled me.

Certainly if "unsafe" wraps some interaction with the hardware or other language, the compiler does not have enough information to know if it is sound or not. Perhaps a human can judge it to be OK with the knowledge of the external system they have. This is not a Halting Problem kind of problem.

But what about code that is totally knowable by the compiler? Do we still need unsafe there? As far as I know we do sometimes.

Fair enough, a human can inspect it and judge it to be OK.

There is then a paradox for me. If that "unsafe" section is some kind of Halting Problem kind of problem the surely a human cannot verify it either?

Let's say we have code like:

let badptr = 0 as *const u8;
if odd_perfect_number_exists() {
   *badptr;
}

Even if the compiler can see and "understand" 100% of that code, it would have to crack an unsolved problem in mathematics to say whether this code is unsafe or not.

If the condition is false, then the code is safe. If you want to be able to analyze arbitrary code, you can't forbid or ignore such cases. For example, you need to analyze dynamic conditions to prove that Rust's Cow runs destructor only when that is safe to do.

Halting Problem is a name derived from the proof that arbitrary code can't be analyzed. It's not about halting, but lots of other analysis problems can be reduced to that problem by making them depend on whether part of a program will halt (such as whether a loop that stops on the first odd perfect number will stop or not).

There is analyzable subset of code, e.g. while true {} obviously never halts, and while false {} obviously always does. But that is not all code, and there's a proof that you will never be able to analyze all code. Rust can obviously analyze safe subset of Rust. It is possible to analyze more than that, but this usually gets exponentially more difficult (e.g. if you try to symbolically execute all possible paths through a function with all possible values), so there has to be a line somewhere between what is analyzed, and where analysis has to give up.

16 Likes

I agree. "good" and "bad" are emotive terms for a technical feature that is actually essential in some cases.

We do want to minimize the risk of our programs failing though. To that end I don't want to "write" unsafe into my application code. I don't like to operate machines with the guards off! If I have to I want "unsafe" wrapped up nicely somewhere not scattered all around my code. Preferably I want it to be in libraries written by people who know what they are doing, vetted by others and tested by even more users.

Yes indeed.

But that, and the rest of what you said, applies to the human reader as well.

Hence my suggestion of a paradox here.

For me, unsafe code is a tool like an engine saw. Should we peel the apple using engine saw? No, it should be better to use safer tools like kitchen knife. Should we recommend to use engine saw for people new to woodworking? No, they should be used to small carving knives first. Should we cut the trees using carving knife? No, that would be too slow. But it would be better to call existing tree-cutting service organized by experienced people. You can't find a such service for your use case? Than please make one for yourself so others can benefit from it.

3 Likes

I'm having doubts about that. As far as I can tell "unsafe" is not a tool to enable you do do things faster, it's to enable you to do things that are otherwise logically impossible. Like calling out to C or dealing with hardware.

As I think I mentioned above, so far I have always managed to match the speed of C/C++ without using unsafe.

Does anyone have an example of using "unsafe" is required to achieve performance that cannot be achieved without it?

Performance benefits are mainly via implementing data structures that aren't possible to implement exclusively in safe code.

As a current example, indexmap is a surprisingly competitive HashMap implementation for being entirely based on safe code using Vec. However, using hashbrown (which is the implementation powering the std HashMap) is unambiguously more performant, due to being a highly optimized implementation using raw pointer manipulation.

3 Likes

This is a good reaction!

I usually think of unsafe code as an escape hatch you can use when you are doing tricky things with memory (channels, allocators, BTreeMap, Vec<T>, etc.) or need to access external code that the compiler can't verify.

I don't usually buy into the "we need unsafe because performance" argument. You are often trading correctness for perceived performance, when the optimiser would have been able to (provably correctly) make those optimisations anyway. These uses of unsafe have a much higher burden of proof than the previous case.

Likewise, using unsafe to work around the borrow checker should be met with skepticism... If the borrow checker thinks something you are doing is sketchy, I'd be inclined to listen to it.

I like this analogy... I work with engineering and sometimes we'll need to do things that are normally unsafe in order to complete a job. For example, imagine needing to remove the guarding from a piece of machinery while it is running so you can see which part isn't spinning properly.

In this case it's not practical/possible to do the job with all the safety measures in place so its up to the human to make sure they don't hurt themselves.

An interesting piece to look at here is Pin. As far as I can tell, its real purpose is to allow the ownership of an object to change while there are still references or pointers to it. There’s nothing that makes this theoretically unsound, except that transferring ownership has traditionally invloved copying the value to a different place in memory.

As noted in its docs, doing anything useful with Pin requires unsafe code: There’s no other way to convince the compiler to move an object with active references. If you don’t want to do that, there’s no need for the Pin at all. This is exactly “using unsafe to work around the borrow checker.”

I would not say that this is accurate. One way to think of it is as a way to add more guarantees to a reference, e.g. a &mut T guarantees that the pointee does not move while the reference exists, whereas a Pin<&mut T> guarantees that the pointee will never move again, even after the reference goes away.

1 Like

Right, but is there any situation where this would happen that doesn’t arise from a change in ownership? You’ve accurately described what Pin<&mut T> does; I’m trying to understand why it exists, which is a subtly different question.

I should’ve probably said ‘pointers’ rather than ‘references’, though, as those are two distinct things in Rust.

It depends a bit on your definition of "transfer ownership", e.g. does transferring ownership of a Box<T> also transfer ownership of the T? If the answer to that is no, then you cannot ever transfer ownership of a value that has been pinned. The Pin type exists to make self-referential structs sound. Self-referential structs are normally unsound as all moves are just a memcpy, so any pointers the struct stores into itself would not be updated when the struct is moved, but if you know that the struct is never moved, that's not a problem.

To tie this back to the discussion in this thread, note that a self-referential type would never be usable in safe code without something like Pin (or heap allocation), because the soundness relies on the owner of the object making a promise to never move the object. It is possible to define a pin! macro that makes use of variable shadowing to ensure that the value can never be moved again, allowing safe code to pin something to the stack.

So Pin is actually an example of library code that gives the compiler the ability to verify something, that the compiler was not able to verify previously. Now that the Pin api exists, a user can, in safe code, pin some value, and have the compiler verify that the value does in fact not move later. Once the value is pinned, the user can obtain a Pin<&mut T> to that value, and by passing a pinned reference to other functions, those other functions know that the argument is definitely pinned, and can rely on that guarantee in unsafe code.

As an example, the standard library mutex stores the value in an heap allocation, which is necessary due to some OS apis taking pointers directly to the mutex. If the mutex object was moved, that would invalidate those pointers. Now that Pin exists, it would be possible to rewrite mutex to instead take a pinned reference when locking the mutex, and by doing this, the user can promise that they wont move the mutex object, and it would be perfectly fine to give the OS pointers into objects owned by the caller, instead of using a heap allocation to ensure the pointers remain valid.

3 Likes

In the context of formal computability theory, we're usually talking about a single algorithm that can (in this case) prove the absence of undefined behavior for any Rust program whatsoever with zero false positives and zero false negatives. Such an algorithm is just mathematically/logically impossible, and that's never going to change. The general principle here is https://en.wikipedia.org/wiki/Rice's_theorem, which in some sense is a generalization of the famous halting problem.

What is possible is proving soundness for any program in a sufficiently restricted subset of Rust and allow false negatives (as well as false positives if you make mistakes in your unsafe code), which is exactly what rustc does today.

The reason this doesn't apply to humans is simply that humans aren't algorithms. They can guess, and try creative solutions, and invent novel proofs of their own. But doing that usually takes a lot longer than we want for our rustc compile times. In practice, the human task is usually coming up with various "restricted subsets" and acceptable "false negatives" that are algorithmically solvable, proving that they are, and then deciding which ones we want to use to do our day-to-day compilation proofs.

(if you want to learn about all the details I'm sweeping under the rug with phrases like "sufficiently restricted subset of Rust", that's a branch of mathematics called "computability theory")

4 Likes

What do you mean by "single algorithm"? One could compose any arbitrarily huge and complex single algorithm from every other algorithm one has.

I'm not totally unfamiliar with Turing and Gödel and the like. But no expert by any means.

I don't want to by into such an argument. It's making an assumption "humans aren't algorithms" that is not proven, if ever it could be. It flies in the face of physics, computers and humans are just particles blindly following the laws of physics. It seems to introduce the slippery ideas of Free Will vs determinism.

I'm not suggesting such a checker is, in general, possible. I'm am resisting the notion that humans are any better at it than algorithms could be.

As a practical matter we don't have such a checker because:

  1. We have not figured out how to make such a checker algorithm. Never mind if it is actually possible or not.

  2. Even if we did it would take unacceptably long time to do it's job.

What is meant here is that although you can make it as huge, complex, and composed from whatever you want, you have to make a choice about which algorithm you are working with. In other words, if you pick a specific algorithm, then there is some Rust file that it will not be able to verify. On the other hand, there is no Rust file that every algorithm fails to verify.

One place where this comes into play is randomness. These theorems assume that the algorithm is fully deterministic, and unless you hard-code some sort of pseudo-randomness seed in the algorithm, you have not picked a "specific algorithm" in the sense used here. (If this seed has a bounded size, you can just try every seed. You are allowed to combine as many algorithms as you want after all, as long as there are finitely many.)

As for humans; if you want to apply these theorems to us, you run into the issue of "single algorithm" again. You can't just say "humans", because that is not a specific fully deterministic algorithm, and the theorem only applies once you have chosen a single specific algorithm. If we assume that the universe is deterministic or whatever (and that humans eventually go extinct), we can make some argument where our single algorithm loops through every nanosecond in the universe, and for each instant, loops through every person alive at this moment and sees what they would do. However this runs into some trouble with non-obvious assumptions about physics, continuity of time, bounded lifespan of humans, the fact that we also have to verify the answers given by humans somehow, as there are probably a lot of them who give the wrong answer, and figuring out what to do if every human gives up trying to solve it.

The above is not an argument that humans are better than algorithms; rather it is an argument that the halting problem does not guarantee that humans are as bad computers are.

5 Likes

I find it just as unproven and debatable to say we are nothing but particles without souls as it is to say we "humans aren't algorithms". So it's a draw - two competing unprovable philosophies, leaving us with nothing but our free will to help us decide which to choose. :slight_smile:

A very successful programming language inventor once said "Design and programming are human activities; forget that and all is lost". The bottom line seems to be that there are things very difficult for algorithms (designed by humans) to do that are easy for humans, and visa versa. The beauty of Rust is that I don't have to use my human mind as a blunt instrument trying to track pointer lifetimes, and can instead use Rust as a tool for that and use my mind for something it is better suited for. (In my case, I'm using Rust for particle simulation.)

Circling back to the original post - personally I wouldn't use a library that requires me to use unsafe because that defeats my purpose for using Rust in the first place. I want Rust to prove the memory safety, not me.

1 Like

Ha! Touché.

I invoke occam's razor. You are introducing the concept of "soul". Something nobody has any evidence of existing. And that it can have some as yet unobserved effect on physical matter.

Back on topic. Totally with you there. I'm all for the computer doing all that bookkeeping grunt work. That is why we created computers is it not?

With the caveat that just now, for me, it feels like I spend more time trying to convince the borrow checker to approve of my code than I'm saving. But the payoff of course is that the borrow checker is far more likely to be right than I am :slight_smile:

Exactly.

I am so tempted to point out how occam's razor results in circular reasoning in this case. And provide the evidence. But if I did that, mods would probably object about "off topic" or something. So I will abstain. :slight_smile:

You and I are in the same boat here as far as seeming to take a long time to satisfy the borrow checker. Which is concerning to me. I first went through the Rust book a year ago, and have been programming sporadically in Rust in my spare time since then. From your previous posts it seems like 1) You have been doing Rust a little bit longer than me, 2) you use it for your day job on a regular basis, and 3) you are really smart. And yet like me you are still struggling to feel productive.

I'm still hoping for and looking for that breakthrough where I "get" the Rust way of doing things and am comfortable with it enough that I feel more productive. When I started using Python (20 years ago) I felt an immediate productivity boost compared to C++ and Java. But now, using Python on larger projects with larger teams, Python isn't feeling as productive as it used to be. I recently spent a major part of a code review pointing out to a fellow developer that the function he wrote gets called with a parameter of the wrong type. He claimed otherwise, then I had to prove to him that function A calls function B which calls function C with a parameter of the wrong type that originated in function A. At that point I wondered, where did my Python productivity boost go? I'm ready to have a compiler start checking the code again. Others have noticed this problem and have started bolting on optional static type checking to Python. I'm trying Rust out as an experiment to see if it is viable option for creating reliable components that have good performance and memory usage - but I can't recommend it to my team until I start feeling productive with it! How long is that generally taking people? I'm interested in hearing people's stories. (Maybe that's a new topic.)

1 Like

Feeling more productive and actually being more productive are two quite distinct things. I still have a lot of back-and-forth with the compiler, but I’ve stopped being shocked when compiling code works correctly as soon as it compiles: I don’t necessarily get the emotional hit of feeling productive, but objectively I seem to be getting more done.

2 Likes

The whole discussion of Rust productivity may call for it's own thread.

Well, productivity is not about a single programmer churning out a thousand lines of code in a day and then being proud of his amazing productivity.

To my mind when you think of productivity you have to think of the whole software life cycle, designing it, writing it, documenting it, deploying it. Then comes all the fielding bug reports as it keels over in use or spits out random results occasionally over however many years it lives. Then what about future feature enhancements, how easy are they to put into place, how many more bugs do they introduce?

And likely that is not just one programmer but a team. A team whose membership may change over time.

I would not read too much into my experience. I started looking at Rust a year ago. I have by no means been at it full time, far from it. I have been diverted by hardware design and then we still have software in Python and Javascript around here.

However what I put in place last year has been spinning along with no issues. A Rocket web server with RESTFULL API, a web socket server, a decoder of endless streams of horrible proprietary binary protocol, interfaces to NATS messaging and CockroachDb. No mysterious random outputs, no crashing in the night with memory exhaustion. No wasted weeks trying to find some obscure memory or race condition issue.

That right there is your productivity. It does not cause problems and waste your time after you have written it :slight_smile:

6 Likes

Your experience of "When it finally compiles, it just works without problems and is (comparatively) easy to refactor should the need arise" is common among Rustaceans. Which brings us back to the subject of this thread: "Is unsafe code a good thing?"

For me that answer has multiple parts:

  1. unsafe code is necessary to implement many of the safe foundational abstractions that Rust offers within std and a few other crates. IMO this is a good thing.
  2. unsafe code is necessary to work at the bare-metal level in embedded systems or, for crypto, to avoid timing side-channels. IMO this also is a good thing.
  3. unsafe code is necessary to interface to all those other languages that are inherently unsafe (e.g., C, C++). IMO this is unavoidable; such interfaces are just an extension of the unsafety of those other languages.
  4. Judicious use of unsafe code is sometimes called for to improve critical "hot paths" in high-traffic or time-critical code. IMO this is unfortunate but understandable.
  5. unsafe code is often used in what amounts to premature optimization. IMO this is ill-advised and completely avoidable.
  6. unsafe code is often used in attempts to circumvent the borrow checker, often – though not always – resulting in UB. IMO this is [Edit: completely usually] avoidable.
9 Likes