How safe is Zig?
scattered-thoughts.net
scattered-thoughts.net
At least in simple cases, this means that the memory for escaped variables could be allocated all at once at the beginning of the program not too differently to how the program allocates memory for the stack.
I’d hazard a guess that the implementation will rely on use-after-free faulting, meaning that the use of any escaped variable will fault rather than corrupting the stack.
Zig has future plans to require recursive functions to declare their maximum stack memory usage up-front, so that will provide the rest.
For every allocation, also store on the heap a "reference object", that keeps track of this new reference.
struct reference_t {
// the ptr returned by malloc
void* ptr;
// the stack/heap location that ptr was written to.
// i.e the reference location.
void* referenceAddr;
}
Reference track: every time ptr is copied or overwritten, create and delete reference objects as needed.If ptr is freed, visit every reference and call *referenceAddr = NULL, turning all of these references into NULL pointers.
All in all, a mark-and-sweep will likely be faster (in throughput at least) let alone a good GC.
The programmer would still decide when to free the object, not some automatic system. Manual memory management, with Ref Counting just to add some additional Use After Free checks.
Maybe the compiler could offer me a simple way to fix the declaration somehow. But being explicit and transparent here feels important to me; if I wanted to second-guess the compiler and meditate over disassembly, I could pick C++.
What If we combined the 'non-repeating' malloc idea with 128-bit uuids?
malloc would just return a 128-bit uuid, and to get to the data ptr you'd need to consult a hash table.
dataPtrArr[hash(uuid)].dataPtr = dataPtr
We'd check if it's been freed by checking: dataPtrArr[hash(uuid)].uuid == uuidThat solution is basically how platforms like Psion or Symbian used handles due to memory constraints.
You also don't need to worry about ref cycles or GC pauses.
https://floooh.github.io/2018/06/17/handles-vs-pointers.html
(but for this an "auto-decaying pointer" would be nice which cannot be stored outside the stack and cannot be "carried across" function calls.
var fba = std.heap.FixedBufferAllocator.init(slice_for_fba);
gpa = fba.allocator();
slice_for_fba is a heap-allocated byte slice. gpa is a global. fba was local to main(), which coincidentally made it live as long as gpa, but then it was local to some setup subroutine called by main(). gpa contains an internal pointer to fba, so you run into trouble pretty quickly when you try allocating memory using a pointer to whatever is on that part of the stack later, instead of your FixedBufferAllocator.Many of the dangling stack pointers I've caught in code review don't really look like the above. Instead, they're dangling pointers that are intended to be internal pointers, so they would be avoided if we had non-movable/non-copyable types. I'm not sure such types are worth the trouble otherwise though. Personally, I've just stopped making structs that use internal pointers. In a typical case, instead of having an internal array and a slice into the array, a struct can have an internal heap-allocated slice and another slice into that slice. like I said, I'd like these thorns to be less thorny somehow.
There is a proposal for this that was accepted a while ago[0]. However, the devs have been focused on the self-hosted compiler recently, so they're behind on actually implementing accepted proposals.
But not requiring a PhD (hyperbole) is not enough: it should be Simple as well.
But unfortunately Rust is (mamma mia) Complex and only pointy-haired Scala type architects are supposed to gravitate towards it.
But think of what the distinction between no-found-bugs (testing) and no-possible-bugs (a certain class of bugs) buys you; you don’t ever have to even think about those kinds of things as long as you trust the compiler and the Unsafe code that you rely on.
Again, I could understand if someone thought that this safety was not worth it if people had to prove their code safe in some esoteric metalanguage. And if the alternatives were fantastic. But what are people willing to give up this safety for? A whole bunch of new languages which range from improved-C to high-level languages with low-level capabilities. And none of them seem to give some alternative iron-clad guarantees. In fact, one of their selling point is mere optionality: you can have some safety and/or you can turn it off in release. So runtime checks which you might (culturally/technically) be encouraged to turn off when you actually want your code to run out in the wild, where users give all sorts of unexpected input (not just your “asdfg” input) and get your program into weird states that you didn’t have time to even think of. (Of course Rust does the same thing with certain non-memory-safety bug checks like integer overflow.)
The problem with getting lost too much in the ironclad certainties of Rust is that you start forgetting that simplicity (papa pia) protects you from other problems. You can get certain programs in pretty messed up states with an unwanted wrap around.
Programming is hard. Rust is cool, very cool, but it's not a universal silver bullet.
If option A has 20 defects and option B has the superset of 25 defects then option A is better—the fact that option A has defects at all is completely besides the point with regards to relative measurements.
People have been fighting this fight forever. Should we use static types which make it slower to iterate or dynamic types that help converge on error-free behavior with less programmer intervention? The tradeoffs have become clearer over the years but the decision remains as nuanced as ever. And as the decision space remains nuanced, I'm excited about languages exploring other areas of the design space like Zig or Nim.
Yes. If you change the entire premise of my example then things are indeed different.
Rust eliminates some defects entirely. Most other low-level languages do not. You would have to use a language like ATS to even compete.
That’s where the five-less-defects thing comes from.
Go down to ten effects? What are you talking about?
> Rust eliminates some defects entirely.
Is really a true premise, and to the extent it is true, is not a clear to me that it makes Rust better or safer than languages who don't eliminate this class of bugs.
Unsafe exists, is widely used, and importantly is used in places where the hairiest versions of these bugs tend to live anyways.
For safe code, I think it's clear that what we as programmers really care about is "how much does the language reduce the number of defects weighted by severity". If Rust reduces the number of memory bugs but increases other types of bugs due to increased complexity, that might not be a net win.
Personally my guess is that Rust is a net win in this regard, but I don't think we have any evidence of that.
I've worked in heavily monadic Scala codebases where changes in object structure require minor refactors due to strict typing in the codebase. Changes that could be small in other languages would necessitate changes in our monad transformer stacks and have us changing types throughout the project. This eventually lead to engineer anxiety ("small changes take forever so I'm not willing to work on this codebase and explain to my manager why a schema change is taking so long") and aversion to project ownership.
This codebase did materially have fewer defects than other, looser, codebases my team worked with. We got paged fewer times for this codebase than others. Unfortunately, other than a handful of engineers who loved working with monadic FP and were also busy, nobody wanted to touch the code and once the original authors left the team, the codebase went untouched. You could make the case that management should have let these engineers take more time to make these changes but in the meantime, other teams at the company built up high reliability ways of working in other languages and paradigms with higher velocity and similarly low defect rate.
Defects alone aren't everything. You need to look at the big picture.
> you don’t ever have to even think about those kinds of things as long as you trust the compiler and the Unsafe code that you rely on.
Forgive if I don’t give the lawyerly disclaimer in all of my follow-up comments.
Yes, you have to be able to trust the Unsafe code that you depend on.
200+ memory safety errors were found in rust crates: https://www.infoq.com/news/2021/11/rudra-rust-safety/
I love rust, I think there's a good chance zig in its current state isn't the answer, but saying rust is totally memory safe is wrong. You drop into unsafe and people make the same errors C/C++ devs make.
In C/C++ world, _everything_ is unsafe, so you can't even limit your exposure in the safer parts because you can do unsafe operations anywhere.
> you don’t ever have to even think about those kinds of things as long as you trust the compiler and the Unsafe code that you rely on.
Your comment is not some kind of gotcha. I can’t be bothered to give the long-form disclaimer in all of my follow-up comments. Read the context.
Only if "defect count" is what you care for.
What if you don't give a fuck about defect count, but prefer simplicity to explore/experiment quickly, ease of use, time to market, and so on?
I might still want Zig just fine. E.g. because I know C well, and it's a better C. Or because of the ease of interfacing with C/native libs. And several other reasons.
Zig allows custom allocators pretty much everywhere, right? Would it be impossible to provide it with a GC-based allocator for increased convenience at the cost of a little performance for programs (or parts of programs) where convenience is preferred? Perhaps libgc could be an inspiration here.
I probably can "beat that", because e.g. breaking things into functions increases simplicity and being able to explore/experiment quickly.
Whereas eliminating defects with a type/lifetime checker you need to hand-wrestle, not so much.
I personally am not interested at all in abstract discussions about sets of errors. Reality is much more complicated, each error needs to be evaluated with regards to the probability of causing it and the associated cost. Both things vary wildly depending on the project at hand.
Fair.
> I personally am not interested at all in abstract discussions about sets of errors.
Abstract? Handwaving “no silver bullet” is even more abstract (non-specific).
I have absolutely no faith that, in a future where Zig is popular, it will remain so. "well it passed our unit tests and it didn't fall down when we fuzzed it, so lets squeeze a couple free % extra perf out of it and ship" already in this post we have people talking about how safer mallocs or sanitizers are too much of a hit to expect people to use in the wild.
I think some of the push back is from domains where Rust isn't uniquely suited. Eg, You see a lot of complexity in Rust for server backends; eg async and traits. So, someone not used to Rust may see these, and assume Rust is overly complex. In these domains, there are alternatives that can stand toe-to-toe with it. In lower-level domains, it's not clear there are.
The big win for rust in these domains is startup time, memory usage, and distributable size.
It may be that these things outweigh the easier programming of go or java.
Now if you have a big long running server with lots of hardware at your disposal then rust doesn't make a whole lot of sense. However, if want something like an aws lambda or rapid up/down scaling based on load, rust might start to look a lot more tempting.
But as someone that's dabbled a bit in both Zig and Rust, I think there's a lot of incidental complexity in Rust.
For example, despite having used them and read the docs, I'm still not exactly sure how namespaces work in Rust. It takes 30s to understand exactly what is going on in Zig.
The non-CS "human" answer to this is that so much of tech and programming is unfortunately tied to identity. There are developers who view their choices as bordering on religion (from editors to languages to operating systems and so on) and across the entire industry you can see where some will take the slightest hint that things could be better as an affront to their identity.
The more that Rust grows and winds up in the industry, the more this will continue to happen.
There is a recurring trend of sound C programs turning into unsound Rust programs, because shared mutability is often necessary but it's difficult to avoid creating &mut, and Stacked Borrows places strict conditions on constructing &mut T (they invalidate some but not all aliasing *const T).
For a Rust program the bar is "has to be safe, even if some other part of the program uses unsafe". That seems like it's arguably a higher bar than C where everything is already "unsafe" in that same way.
Although I'm a big fan of safety, since experiencing Rust my opinion is that low-pause GC is a better direction for the future of safe but high performance programming. And there's also a niche for languages that aren't absolutely safe which I think Zig looks like a great fit for.
Rust today let's you avoid allocations, if you want smart pointers you can have them today, I'd you want a GC inside rust, you can have that today as well. Don't need to wait for another language lol.
My comment was about preferring other new, low-level languages over Rust when they don’t give the same safety guarantees. If you can deal with a GC then fine—my comment has got nothing to do with that.
So it was much, much more narrow than making a case for Rust in general.
Rust and GC both eliminate certain defects. And if you can use a GC then you don’t need Rust (w.r.t. memory safety).
Admittedly maybe I could have made it more clear that my comment does not make an argument against new low-level capable languages when used with some kind of automatic memory management scheme, like I guess Nim.
- If the language is not well known, that’s bad. It will be harder to hire a proficient team. More time will be spent on learning the language. - If the syntax is needlessly verbose, that’s bad. It increases the chance for typos and time spent fixing typos to get things to compile. Eventually it leads to ide completion based programming which results in the degradation of the skill set of the pool of programmers that know that language. - If the concepts the language uses are difficult to manipulate and remember, it takes more time to engage with any given piece of code - If you often need to drop into unsafe modes that’s also not great because now you effectively are using two languages not just one. The safe language and the unsafe language. they interact but play by totally different rules. yikes.
I think rust is a great language and the borrow checker is an amazing innovation. However I think rust has a lot of warts that will harm its success in the long run. I think the next language that leverages the borrow checker idea but does so with a bit better ergonomics will really take off.
I really like the safety guarantees that Rust provides and I want to keep enjoying them, but the language -- and more importantly, its ecosystem -- is moving from something that was relatively simple to a weird mish-mash of C++, JavaScript, and Haskell, and I'm keeping an eye out for a possible escape hatch.
Zig, Odin, or Hare are not on the same plane of existence as Rust when it comes to out-of-the-box safety (or, at the moment, out-of-the-box suitability for writing production-grade software), but they are simpler and intend to remain that way. That really jives with my values. Yes, this means that some of the complexity of writing software is pushed back onto me, the programmer, but I feel that I have a better shot at writing good software with a simple language than with a complex language where I only superficially understand the features.
That's exactly how I feel too. No matter how much I use Rust, I find it nearly impossible to claim I understand a lot of its features. Zig OTOH is basically what C would look like if designed today! The improvements it offers over C are very compelling to me... it remains to be seen if the lack of formal guarantees that Rust gives still makes Zig programs similarly as buggy as C programs, but my current impression is that Zig programs are going to be very far away from C's in terms of safety issues... the features the blog post mention go a long way.
A compiler cannot be a substitute for your brian. The ability to read code and think clearly about it is a massively important feature because humans at the end of the day are the ones who have to understand code and fix it.
It depends on the person. Programmers are different. Rust works great for some. To me it looks too much like C++ which is something I want to put behind. I know it is a different language but it has a lot of that same focus as C++ that leads to slow compilers and complex looking code.
If I was younger I might have put in the effort, but I am not willing to make the same wrong bet I did with C++. I sunk so much time into perfecting C++ skills and realizing afterwards when using other languages that it was a huge waste.
Knowing for sure that the code you write is at least memory safe is a certain kind of readability win and I don’t see how anyone can claim that it’s not.
What are your top complaints about C++? What parts/patterns are wasteful, and must be avoided?
I have encountered C++ a couple of times in my career. And both those times I was barely able to survive in the short periods of time I spent in those jobs. I'm pretty good at C, but for the life of me, I just can't deal with the hidden behaviours of C++.
So far, I have seen only one example of well-written, understandable C++ code: LLVM. I dabbled in learning LLVM for a side project, and the tutorials on writing compiler passes, and the LLVM's own code, seems to use only the rudimentary features of C++ (primarily, single-inheritance). And this absence of complex C++ features made me feel comfortable looking at, reading, and understanding the LLVM code.
I am of the firm belief now that good code can be written in any bad language, and bad code can be written in any good language. Perhaps those couple of times that I encountered difficult C++ codebases, they were just instances of bad code, and should not be used as an indictment of the C++ language.
Good code => readable, understandable, maintainable, extensible, rewritable. Bad code => !Good code.
Uh, what parts of LLVM were you reading? Because LLVM uses pretty much every C++14 feature allowed by their coding standard. The fact that you felt like no complex machinery was being used is actually a testament of the powers of abstraction in C++.
Using arenas required a bunch of lifetime annotations everywhere, but I was happy to do it if I could get provable memory safety.
I got everything working, but the moment I tried to wrap it in Python, it failed. The lifetime annotation on my struct was a problem. I tried to work around this by using ouroboros and the self-referencing struct pattern. But then I ran into another problem: the Rust arena I was using (Bumpalo) is not Sync, which means references to the arena are not Send. All of my arena-aware containers were storing references to the Arena and therefore were not Send, but wrapping in Python requires it to be Send. I wrote more about these challenges here: https://blog.reverberate.org/2021/12/19/arenas-and-rust.html
You might say "well don't use an arena, use Box, Rc, etc." But now you're telling me to write more complicated and less efficient code, just to make it work with Rust. That is a hard pill to swallow for what is supposed to be a systems language.
I faced the same problem as you. Somehow it felt like Arenas went beyond Rust's philosophy and added huge amounts of complexity to the interpreter.
Tree traversal and mutating the environment of a `block` internally was an issue I spent like 2-3 days on. I was porting Java code to Rust after all. Somehow got it working in a Rust way. I used unsafe at one place. But I was left heavily unsatisfied. Something about graphs/trees and Rust don't match up.
What I’m trying to say with all that, do not be afraid to use unsafes in Rust. It is part of the language for a reason. Sure, do use ARC for some non-performance critical whatever, because it frankly doesn’t really matter. But where it matters, and you decided to use a low-level language, then go for unsafe if that’s the only reasonable way. The result will still be much safer than the other low-level languages. I believe the problem here is the same what C++ tried to achieve: making people believe it is a high level language. That is just dishonest, and really should not be the goal of Rust.
You have the same restrictions in C++ except with worse consequences.
In C++ it is safe because the arena is only used from one thread at a time.
To model this C++ pattern in Rust, what I would really want is:
1. Arena should be Sync, and not use interior mutability.
2. Arena::alloc() should do a dual borrow: (a) a mut borrow of the Arena metadata, only for the duration of the alloc() call, and (b) a non-mut (shared) borrow of the Arena data.
Because this kind of split borrow cannot be expressed in Rust AFAICS, (2) is not possible, so (1) is not feasible. This forces Bumpalo to be !Sync, which makes a direct Rust port of the C++ pattern impossible.
I've heard this called the "factory problem" for Rust: you cannot easily make a factory type in Rust that returns references, because if the create() operation mutates the factory, then the returned reference will have a mutable borrow on the factory.
The alternative would be to make a truly thread-safe arena/factory, which could be Sync with interior mutability, but that is an efficiency compromise due to synchronization overhead.
https://uploads.peterme.net/nimsafe.html
Edit: noteworthy addendum: the ARC/ORC features have been released, so the footnote is now moot.
https://gist.github.com/pbackus/0e9c9d0c83cd7d3a46365c054129...
The only difference in BetterC is that you lose access to the GC, so you have to use RC if you want safe heap allocation.
Seems inaccurate to display rust as safe and not include what actually allows memory bugs to be found in public crates.
This is also a popular approach in games, especially ones with entity-component-system architectures.
I'm excited about Zig for these use cases especially, it can be a much easier approach with much less complexity than using a borrow checker.
I find that the design is more memory efficient because of these constraints, for example, our new storage engine can address 100 TiB of storage using only 1 GiB of RAM. Latency is predictable and gloriously smooth, and the system overall is much simpler and fun to program.
[1] “Let's Remix Distributed Database Design” https://www.youtube.com/channel/UC3TlyQ3h6lC_jSWust2leGg
This has also been my experience building a database in Zig. It's such a joy.
I’m a little confused by this statement. I assume by “address” you mean indexing, and the size of an index is related to the number of entries, not the amount of data being indexed. (For example, you could trivially address 100TiB using 1 address width of memory if all 100TiB belongs to the same key).
Thanks for the question!
What's in view here is an LSM-tree database storage engine. In general, these typically store keys between 8 and 32 bytes and values up to a few MiB.
In our case, the question is how much memory is required for the LSM-tree to be able to index 100 TiB worth of key/value storage, where:
* keys are between 8 and 32 bytes,
* values are between 8 and 128 bytes,
* keys and values are stored in tables up to 64 MiB,
* each table requires between 128 and 256 bytes of metadata to be kept in memory,
* auxiliary data structures such as a mutable/immutable table must be kept in memory, and where
* all memory required by the engine must be statically allocated.
That's alot of small keys and values!Typically, a storage system might require at least an order of magnitude more than 1 GiB of memory to keep track of that many keys using an LSM-tree as index, even using dynamic allocation, which only needs to allocate as needed.
Another way to think of this is as a filesystem, since it's a very similar problem. Imagine you stored 100 TiB worth of 4096 byte files in ZFS. How much RAM would that require for ZFS to be able to keep track of everything?
You do bring up a valid broader concern. Ironically, this is a reason that GC'd systems can sometimes be better for privacy than Ada or Rust which uses a lot more Vec+indexes. An index into a Vec<UserAccount> is riskier than a Java List<UserAccount>; a Java reference can never suddenly point to another user account like an index could.
But that aside, we're talking about memory safety, array-centric approaches in Zig and Rust can be appropriate for a lot of use cases.
There seems to be no practical difference here. Rust can do a reference to UserAccount, and Java can do an index into an ArrayList of UserAccounts. Or vice versa. As you wish.
The borrow checker, however, forces you to hold onto an index instead.
Programs often require inherent state with data that refers to other data. In these cases, one must circumvent the borrow checker, whether it be with indices, IDs, Rc, or whatever. The borrow checker simply does not allow changing data when someone has a reference to it (except for the rare case where we can use Cell).
It's a myth that we can rewrite any program to not circumvent the borrow checker.
There are dedicated data structures for this [1] that will not let you access another item by mistake.
One of my favorite alternatives is generational_arena [0] which also happens to be the library that inspired Vale's generational references!
[0] https://docs.rs/generational-arena/latest/generational_arena...
Imagine all variables in your program declared as static. This includes all buffers (with indexes instead of pointers), all nested structures, etc.
It doesn't, however, prevent you from accidentally scribbling over your own memory (buffer overflow, for example) or from scribbling over someone else's memory.
The array-centric approach is indeed more applicable at the high levels of the program.
Sometimes I wonder if a language could use an array-centric approach at the high levels, and then an arena-based approach for all temporary memory. Elucent experimented with something like this for Basil once [1] which was fascinating.
First off, thank you for posting all your great articles on Vale!
Second off, I just read the generational references blog post for the 3rd time and now it makes complete sense, like stupid obvious why did I have problems understanding this before sense. (PS: The link to the benchmarks is dead :( )
I hope some of the novel ideas in Vale make it out to the programming language world at large!
I'm pretty excited about all the memory safety advances languages have made in the last few years. Zig is doing some really interesting things (see Andrew's thread above), D's new static analysis for zero-cost memory safety hit the front page yesterday, we're currently prototyping Vale's region borrow checker, and it feels like the space is really exploding. Good time to be alive!
This means we lose thread safety and functions become non-reentrant (but easy to prove safe - make sure graph of A-calls-B is a acyclical).
How frequently does this happen in real software? I learned not to return pointers to stack allocated variables when I was 12 years old.
> There's no way around having a proper lifetime system, or a GC, if you want memory safety.
If you're building an HTTP caching program where you know the expiration times of objects, a Rust-style borrow-checker or garbage collector is not helping anyone.
So, if you slip while walking today, does that mean you didn't learn to walk when you were one year old?
> How frequently does this happen in real software? I learned not to return pointers to stack allocated variables when I was 12 years old.
This happens rarely. However, the reason it isn't an issue is because C programmers are (and have to be) extremely paranoid about this kind of thing.
Rust, however, lets you recklessly pass around pointers to local variables while guaranteeing that you won't accidentally use one as a return value. One example is scoped thread pools which let you spawn a bunch of worker threads and then pass them pointers to stack allocated variables that get concurrently accessed by all the threads. The Rust type system/borrow checker ensures both thread safety and memory safety.
Would you trust a novice C programmer to use something like that?
What rust does sounds like a good idea in theory. In practice it rejects too many valid programs, over-complicates the language, and makes me feel like a circus animal being trained to jump through hoops. Zigs solution is hands down better for actually getting work done, plus it's so dead simple to use arena allocation and fixed buffers that you're likely allocating a lot less in the first place.
Rust tries to make allocation implicit, leaving you confused when it detects an error. Zig makes memory management explicit but gives you amazing tools to deal with it - I have a much clearer mental model in my head of what goes on.
Full disclaimer, I'm pretty bad at systems programming. Zig is the only one I've used where I didn't feel like memory management was a massive headache.
Rust has seen significant usage in large companies; they wouldn't be using it unless it was usable for "real work".
>Full disclaimer, I'm pretty bad at systems programming. Zig is the only one I've used where I didn't feel like memory management was a massive headache.
I'd say this about Rust, though. Rust's mental model is very straightforward if you accept the borrow-checker and stop fighting it. Can you list any examples of what you think is a headache...?
>In practice it rejects too many valid programs, over-complicates the language, and makes me feel like a circus animal being trained to jump through hoops.
I've found that jumping through those hoops leads to things running in production that don't make me get up in the middle of the night. Can you show me a "valid program" that Rust rejects?
I didn't say it wasn't usable. I said I found Zig more usable.
I'd say this about Rust, though. Rust's mental model is very straightforward if you accept the borrow-checker and stop fighting it. Can you list any examples of what you think is a headache...?
Mate, I didn't start learning rust in order to wage war against the borrow checker. I had no idea what the hell it wanted a lot of the time. Each time I fixed an error I thought I got it, and each time I was wrong. The grind got boring.
As for specific examples no, I've tried to put rust out of my mind. I certainly can't remember specific issues from 3 months ago.
I've found that jumping through those hoops leads to things running in production that don't make me get up in the middle of the night. Can you show me a "valid program" that Rust rejects?
Yeah that's how rust as sold, the compiler is your friend and stuff will compile and it will never fail.
In reality the compiler was so irritating I hardly got anything done at all. The output wasn't super reliable software, it was no software.
The turning point came for me when talking to someone using rust pretty much since it first came out. He basically said "yeah, I still don't know why it doesn't compile either sometimes". He's smarter than me, and all he's managed to do in years of learning it is have a knack for fixing errors without understanding them.
I need some brain capacity left over for actually solving a problem.
Yet there’s no syntax to indicate this. Worse, actual copies are hidden behind a trait that you have no way of knowing whether a particular external lay defined type implements it or not outside of reading documentation. A lot of Rust’s important mechanics are underrepresented syntactically, which makes the language harder to get used to imo. I agree with the parent that in general it’s better for things to be obvious as you’re writing them—if rust had syntax that screamed “you’re moving this thing” or “you’re copying this thing because it implements copy” that’d be a lot easier to get used to than what beginners are currently stuck with which is a cycle of “get used to the invisible semantics by having the compiler yell at you at build time until you’ve drilled it into your head past the years of accumulated contrary models” and oh, as soon as you have to use another language this model becomes useless, so expertise in it does not translate to other domains (though that will hopefully change in the future)
We had to introduce Rust at work because we really needed some WASM functionality in one of our mostly JS frontends... anyway, I was excited about Rust and all and pushed the idea, implemented the whole thing and made presentations for other developers about Rust. I was thinking everyone would be as excited as I was and would jump at the chance of maintaining the Rust module.
In reality , only one of the 20+ devs even tried to ever touch the Rust code. Everyone else thought the code looked like Greek (no offence to my greek friends!) ... today when the code needs change, I am pretty much the only one who can do it, or the other guy (who is more novice than me in Rust so takes a lot longer to do anything, but at least there's someone else).
For reference: we write code in Java/Kotlin/Groovy/Erlang. So, we're not a system programming shop in any way, so I can't speak for places where C and C++ were previously being used.
(Legit point, no snark)
#[derive(Debug)]
struct Foo {
a: i32
}
fn thing(foo: &mut Foo) {
match foo {
f @ Foo { a } if *a > 5 => {
println!("{:?}", f)
}
_ => {}
}
}
There's no reason it should reject that, as the use of the `a` reference doesn't interleave with the use of `f`.The trivial example fix is just to... ensure it can copy, and tweak the match line:
#[derive(Copy, Clone, Debug)]
struct Foo {
a: i32
}
fn thing(foo: &mut Foo) {
match *foo {
f @ Foo { a } if a > 5 => {
println!("{:?}", f)
}
_ => {}
}
}
fn main() {
let mut x = Foo { a: 1 };
thing(&mut x);
}
If the struct was bigger and/or had types that couldn't copy, I'd refrain from trying to shoehorn matching like that entirely. #[derive(Debug)]
pub struct Foo {
a: i32
}
impl Foo {
fn get_a(&mut self) -> &mut i32 {
&mut self.a
}
}
pub fn thing(mut foo: &mut Foo) {
let f = &mut foo;
let a = f.get_a();
if *a > 5 {
println!("{:?}", f);
}
}
That's why I was so surprised the compiler rejected it.One thought:
> Never calling free (practical for many embedded programs, some command-line utilities, compilers etc)
This works well for compilers and embedded systems, but please don't do it command-line tools that are meant to be scripted against! It would be very frustrating (and a violation of the pipeline spirit) to have a tool that works well for `N` independent lines of input but not `N + 1` lines.
An example would be a generous rolling buffer, with enough room for the data you're working on. Most tools which are working on a stream of data don't require much memory, they're either doing a peephole transformation or building up data with filtration and aggregation, or some combination.
You can't have a use-after-free bug if you never call free, treating the OS as your garbage collector for memory (not other resources please) is fine.
I.e. we can choose to risk running out of memory? I don’t understand how this is a viable strategy unless you know you only will process a certain input size.
Race conditions are a big ugly can of worms whose exploitability could probably be the basis for a long, tedious debate.
When people talk about Zig being unsafe, they're mostly reacting to the fact that UAFs are still viable in it.
As you know, buffer bleeds like Heartbleed and Cloudbleed can happen even in a memory safe language, they're hard to defend against (padding is everywhere in most formats!), easier to pull off than a UAF, often remotely accessible, difficult to detect, remain latent for a long time, and the impact is devastating. All your RAM are belong to us.
For me, this can of worms is the one that sits on top of the dusty shelf, it gets the least attention, and memory safe languages can be all the more vulnerable as they lull one into a false sense of safety.
It caught different banking/investment systems written in memory safe languages leaking server RAM. You could sometimes see the whole intranet web page, that the teller or broker used to generate and send the statement, leaking through.
Bleeds terrify me, no matter the language. The thing with bleeds is that they're as simple as a buffer underflow, or forgetting to zero padding. Not even the borrow checker can provide safety against that.
The tool is called Pure [1]. It was originally written in JavaScript and open-sourced, then rewritten for Microsoft in C at their request for performance (running sandboxed) after it also detected David Fifield's “A Better Zip Bomb” as a zero day.
I'd love to rewrite it in Zig to benefit from the checked arithmetic, explicit control flow and spatial safety—there are no temporal issues for this domain since it's all run-to-completion single-threaded.
Got to admit I'm a little embarrassed it's still in C!
I say this as someone who is doing a lot of unsafe for graphics programming - I want to be able to pass a buffer to a shader without necessarily having zeroed out all the memory, in the common case I'm only using some of that buffer to store the scene data etc. I have a safe-ish abstraction for this (BufWriter in piet-gpu, for the curious), but it's still possible for unsafe shaders to do bad things.
For example, another way to think of this is that you have a buffer of initialized memory, containing a view onto some piece of data, from which you serve a subset to the user, but you get the format of the subset wrong, so that parts of the view leak through. That's a bleed.
Depending on the context, the bleed may be enough or it might be less severe, but the slightest semantic gap can be chained and built up into something major. Even if it takes 6 chained hoops to jump through, that's a low bar for a determined attacker.
If there's full initialization then this is just a logic error, no? Apart from some kind of capability typing over ranges of bytes (not very ergonomic), this would be a very difficult subtype of "bleed" to statically describe, much less prevent.
I think you mentioned WUFFS before the edit; I find that approach very promising!
The SafeDocs program and approach looks incredible. Installing tools like this at border gateways for SMTP servers, or as a front line defense before vulnerable AV engine parsers (as Pure is intended to be used), could make such a massive dent against malware and zero days.
"I would consider that type of logic error more or less impossible to defend at the language level, but I can see how analysis tools can be helpful."
This is how I see it too, as a logic error that exposes sensitive memory in a way that can be as unsafe as a UAF. It's a great leveler of languages, and why I get excited about enabling checked arithmetic by default in safe builds, explicit control flow, and minimizing complexity, abstractions and dependencies—this all helps to reduce the probability of semantic gaps and leaks.
Uninitialised memory is considered unsafe. There aren't any loopholes even for "just bytes". You can't accidentally make an uninitialised buffer, and you can't expose one without explicit `unsafe{}`.
Sych unsafe code is typically wrapped in safe interfaces, so the risk is limited to implementation side, and not spread to every usage site.
For filling buffers the language pushes towards use of growable Vec with capacity that tracks how much has been initialised, or to collect straight from an iterator, which is foolproof.
Custom buffers are not even used that often, because things like zip writing or compression use io::Write streaming interface.
let mut buf = vec![0; DEFAULT_BUFFER_SIZE];
let database_frame_len = database_socket.read(&mut buf)?; // use 1
let database_result = Database::parse(&buf[..database_frame_len])?;
let html_template = HtmlTemplate::new(database_result);
let html_len = html_template.write(&mut buf)?; // use 2
socket.write(&buf[..html_len]);
The above code makes the tenuous assumption that HtmlTemplate::write actually clobbers everything it's supposed to write. But it doesn't even require unsafe code for it to not do that, because the underlying buffer is entirely initialized according to the type system.The only advice I can really give to avoid this kind of bug is: don't reuse buffers unless it's actually a bottleneck.
html_template.pipe_to(socket);
and there's a foolproof io::BufWriter adapter if you need these writes buffered.Is it possible, in principle, to use comptime to obtain Rust-like safety? If this was a library, could it be extended to provide even stronger guarantees at compile time, as in a dependent type system used for formal verification?
Of course, this does not preclude a similar approach in Rust or C++ or other languages; but comptime's simplicity and generality seem like they might be beneficial here.
https://github.com/DutchGhost/zorrow
It's not a complete solution, among other things, because it only works if you use it to access variables, as the language has no way of forcing you.
Researchers have been working on these three things for decades. Yes, “comptime” isn’t some Zig invention but a somewhat limited (and anachronistic to a degree) version of what researchers have added to research versions of ML and Ocaml. So can it implement all the static language goodies of Rust and give you dependent types? Sure, why not? After all, computer scientists never had the idea that you can evaluate values and types at compile-time. Now all those research papers about static programming language design will wither on their roots now that people can just use the simplicity and generality of `comptime` to prove programs correct.
Larger allocations will be page-aligned, but if you make a bunch of very small allocations, they may go into the same pages, and freeing all but one per page of them may leave you with the pages still mapped. I've skimmed the GeneralPurposeAllocator code and know it has this sort of behavior at least sometimes, but I'm not really familiar with which things change in safe builds.
So I suppose the "use after free" protection is "best effort": it will try to trap such use by unmapping the page, but there's no guarantee that it will definitely be the case.
I suppose that's ok, since use after free is a bug anyway. We want to catch the bugs and protect against data corruption, but the programmer is still on the hook for not making the bugs in the first place, so if the protection does miss one, well, at least it tried -- its just an extra layer. It would certainly be wasteful if allocating a few bytes uses up an entire page, just so it can be unmapped when the memory is freed.
I closed the window right there. Digs like this (the "not recommended" bit is a link to a now famous bomb thrown by Szabolcs on the oss-sec list, not to any kind of industry consensus piece) tell me that the author is grinding an axe and not taking the subject seriously.
Security is a spectrum. There are no silver bullets. It's OK to say something like "Rust is better than Zig+ASan because", it's quite another to refuse to even treat the comparison and pretend that hardening tools don't exist.
This is fundamentally a strawman, basically. The author wants to argue against a crippled toolchain that is easier to beat instead of one that gets used in practice.
> I'm also focusing on software as it is typically shipped, ignoring eg bounds checking compilers like tcc or quarantining allocators like hardened_malloc which are rarely used because of the performance overhead.
To advertize that Zig is perfectly safe because things like ASan exist would be misleading, because that's not what users get out of the box. Zig is up-front and honest about the tradeoffs between safety and performance, and this evaluation of Zig doesn't give any surprises if you're familiar with how Zig describes itself.
Exactly! And for the same reason. You frame your comparison within the bounds of techniques that are used in practice. You don't refuse to compare a tool ahead of time, especially when doing so reinforces your priors.
To be blunt: ASan is great. ASan finds bugs. Everyone should use ASan. Everyone should advocate for ASan. But doing that cuts against the point the author is making (which is basically the same maximalist Rust screed we've all heard again and again), so... he skipped it. That's not good faith comparison, it's spin.
Well, is ASan used in practice, by the relevant target audience (i.e. mainstream C++ developers)? My guess is that the vast majority of the people both Rust and Zig are aiming for are people who don't use ASan with C++ today and wouldn't use ASan with Rust or Zig if they switched to them.
Rust did it but they had to rethink the whole problem from the ground up. Rust is safe but that safety had quite the learning cost as compared to, say, Zig or Go. The good thing about this is that you can't use many of the bad practices from other languages that have become habits.
But Zig is still in beta/alpha stage so let's see how they increase the overall safety in the coming months/years. My experience with Zig has left me quite satisfied, especially the comptime features but the explicitness sometimes gets in the way of readability.
I can believe that abstractions prevent errors, but can't a language allow footguns but have syntax that makes them easy to detect?
Rust is not only or even primarily used in embedded or driver programming. Indeed, I see more user space software built in rust than in embedded nowadays. As for Zig, it's so new and alpha that it didn't even find a niche yet. Rust also hasn't yet found a specific niche. It's all over the place in GUI, networking, embedded etc. which isn't a bad thing, to be clear.
It might be harder to do embedded in Go but that's not the point here, is it? It's about safety. Go has a special place in that it is compiled, easy to learn, quite performant for most tasks, and safe to boot.
> I can believe that abstractions prevent errors, but can't a language allow footguns but have syntax that makes them easy to detect?
What would be the point though? If a language can detect footguns, it's time to prevent them which is what Rust does, essentially. Footguns are rarely useful and there's always an alternative way. For these rare cases, Rust includes the unsafe escape hatch but then all bets are off. Don't expect the compiler to help you if you are intent on going down that road.
https://www.withsecure.com/en/solutions/innovative-security-...
About detecting footguns, I was honestly asking, I thought maybe you could have a borrow checker, but none of the zero cost abstractions that rust uses to make it mostly tolerable. I'm not saying I want to use that language, but I do like explicit languages if they aren't too verbose. Go is a good example of that I think, until you get into the abstractions around parallelism.
Here's some data: https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
Ignoring the programs using x86 intrinsics to do vectorized math, the top-performing Rust, Go, Java, and C# programs are all written in a simple, straightforward style; each is practically a direct translation from the other. The Rust program is fastest, but the others come in at 1.6x, 1.7x, and 1.7x the Rust program, respectively. These are not significant differences for the vast majority of applications.
Of course this is one artificial benchmark, and on that website you will find others where the Java, C#, or Go program will do especially well or especially poorly. But it's clear that those three are all roughly in the same performance class..
As-in "Notice which box plot IQRs overlap."
https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
And since you speak of Java, companies like PTC, Aicas, microEJ, ExelsiorJET (now gone) have been happily doing business on embedded, with bare metal or RTOS based deployments.
And then there is Meadow/Netduino on .NET world, and Astrobe for Oberon as commercial products as well. Astrobe has been in business for 20 years now.
All the runtime tooling it offers, already exists for C and C++ for at least 30 years, going back to stuff like Purify (1992).
"How Safe Is Zig?": https://news.ycombinator.com/item?id=26537693
Simple compiler probably can. Most complex probably cannot (I don't want to imagine a Rust compiler without freeing memory: it has 7 layers of lowering (source code->tokens->ast->HIR->THIR->MIR->monomorphized MIR, excluding the final LLVM IR) and also allocates a lot while type-checking or borrow-checking).
What is most interesting to me is the average compiler. Does somebody have statistics on the average amount compilers allocate and free?
I worked on, one of the many, Microsoft compiler teams, though as a software engineer in test not directly on the compiler itself, and I believe the lead dev told me they don't free any memory, though I could be misremembering since it was my first job out of college.
Remember C compilers are often one file at a time (and a LOLWTF # of includes), and the majority of work goes into making a single output file, and then you are done. Freeing memory would just take time, better to just hand it all back to the OS.
Also compilers are obsessed with correctness, generating incorrect code is to be avoided at all costs. Dealing with memory management is just one more place where things can go wrong. So why bother?
I do remember running out of memory using link time code gen though, back when everything was 32bit.
Related, I miss the insane dedication to quality that team had. Every single bug had a regression test created for it. We had regression tests 10-15 years old that would find a bug that would have otherwise slipped through. It was a great way to start my career off, just sad I haven't seen testing done at that level since then!
If you wrote a compiler in a GCd language, you'd want to disable the collector because that just takes time, and compilers are slow enough as it is!
Also, most of the work will be done in parallel, and I really wouldn't put aside that a generational GC's improved cache effect (moving still used objects close) might even improve performance (all other things being equal, but they are never of course). All in all, do not assume that just because a runtime has a GC it will necessarily be slower, that's a myth.
Compilers in particular are usually easy to have rigorous regression testing for. Investigating any issue usually forces you to produce a minimal repro because of how complicated a compiler is. Also, all the inputs and outputs are known. Then it's just a one tiny extra step of putting that all together into a checked-in test.
Rustc is written entirely in Rust, so I would assume that it doesn't do that.
//! The arena, a fast but limited type of allocator.
//!
//! Arenas are a type of allocator that destroy the objects within, all at
//! once, once the arena itself is destroyed. They do not support deallocation
//! of individual objects while the arena itself is still alive. The benefit
//! of an arena is very fast allocation; just a pointer bump.
The other thing (not specifically mentioned in this comment, but mentioned elsewhere, and important to understanding why it work the way it does) is that if everything in the arena gets freed at once, it implies that you can soundly treat everything in the arena as having exactly the same lifetime.You can see an example of how every ty::Ty<'tcx> in rustc winds up with the same lifetime, and an entry point for understanding it more, here in the dev guide: https://rustc-dev-guide.rust-lang.org/memory.html
However, arena allocation doesn't cover all of the dynamic allocation in rustc. Rustc uses a mixed strategy: there's both RAII and lots of arena allocated context that only gets destroyed at the end of a particular phase.
If a compiler has to be run multiple times in the same process, it may use an area allocator to track all memory, so you can free it all in one go once you're done with compilation. Delaying all freeing until the end effectively eliminates temporal memory issues.
The never-free pattern isn't just for compilers of course, it's also been used in missile-guidance code.
[0] https://web.archive.org/web/20190126213344/https://www.drdob...
https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html#...
So it seems that null-pointer dereferences and integer overflows can be checked at runtime in C. Besides, there should be production-ready C compilers that offer bounds checking.
The best is to use C++ instead, with bounds checked library types.
clang and gcc will both tell you at runtime if you go out of bounds, have an integer overflow, use after free etc. You need to turn on the sanitizer. You can't have them all on at the same time because code will be unnecessarily slow (ex: having thread sanitizer on in a single threaded app is pointless)
Sanitizers are great; I love sanitizers. But you can't run them in production without a significant performance hit, and that's where they're needed most. I don't believe this post blows that problem out of proportion, and is correct in noting that we can solve it without runtime instrumentation and overhead.
Yes, the "just-enable-the-compiler-flags" approach can be expensive, but the tools exist to allow most people to be sanitizing most of the time. Devs simply don't know what's available to them.
But let's say, for the sake of argument, that I can tolerate programs that run twice as long in production. This doesn't improve much:
* I'm not going to be deploying SoTA sanitizers (SANRAZOR is currently a research artifact; it's not available in mainline LLVM as far as I can tell.)
* No sanitizer that I know of guarantees that execution corresponds to memory safety. ASan famously won't detect reads of uninitialized memory (MSan will, but you can't use both at the same time), and it similarly won't detect layout-adjacent overreads/writes.
That's a lot of words to say that I think sanitizers are great, but they're not a meaningful alternative to actual memory safety. Not when I can have my cake and eat it too.
But in general, sanitizers are also something you can do to legacy code to bring it closer to safety and you can turn them off for production if you absolutely, definitely need those last few percent (which few people do). It's hard to overstate how valuable all of that is. A big part of the appeal of zig is its interoperability with C and the ability to introduce it gradually. Compare to the horrible contortions you have to do with CFFI to call Python from C.
For a particular simulation on a particular Cascade Lake chip, mitigations collectively cause it to run about 30% slower. So I won't scream about 1%, but that's a lot of 1%s.
What people? and in my experience rust has always been much higher than 2% regression
Embedded land, everything is a flat memory map, odds are malloc isn't used at all, memory is possibly 0'd on boot.
It is perfectly valid to just start walking all over memory. You have a bunch of #defines with known memory addresses in them and you can just index from there.
Fun fact: Microsoft Band writes crash dumps to a known location in SRAM and because SRAM doesn't instantly lose its contents on reboot, after a crash the runtime checks for crash dump data at that known address and if present would upload the crash dump to servers for analysis and then 0 out that memory.[1]
Embedded rocks!
[1] There is a bit more to it to ensure we aren't just reading random data after along power off, but I wasn't part of the design, I just benefited from a 256KB RAM wearable having crash dumps that we could download debugging symbols for.
How is it an exaggeration when he explicitly called this out?
I also never heard of a data race existing without a race condition existing. That's a pointless metric like many of the above I mentioned
Is the Project Zero team just too lazy to remind Chromium to use sanitizers?
(Also Rust defenders please note I'm not saying Rust can't work at that scale. I'm just saying, it's a very big scale and I think it's an open problem. My personal opinion and gut say yes, it shouldn't be any worse than it has to be because of the sheer size (that is, the essential complexity is pretty significant no matter what you do), but I don't know that.)
There would absolutely be issues, including security issues. But there is also very good evidence that the issues that are most exploited in browsers and operating systems relate to memory safety. Alex Gaynor's piece that the author linked is good on this point.
While securing Chromium is huge and a difficult task, it and consumer operating systems are crucial for individual security. Until browsers and consumer operating systems are secure, individuals ranging from persecuted political dissidents to Jeff Bezos won't be secure.
* Actually not sure why I said Chromium rather than Chrome. Nothing hangs on the distinction, afaict.
I don't think this does much for your initial claim. Take the most generous reading you can--Rust isn't any better at preventing UAF than C/C++. That doesn't make safe C/C++ a thing, it means that Rust isn't an appropriate solution.
Writing unsafe code and removing tools "because production" gets you unsafe code as shown in that rust cve
Lazy C++ is unsafe, lazy Zig is safe-ish, lazy Rust is safe. Given how lazy most programmers are, I consider that a strong argument against C++.
Maybe I'm missing something here?
Yeah, so clang and gcc don't actually tell you at runtime if you go out of bounds. How many program ship production binaries with asan or ubsan enabled, to say nothing of msan or tsan?
Also you can't have them all on at the same time because they're not necessarily compatible with one another[0], you literally can't run with both asan and msan, or asan and tsan.