What memory model should Rust use?(paulmck.livejournal.com)
paulmck.livejournal.com
What memory model should Rust use?
https://paulmck.livejournal.com/66175.html
156 comments
> The purpose of the unsafe keyword is to mark code that could cause safety issues (undefined behavior) if its expectations about the surrounding code are not met.
That is one use of the `unsafe` keyword. You see this version in the qualifiers for unsafe functions, where safety depends on the inputs and other context. It means that the marked function cannot be called from safe code. The `unsafe` keyword is also used to mark code blocks where the content of the block must be manually proved to be safe, even for valid inputs—this includes calls to unsafe functions, dereferencing raw pointers, etc. (Unsafe functions use this mode by default.)
The same keyword is also used to mark unsafe traits and their implementations, where an incorrect implementation of the trait—even the members which are not themselves marked as `unsafe` and thus can be called from safe code—can impact the safety of other parts of the program (via unsafe code blocks or unsafe functions). An example of this would be a trait with a member which returns a raw pointer (itself a safe operation) which is then called from within an `unsafe` block and dereferenced.
That is one use of the `unsafe` keyword. You see this version in the qualifiers for unsafe functions, where safety depends on the inputs and other context. It means that the marked function cannot be called from safe code. The `unsafe` keyword is also used to mark code blocks where the content of the block must be manually proved to be safe, even for valid inputs—this includes calls to unsafe functions, dereferencing raw pointers, etc. (Unsafe functions use this mode by default.)
The same keyword is also used to mark unsafe traits and their implementations, where an incorrect implementation of the trait—even the members which are not themselves marked as `unsafe` and thus can be called from safe code—can impact the safety of other parts of the program (via unsafe code blocks or unsafe functions). An example of this would be a trait with a member which returns a raw pointer (itself a safe operation) which is then called from within an `unsafe` block and dereferenced.
And another quite different thing people do with "unsafe" is label code that is, from Rust's point of view, entirely safe, but which has important practical problems they want its callers to pay attention to.
Perhaps openHellPortal is complicated enough that you feel it should be a separate function. On the other hand, you want to be clear that nobody should expect openHellPortal to check the necessary precaution are in place. So, mark openHellPortal unsafe and provide a safe wrapper SafeHellPortal which internally calls openHellPortal after checking some necessary precautions are in place and ensures closeHellPortal is called when appropriate too.
As far as I know nobody builds hell portals with Rust (You have to assume there'd be a HN post about it right?) but they do plenty of embedded systems development, and those people use unsafe this way. Twiddling the SOC's interrupt controller is nothing to Rust itself, but the people writing the interrupt controller twiddling code can mark it "unsafe" and explain what callers must do to avoid chaos. Then callers can obey those instructions and make a safe wrapper for a particular purpose e.g. "SerialPort", and the average developer needn't even know that under the hood randomly twiddling the interrupt controller causes everything to catch fire, because they're using a safe SerialPort abstraction which can't cause that even if they hold it wrong.
Perhaps openHellPortal is complicated enough that you feel it should be a separate function. On the other hand, you want to be clear that nobody should expect openHellPortal to check the necessary precaution are in place. So, mark openHellPortal unsafe and provide a safe wrapper SafeHellPortal which internally calls openHellPortal after checking some necessary precautions are in place and ensures closeHellPortal is called when appropriate too.
As far as I know nobody builds hell portals with Rust (You have to assume there'd be a HN post about it right?) but they do plenty of embedded systems development, and those people use unsafe this way. Twiddling the SOC's interrupt controller is nothing to Rust itself, but the people writing the interrupt controller twiddling code can mark it "unsafe" and explain what callers must do to avoid chaos. Then callers can obey those instructions and make a safe wrapper for a particular purpose e.g. "SerialPort", and the average developer needn't even know that under the hood randomly twiddling the interrupt controller causes everything to catch fire, because they're using a safe SerialPort abstraction which can't cause that even if they hold it wrong.
Having "unsafe" mean "can wreak havoc if used incorrectly" is fine, as long as it's clear what constitutes incorrect usage, and what the behavior of correct usages is. And it does not have to be clearly defined what "havoc" is (just like undefined behavior can cause any behavior).
My issue is with using "unsafe" to mean "watch out, you might be using this incorrectly, but no one even knows the exact rules that you should stick to to make sure your code is correct."
Of course, having it be in the "unsafe" part of Rust is better than having it be in safe Rust. But ideally you would be able to look at any part of a Rust program and be able to tell what it does, without having to think in terms of specific compiler optimizations or hardware details. Otherwise formal verification of concurrent algorithms and data structures becomes hopeless.
My issue is with using "unsafe" to mean "watch out, you might be using this incorrectly, but no one even knows the exact rules that you should stick to to make sure your code is correct."
Of course, having it be in the "unsafe" part of Rust is better than having it be in safe Rust. But ideally you would be able to look at any part of a Rust program and be able to tell what it does, without having to think in terms of specific compiler optimizations or hardware details. Otherwise formal verification of concurrent algorithms and data structures becomes hopeless.
So many thoughts.
* Rust threads run on CPU cores, but also GPU cores, FPGA cores, and a wider range of nastier hardware than Linux kernel threads run on.
* The author implies throughout the text that safe Rust operations might not be allowed in unsafe Rust. This hints at a deep misunderstanding of how Rust works.
* C, C++, and similar languages, do not have as a goal to be "sound". Soundness is, however, core to Rust's value proposition.
* C, C++, and similar languages, do not have the goal that undefined behavior must always trap in their abstract machines. But Rust does.
* C++ and similar languages do not have the goal of forever-backwards-compatibility (e.g. C++ breaks backwardward compatibility on almost every new standard release). Rust, however, guarantees that all programs written for Rust 1.0 in 2015 that have no undefined behavior will build and run correctly _forever_.
* Rust has many compiler backends, e.g., LLVM-IR, GCC, Cranelift, SPIR-V, etc. Rust code is translated to the IR of those toolchains. That is, those IRs would need to be extended with the new semantics as well to be useful.
> But again, this decision rests not with me, but with the Rust communty.
The Rust community needs people working on formalizing the memory model further. So if they are interested, they should jump right in.
They should probably start by understanding Rust as is today, learning about unsafe code, and well the blog post cites https://plv.mpi-sws.org/rustbelt/rbrlx/ , but it is clear from the exposition that the author has not deeply understood that paper, so maybe they should also take a deeper look at that. All the proofs are open-source. So if they think they can extend the soundness proofs with new features that are also sound, that could be a place to start.
* Rust threads run on CPU cores, but also GPU cores, FPGA cores, and a wider range of nastier hardware than Linux kernel threads run on.
* The author implies throughout the text that safe Rust operations might not be allowed in unsafe Rust. This hints at a deep misunderstanding of how Rust works.
* C, C++, and similar languages, do not have as a goal to be "sound". Soundness is, however, core to Rust's value proposition.
* C, C++, and similar languages, do not have the goal that undefined behavior must always trap in their abstract machines. But Rust does.
* C++ and similar languages do not have the goal of forever-backwards-compatibility (e.g. C++ breaks backwardward compatibility on almost every new standard release). Rust, however, guarantees that all programs written for Rust 1.0 in 2015 that have no undefined behavior will build and run correctly _forever_.
* Rust has many compiler backends, e.g., LLVM-IR, GCC, Cranelift, SPIR-V, etc. Rust code is translated to the IR of those toolchains. That is, those IRs would need to be extended with the new semantics as well to be useful.
> But again, this decision rests not with me, but with the Rust communty.
The Rust community needs people working on formalizing the memory model further. So if they are interested, they should jump right in.
They should probably start by understanding Rust as is today, learning about unsafe code, and well the blog post cites https://plv.mpi-sws.org/rustbelt/rbrlx/ , but it is clear from the exposition that the author has not deeply understood that paper, so maybe they should also take a deeper look at that. All the proofs are open-source. So if they think they can extend the soundness proofs with new features that are also sound, that could be a place to start.
> * C, C++, and similar languages, do not have the goal that undefined behavior must always trap in their abstract machines. But Rust does.
What do you mean ?
What do you mean ?
To keep Rust's safety model, anything that is UB should only be allowed in unsafe code blocks.
However to be 100% sure of what can be considered safe, it needs to be the same regardless of what hardware is being targeted.
That is how C got its UB in first place, WG14 didn't want to rule out any kind of hardware that was possible to target so UB it was.
Other memory safe systems programming languages have gone through other path and were willing to add abstractions to their runtimes instead, ensuring that the memory model remains consistent to the developers.
Naturally in languages that want to be zero cost abstraction, that solution is not welcomed.
However to be 100% sure of what can be considered safe, it needs to be the same regardless of what hardware is being targeted.
That is how C got its UB in first place, WG14 didn't want to rule out any kind of hardware that was possible to target so UB it was.
Other memory safe systems programming languages have gone through other path and were willing to add abstractions to their runtimes instead, ensuring that the memory model remains consistent to the developers.
Naturally in languages that want to be zero cost abstraction, that solution is not welcomed.
Guessing safety guarantee's?
https://doc.rust-lang.org/reference/behavior-considered-unde...
https://doc.rust-lang.org/reference/behavior-considered-unde...
The implementation of the Rust abstract machine - miri - stops execution of Rust programs when they exhibit undefined behavior.
C, C++, etc. don't even have implementations of their abstract machines. They don't have one existing as a goal. And they are happy to make certain operations exhibit undefined behavior even if that implies that it would make an implementation of the abstract machine that traps impossible.
This is why even if you were to combine valgrind with address sanitizer, memory sanitizer, thread sanitizer, undefined-behavior-sanitizer, and other existing C and C++ tools, there is still a lot of classes of undefined behavior that these tools can't detect.
That's fine in C and C++, but not fine in Rust. In Rust, if we add a new type of undefined behavior, the constraint is that it should be (demonstrably) possible to extend miri to detect it, such that if a user doesn't know whether some program exhibits undefined behavior for some inputs, they can just run it under miri, and miri will precisely pinpoint which part of their code exhibited undefined behavior and why, and how their program execution got there.
C, C++, etc. don't even have implementations of their abstract machines. They don't have one existing as a goal. And they are happy to make certain operations exhibit undefined behavior even if that implies that it would make an implementation of the abstract machine that traps impossible.
This is why even if you were to combine valgrind with address sanitizer, memory sanitizer, thread sanitizer, undefined-behavior-sanitizer, and other existing C and C++ tools, there is still a lot of classes of undefined behavior that these tools can't detect.
That's fine in C and C++, but not fine in Rust. In Rust, if we add a new type of undefined behavior, the constraint is that it should be (demonstrably) possible to extend miri to detect it, such that if a user doesn't know whether some program exhibits undefined behavior for some inputs, they can just run it under miri, and miri will precisely pinpoint which part of their code exhibited undefined behavior and why, and how their program execution got there.
Thanks!
> This is why even if you were to combine valgrind with address sanitizer, memory sanitizer, thread sanitizer, undefined-behavior-sanitizer, and other existing C and C++ tools, there is still a lot of classes of undefined behavior that these tools can't detect.
Do you have specific examples of such UB?
> This is why even if you were to combine valgrind with address sanitizer, memory sanitizer, thread sanitizer, undefined-behavior-sanitizer, and other existing C and C++ tools, there is still a lot of classes of undefined behavior that these tools can't detect.
Do you have specific examples of such UB?
Sure: TBAA, access to invalid union field (in C++), etc.
Many of them are impossible in theory to check, and many are impossible to efficiently check in practice.
Many of them are impossible in theory to check, and many are impossible to efficiently check in practice.
I am woefully under-informed about the LKMM, but one useful comment I can contribute to this discussion is that the recommended strategy here would be extremely difficult to implement, because you can use both the relaxed and consume orderings in safe Rust today, and so making them unsafe would be a breaking change.
Furthermore, because they're standard library constructs, they are not straightforward to change in an edition either.
Furthermore, because they're standard library constructs, they are not straightforward to change in an edition either.
If you wanted to do this, which to be clear I certainly don't think you should, the way to approach it would probably go like this:
1. Add new unsafe relaxed atomics features, by whatever means. Being new there's no backward compatibility worry.
2. Modify the existing safe atomics to treat Relaxed as Acquire-release. This doesn't break anything since Acquire-release is necessarily not breaking Relaxed guarantees, although of course some code now has worse performance than before.
Edited to add: Also, what do you mean you can use Consume today in Rust? Where? My std::sync::atomic::Ordering doesn't even list Consume as an option.
1. Add new unsafe relaxed atomics features, by whatever means. Being new there's no backward compatibility worry.
2. Modify the existing safe atomics to treat Relaxed as Acquire-release. This doesn't break anything since Acquire-release is necessarily not breaking Relaxed guarantees, although of course some code now has worse performance than before.
Edited to add: Also, what do you mean you can use Consume today in Rust? Where? My std::sync::atomic::Ordering doesn't even list Consume as an option.
I was wrong about consume.
BTW, the likely reason is that newer C/C++ standards also include a warning that the "consume" memory ordering has unclear semantics and should not be used. It can be replaced by acquire which provides stronger guarantees.
> because they're standard library constructs
Not only "std library" (which is only available when an OS is used), but also "core library" constructs. That one is available completely independent of the OS. Which means also in the current Rust for Linux project, or any other bare-metal platform.
Not only "std library" (which is only available when an OS is used), but also "core library" constructs. That one is available completely independent of the OS. Which means also in the current Rust for Linux project, or any other bare-metal platform.
What the article suggests seems to be more along the lines of a clippy lint warning agaunst use of these orderings, which wouldn't be a breaking change.
I am vaguely uncomfortable with this idea for reasons I am struggling to put into words, but that is a more viable path, yeah.
Soft-deprecating something in the standard library with a clippy lint feels a bit off for me too; I think that if something in the standard library is going to be discouraged, it should be done in the standard library itself (e.g. through documentation and/or using the deprecation warning). Clippy is absolutely phenomenal, but I tend not to think of it as something that's intended to be required by all users. Using it as a way to keep track of discouraged std types feels like it could easily lead to fragmentation where users stop looking to the standard library as the source of truth for its own contents.
And after some period of that it could be made unsafe in a future edition. So it’s not a totally crazy idea, but the payoff would have to be quite something.
For most of the cases I use relaxed or have seen it being used, it seems to be a loss. It's often to e.g. increase some shared metric counters from multiple threads - where there's no real ordering concerns, and the only thing that matters is that it counts right and not when exactly he value gets visible. The cheapest possible atomic operation which can fulfill that use-case (relaxed) is fine there. Having to use `unsafe` for that doesn't seem too appealing.
You cannot currently change the API of the standard library on a per-edition basis.
> consume
How do you use the consume ordering in Rust today?
How do you use the consume ordering in Rust today?
Whoops, just relaxed, I forgot about this!
I'm confused:
"Inspired by the apparent success of Java's new memory model, many of the same people set out to define a similar memory model for C++, eventually adopted in C++11." - https://research.swtch.com/plmm
"Rust's growing connection to the deep embedded world rules out the memory models of dynamic languages such as Java and Javascript."
So what is Rust using now, C++11 or C++20? "memory model for atomics from C++20"?!
"Inspired by the apparent success of Java's new memory model, many of the same people set out to define a similar memory model for C++, eventually adopted in C++11." - https://research.swtch.com/plmm
"Rust's growing connection to the deep embedded world rules out the memory models of dynamic languages such as Java and Javascript."
So what is Rust using now, C++11 or C++20? "memory model for atomics from C++20"?!
C++’s memory model has not changed much (if at all) since C++11. The Rust documentation specifically refers you to the C++20 standard, because they had to pick some version, but the C++20 memory model is (as far as I know) the same memory model introduced with the C/C++11 standards.
Kind of, volatile meaning changed, but they are looking into it for C++23 as the embedded community is not happy with the outcome.
But, volatile isn't actually part of the memory model (except arguably in MSVC). And all that was changed was to deprecate things that are a bad idea. They don't mean anything different, they aren't hard errors, they're just deprecated.
The embedded community in this context is people who were able to:
#include <embedded_vendor/mmio_stuff.h>
... a file full of dodgy C pre-processor macros into their C++ project and are unhappy that J. F. Bastien's volatile deprecation means now their compiler points out what a terrible idea some of those macros actually are.
Having the compiler shut up and silently allow this might make them feel better, but the situation they're in isn't actually improved by such a reversion at all.
Removing the batteries and disabling the smoke alarm does mean you get a peaceful night's sleep, but sometimes you don't wake up after that peaceful night's sleep because meanwhile your home burned down and there was no alarm.
The claim made in the proposal paper is that every C++ programmer who is including such macros definitely ensures by construction that their use of the macros is actually safe. You've probably read a bunch of real world C++ code written by people who are very confident in their ability to write correct programs, how do you feel about the likelihood that all these programs are in fact correct by construction despite using the unsafe macros?
It seems that in later revisions of the proposal, they considerably reduced the scope of the un-deprecation, realising that even if you earnestly believe (interrupt_register |= ENABLE_IRQC) isn't a footgun, it's much harder to rationalise (clock_rate /= 3) and either way nobody wanted half the random places C++ allowed you to write "volatile" anyway, so deprecating those isn't actually a problem and "reverting" the deprecation just highlights yet another mysterious black box in C++ with no clear purpose.
The embedded community in this context is people who were able to:
#include <embedded_vendor/mmio_stuff.h>
... a file full of dodgy C pre-processor macros into their C++ project and are unhappy that J. F. Bastien's volatile deprecation means now their compiler points out what a terrible idea some of those macros actually are.
Having the compiler shut up and silently allow this might make them feel better, but the situation they're in isn't actually improved by such a reversion at all.
Removing the batteries and disabling the smoke alarm does mean you get a peaceful night's sleep, but sometimes you don't wake up after that peaceful night's sleep because meanwhile your home burned down and there was no alarm.
The claim made in the proposal paper is that every C++ programmer who is including such macros definitely ensures by construction that their use of the macros is actually safe. You've probably read a bunch of real world C++ code written by people who are very confident in their ability to write correct programs, how do you feel about the likelihood that all these programs are in fact correct by construction despite using the unsafe macros?
It seems that in later revisions of the proposal, they considerably reduced the scope of the un-deprecation, realising that even if you earnestly believe (interrupt_register |= ENABLE_IRQC) isn't a footgun, it's much harder to rationalise (clock_rate /= 3) and either way nobody wanted half the random places C++ allowed you to write "volatile" anyway, so deprecating those isn't actually a problem and "reverting" the deprecation just highlights yet another mysterious black box in C++ with no clear purpose.
Yep, I am with you on that, it is that kind of subculture that spoils the fun of using C++, and no matter what the community does to improve the language, such kind of source bases will always be around in some companies.
Essentially the C++ 20 memory model is the C++ 11 memory model except where it talked about Consume now it just warns you that it's unclear whether this does what you want or expect and so you should probably not use it. "This ordering is temporarily discouraged" was added to the standard in 2017 and was unsurprisingly not removed in 2020.
Rust simply omits Consume altogether. It can always be added if there's consensus that we now understand what's going on here and can meaningfully deliver it on hardware people actually have in the real world.
Rust simply omits Consume altogether. It can always be added if there's consensus that we now understand what's going on here and can meaningfully deliver it on hardware people actually have in the real world.
Can you explain to someone who has not written a kernel but still coded Java and C(++) for 20 years how this quote is possible (how it works that Java which is written in C can outperform C):
"While I'm on the topic of concurrency I should mention my far too brief chat with Doug Lea. He commented that multi-threaded Java these days far outperforms C, due to the memory management and a garbage collector. If I recall correctly he said "only 12 times faster than C means you haven't started optimizing"." - Martin Fowler https://martinfowler.com/bliki/OOPSLA2005.html
I get the feeling this quote is possible due to the memory management rewrite of the entire JVM in version 1.5 to allow Doug to make the concurrency package?
But I cannot explain it, even if I have experienced it!
"While I'm on the topic of concurrency I should mention my far too brief chat with Doug Lea. He commented that multi-threaded Java these days far outperforms C, due to the memory management and a garbage collector. If I recall correctly he said "only 12 times faster than C means you haven't started optimizing"." - Martin Fowler https://martinfowler.com/bliki/OOPSLA2005.html
I get the feeling this quote is possible due to the memory management rewrite of the entire JVM in version 1.5 to allow Doug to make the concurrency package?
But I cannot explain it, even if I have experienced it!
I can try, in the most general sense. There are some specific technical tricks going on like the use of a JIT (Just In Time compiler) but mostly it comes back to this:
The Java program isn't doing anything that is literally impossible in C, but powerful abstractions can make it exponentially easier for everybody to do it in Java.
I can just about keep in my head how Quick Sort works. So, I figure I could write one, from scratch, in maybe a day or two in a language like C and have it work. But obviously I shouldn't do that, your standard C library comes with a perfectly nice fast sort. However, Java is supplied with an array of equally good features, and C is quite sparse on that front. You could program every one of those features in C, but, you probably didn't.
Memory management is an example like that, but spread through most programs. If you're meticulously allocating and freeing things in C, that takes time. Is it worth it? Maybe not but can you keep track well enough to do anything faster without just leaking unlimited memory? Java comes with a good Garbage Collector which can avoid all that explicit tracking you need to do, yet free all the same memory. Clever. You can do this in C, some people do, but it's hard, you probably don't do it.
The Java program isn't doing anything that is literally impossible in C, but powerful abstractions can make it exponentially easier for everybody to do it in Java.
I can just about keep in my head how Quick Sort works. So, I figure I could write one, from scratch, in maybe a day or two in a language like C and have it work. But obviously I shouldn't do that, your standard C library comes with a perfectly nice fast sort. However, Java is supplied with an array of equally good features, and C is quite sparse on that front. You could program every one of those features in C, but, you probably didn't.
Memory management is an example like that, but spread through most programs. If you're meticulously allocating and freeing things in C, that takes time. Is it worth it? Maybe not but can you keep track well enough to do anything faster without just leaking unlimited memory? Java comes with a good Garbage Collector which can avoid all that explicit tracking you need to do, yet free all the same memory. Clever. You can do this in C, some people do, but it's hard, you probably don't do it.
I only use hardcoded arrays in C, that way it's hard to leak anything and you avoid cache misses, but I feel fortunate I use Java on the server because I would not be able to sleep with C crashing on there!
I think the real technical reason, like you allude to, is closer to this quote:
"Many lock-free structures offer atomic-free read paths, notably concurrent containers in garbage collected languages, such as ConcurrentHashMap in Java. Languages without garbage collection have fewer straightforward options, mostly because safe memory reclamation is a hard problem..." - Travis Downs https://travisdowns.github.io/blog/2020/07/06/concurrency-co...
But I have yet to meet someone (4 years into this realization) that can clearly explain why in a way that anyone could understand and more importantly accept!
I think the real technical reason, like you allude to, is closer to this quote:
"Many lock-free structures offer atomic-free read paths, notably concurrent containers in garbage collected languages, such as ConcurrentHashMap in Java. Languages without garbage collection have fewer straightforward options, mostly because safe memory reclamation is a hard problem..." - Travis Downs https://travisdowns.github.io/blog/2020/07/06/concurrency-co...
But I have yet to meet someone (4 years into this realization) that can clearly explain why in a way that anyone could understand and more importantly accept!
To quote the Rustonomicon page: “C++11 was the first version of the model but it has received some bugfixes since then.”
I'd kill for a "volatile" with guaranteed semantics in Rust. Possibly more than one person.
> I'd kill for a "volatile" with guaranteed semantics in Rust. Possibly more than one person.
The reason people ask for this is because they haven't thought this trough.
Even if the compiler "preserves" all volatile operations, the HW does not.
A trivial example is WASM. Rust can generate WASM code that, when using volatile, has many duplicated loads and stores in some order. Then the browser runs a WASM optimizer on the WASM code, which discovers that these instructions are redundant, and there you go, your program now doesn't do what you wanted anymore (formally, it never did).
x86, ARM, PPC, FPGAs, GPUs, etc. all do this in HW as well. So while you could argue that, e.g., WASM should have volatile loads and stores, the same argument can be made at the next level down below, down to hardware.
You'd need to ask every hardware vendor for some sort of serializing loads and stores, and people have done that, and vendors have implemented that, but then when users finally got what they wanted, they complained that they are too slow, so at that point they just stop using volatile and use atomics which is what they should have done in the first place.
The reason people ask for this is because they haven't thought this trough.
Even if the compiler "preserves" all volatile operations, the HW does not.
A trivial example is WASM. Rust can generate WASM code that, when using volatile, has many duplicated loads and stores in some order. Then the browser runs a WASM optimizer on the WASM code, which discovers that these instructions are redundant, and there you go, your program now doesn't do what you wanted anymore (formally, it never did).
x86, ARM, PPC, FPGAs, GPUs, etc. all do this in HW as well. So while you could argue that, e.g., WASM should have volatile loads and stores, the same argument can be made at the next level down below, down to hardware.
You'd need to ask every hardware vendor for some sort of serializing loads and stores, and people have done that, and vendors have implemented that, but then when users finally got what they wanted, they complained that they are too slow, so at that point they just stop using volatile and use atomics which is what they should have done in the first place.
Thank you, but I'm aware of this. Why did you claim that I haven't thought this through?
Your original comment reads like a feature request.
Since now I know that you knew that the feature you were requesting is impossible to implement, and you did not mention that in your feature request, now... i have no idea of what the goal of your comment was.
Since now I know that you knew that the feature you were requesting is impossible to implement, and you did not mention that in your feature request, now... i have no idea of what the goal of your comment was.
Is there a specific worry that this would solve, or is it just the general uncomfortableness with it not being a 100% guarantee?
The latter: I translate slightly off-the-beaten-path concurrency stuff to Rust, and a lot of papers and reference implementations lean on the semantics of "volatile" in C++ or Java.
Note that “slightly off-the-beaten-path concurrency stuff” may not be correct in today’s world, especially if it uses ‘volatile’. This is primarily because memory accesses can now be reordered throughout the compiler/processor stack, from the optimizer down to out-of-order execution down to the memory hierarchy. In particular, C(++) guarantees that volatile reads/writes will never be reordered with one another, but they may be reordered with reads/writes to other variables [0] (and this ordering may not be consistent across CPU cores).
Importantly, the x86 ISA provides much stronger guarantees than C/C++. AFAIK the only memory reordering allowed is that a write followed by a read can be swapped around. This is extremely strict (we can’t even reorder adjacent reads) and literally any other processor architecture will provide much weaker guarantees. And because C and C++ formally defined a weak memory model in 2011, modern compilers are free (even when targeting x86 chips) to perform optimizations that reorder memory accesses in ways programmers familiar with x86’s semantics might not expect.
So an algorithm that worked on x86 processors 10-15 years ago may no longer be correct when compiled with a modern optimizing compiler, or when running on ARM. (If in doubt, you should be OK replacing anything volatile with an atomic and specifying sequentially-consistent ordering semantics for all operations — which you can do in Rust!)
[0]: https://systemtbe.blogspot.com/2014/05/volatile-memory-barri...
Importantly, the x86 ISA provides much stronger guarantees than C/C++. AFAIK the only memory reordering allowed is that a write followed by a read can be swapped around. This is extremely strict (we can’t even reorder adjacent reads) and literally any other processor architecture will provide much weaker guarantees. And because C and C++ formally defined a weak memory model in 2011, modern compilers are free (even when targeting x86 chips) to perform optimizations that reorder memory accesses in ways programmers familiar with x86’s semantics might not expect.
So an algorithm that worked on x86 processors 10-15 years ago may no longer be correct when compiled with a modern optimizing compiler, or when running on ARM. (If in doubt, you should be OK replacing anything volatile with an atomic and specifying sequentially-consistent ordering semantics for all operations — which you can do in Rust!)
[0]: https://systemtbe.blogspot.com/2014/05/volatile-memory-barri...
To be clear, plain C volatile has never been sufficent even on x86. This has not changed with C++11/C11. For example the linux kernel (which has yet to embrace the C11 MM) has always used memory and compiler barriers were needed even on x86 for decades.
Java volatile is different of course.
Java volatile is different of course.
As long as you use C's volatile for all the variables that are involved in a (lock-free) transaction, you should be fine I think. For example, volatile is said to be sufficient for interfacing with hardware through memory mapped regions, because that is one area where it is very clear what the outputs are and one can just make all those accesses volatile so they won't get reordered.
Although, having to go all-volatile is maybe too much of a constraint, when many algorithms (like lock-free queues etc.) only require like 3 non-reordered accesses (2 to begin, 1 to end?) to safeguard a transaction, and then the remaining reads and writes can be reordered arbitrarily (inside the safe bounds).
Although, having to go all-volatile is maybe too much of a constraint, when many algorithms (like lock-free queues etc.) only require like 3 non-reordered accesses (2 to begin, 1 to end?) to safeguard a transaction, and then the remaining reads and writes can be reordered arbitrarily (inside the safe bounds).
> As long as you use C's volatile for all the variables that are involved in a (lock-free) transaction, you should be fine I think.
No, you really won't, neither in C nor in Rust. There are (at least) two reasons for that:
1) You can't do atomic read-modify-write operations (like fetch_add) with volatile.
2) "volatile" is meant for the compiler, not for the processor. Volatile accesses are compiled down to regular read and write machine instructions, so you won't get any ordering guarantees for code running concurrently on different cores.
The reason "volatile" is useful for memory-mapped IO is that such memory is mapped with different flags for cacheability than regular memory. For example, given the right flags, the processor won't cache stores in a store-buffer, or won't coalesce smaller stores into larger stores before sending them up the memory hierarchy.
No, you really won't, neither in C nor in Rust. There are (at least) two reasons for that:
1) You can't do atomic read-modify-write operations (like fetch_add) with volatile.
2) "volatile" is meant for the compiler, not for the processor. Volatile accesses are compiled down to regular read and write machine instructions, so you won't get any ordering guarantees for code running concurrently on different cores.
The reason "volatile" is useful for memory-mapped IO is that such memory is mapped with different flags for cacheability than regular memory. For example, given the right flags, the processor won't cache stores in a store-buffer, or won't coalesce smaller stores into larger stores before sending them up the memory hierarchy.
> You can't do atomic read-modify-write operations (like fetch_add) with volatile.
Are those operations so important? All I've ever done to be honest is implement some lock-free SRSW queues, and those can be easily implemented with just acquire/release semantics. No atomic read-modify-write needed. Could it be that those are only needed when more than 2 threads are involved?
Are those operations so important? All I've ever done to be honest is implement some lock-free SRSW queues, and those can be easily implemented with just acquire/release semantics. No atomic read-modify-write needed. Could it be that those are only needed when more than 2 threads are involved?
> Are those operations so important?
Yes, absolutely. You can't even implement thread-safe reference counting (std::sync::Arc in Rust) without atomic RMW – or even a thread-safe counter.
A lock-free single-produce-single-consumer (SPSC) queue is pretty simple. As soon as you have more than one producer or more than one consumer, you need atomic RMW.
Yes, absolutely. You can't even implement thread-safe reference counting (std::sync::Arc in Rust) without atomic RMW – or even a thread-safe counter.
A lock-free single-produce-single-consumer (SPSC) queue is pretty simple. As soon as you have more than one producer or more than one consumer, you need atomic RMW.
> For example, volatile is said to be sufficient for interfacing with hardware through memory mapped regions, because that is one area where it is very clear what the outputs are and one can just make all those accesses volatile so they won't get reordered.
It's not "sufficient" for MMIO, it's literally the sole use case for it, and the sole situation where it's correct, because the hardware will not fuck with MMIO accesses either (and not eliding reads is important because reads might have side effects). Although even then you may need to use atomics anyway if the driver is concurrent, because volatiles don't guarantee any sort of atomicity and the reaction of MMIO devices to concurrent access is very likely to be funky (then again these days MMIO is usually a thing on uCs where there is no uncontrolled and arbitrary concurrency).
> Although, having to go all-volatile is maybe too much of a constraint, when many algorithms (like lock-free queues etc.) only require like 3 non-reordered accesses (2 to begin, 1 to end?) to safeguard a transaction, and then the remaining reads and writes can be reordered arbitrarily (inside the safe bounds).
Except your accesses will be reordered arbitrarily:
* volatile instructions are only of concern for the compiler, everything below it has no idea about them, and if you're not in an MMIO context they'll absolutely try to reorder and remove them (and even when they won't since volatiles are not synchronisation point you have no idea how they'll propagate)
* volatile instructions are not fences, even for the compiler are no "safe bounds" with volatiles unless all the operations are volatile
It's not "sufficient" for MMIO, it's literally the sole use case for it, and the sole situation where it's correct, because the hardware will not fuck with MMIO accesses either (and not eliding reads is important because reads might have side effects). Although even then you may need to use atomics anyway if the driver is concurrent, because volatiles don't guarantee any sort of atomicity and the reaction of MMIO devices to concurrent access is very likely to be funky (then again these days MMIO is usually a thing on uCs where there is no uncontrolled and arbitrary concurrency).
> Although, having to go all-volatile is maybe too much of a constraint, when many algorithms (like lock-free queues etc.) only require like 3 non-reordered accesses (2 to begin, 1 to end?) to safeguard a transaction, and then the remaining reads and writes can be reordered arbitrarily (inside the safe bounds).
Except your accesses will be reordered arbitrarily:
* volatile instructions are only of concern for the compiler, everything below it has no idea about them, and if you're not in an MMIO context they'll absolutely try to reorder and remove them (and even when they won't since volatiles are not synchronisation point you have no idea how they'll propagate)
* volatile instructions are not fences, even for the compiler are no "safe bounds" with volatiles unless all the operations are volatile
> even for the compiler are no "safe bounds" with volatiles unless all the operations are volatile
That's exactly what I said right? But good to know that volatile won't emit fences.
That's exactly what I said right? But good to know that volatile won't emit fences.
Peterson algorithm is broken on x86 if implemented with just volatiles. The compiler will not emit the required fences.
On x86 if everything that can be potentially shared is volatile, you may get away with it if you only need acquire/release, but in practice not everything being shared are not going to be volatile. For example if you implement a spsc queue only with volatile operations and use it to send a pointer to a non volatile object to another thread, the consumer might see a non initialized object.
On x86 if everything that can be potentially shared is volatile, you may get away with it if you only need acquire/release, but in practice not everything being shared are not going to be volatile. For example if you implement a spsc queue only with volatile operations and use it to send a pointer to a non volatile object to another thread, the consumer might see a non initialized object.
> when many algorithms (like lock-free queues etc.) only require like 3 non-reordered accesses (2 to begin, 1 to end?) to safeguard a transaction, and then the remaining reads and writes can be reordered arbitrarily (inside the safe bounds).
That “(inside the safe bounds)” disclaimer is the reason we can’t use volatile for anything meaningful, because volatile does not provide that guarantee. The compiler & hardware will happily let the contents of your critical section float right past the volatile reads/writes “protecting” it.
If I write a lock-free queue and use volatile for synchronization, then I’m just protecting the queue itself and not the contents. For instance, if I write to some memory and then push a reference to it onto the queue, other cores might see the reference but not the data. Maybe the volatile writes got rushed through the memory hierarchy & to the other cores, but all the non-volatile writes are still stuck in a cache somewhere until next week.
And AFAIK volatile still doesn’t guarantee atomicity in the case of concurrent access. If one thread writes to a volatile variable, other threads can easily see torn writes/“pink elephants”/out-of-nowhere values. Again this is somewhere that x86 provides unnecessarily and somewhat ridiculously strong guarantees that nobody else does, so code that worked on dumb compilers in the 90’s may be very, very, broken today.
So please just use atomics. They’re very nice, and they solve all of these problems. If you don’t understand memory ordering semantics just mark everything as sequentially consistent and don’t think about it, I don’t care, just please use atomics.
That “(inside the safe bounds)” disclaimer is the reason we can’t use volatile for anything meaningful, because volatile does not provide that guarantee. The compiler & hardware will happily let the contents of your critical section float right past the volatile reads/writes “protecting” it.
If I write a lock-free queue and use volatile for synchronization, then I’m just protecting the queue itself and not the contents. For instance, if I write to some memory and then push a reference to it onto the queue, other cores might see the reference but not the data. Maybe the volatile writes got rushed through the memory hierarchy & to the other cores, but all the non-volatile writes are still stuck in a cache somewhere until next week.
And AFAIK volatile still doesn’t guarantee atomicity in the case of concurrent access. If one thread writes to a volatile variable, other threads can easily see torn writes/“pink elephants”/out-of-nowhere values. Again this is somewhere that x86 provides unnecessarily and somewhat ridiculously strong guarantees that nobody else does, so code that worked on dumb compilers in the 90’s may be very, very, broken today.
So please just use atomics. They’re very nice, and they solve all of these problems. If you don’t understand memory ordering semantics just mark everything as sequentially consistent and don’t think about it, I don’t care, just please use atomics.
Calm yourself, and be reassured, I'm using atomics and not questioning them :-). I also do understand memory ordering well enough for my purposes (I'm not sure I succeeded but I tried to explain just what you said here).
Glad to hear it :) Re-reading your comment again I think you’re right that we really were just saying the same thing in different ways, I just got tripped up on your “volatile should be fine” language and misread the rest of your comment. Sorry about that, it was a valuable insight!
No, the x86 ISA guarantees only that the writes are never reordered (unless they are marked as non-temporal or they are made to write-combining memory).
The reads can always be reordered, unless they access uncacheable memory, which can happen only in device drivers, not in normal applications.
x86 provides 3 memory fences (LFENCE, SFENCE, MFENCE) which must be used when you need a certain ordering of the reads or of the reads around writes.
The reads can always be reordered, unless they access uncacheable memory, which can happen only in device drivers, not in normal applications.
x86 provides 3 memory fences (LFENCE, SFENCE, MFENCE) which must be used when you need a certain ordering of the reads or of the reads around writes.
Someone who downvoted this was right, my memory was wrong.
On x86, reads can be reordered between themselves only for non-temporal transfers.
For the normal case, when the accesses are made to cacheable read-write memory, the reads are not reordered between themselves.
On x86, reads can be reordered between themselves only for non-temporal transfers.
For the normal case, when the accesses are made to cacheable read-write memory, the reads are not reordered between themselves.
Rust provides volatile load and store intrinsics which do exactly what they say on the box, but given you talk about "concurrency stuff" I expect they're completely irrelevant to you because this (older) meaning of volatile is about hardware memory access (e.g. for device drivers) and nothing to do with concurrency.
Java's "volatile" is sequential consistency on steroids, a promise that changes to a volatile variable "happen-before" anything else that thread does, and "happen-after" anything the thread already did. Rust's std::sync::atomic has a hardware fence which you could use to deliver this same outcome (throw away performance, gain sequential consistency).
For concurrency C++ volatile does not supply any semantics, except on MSVC. If you're looking at papers or reference implementations which assume C++ volatile has semantics that are relevant to concurrency you're probably looking at papers that only needed Acquire-release and it worked on the x86 CPU they tried it with, or they forgot to mention it only works on MSVC (which promises Acquire-release even on platforms other than x86).
Java's "volatile" is sequential consistency on steroids, a promise that changes to a volatile variable "happen-before" anything else that thread does, and "happen-after" anything the thread already did. Rust's std::sync::atomic has a hardware fence which you could use to deliver this same outcome (throw away performance, gain sequential consistency).
For concurrency C++ volatile does not supply any semantics, except on MSVC. If you're looking at papers or reference implementations which assume C++ volatile has semantics that are relevant to concurrency you're probably looking at papers that only needed Acquire-release and it worked on the x86 CPU they tried it with, or they forgot to mention it only works on MSVC (which promises Acquire-release even on platforms other than x86).
> which do exactly what they say on the box
I mean, the box says: https://doc.rust-lang.org/stable/std/ptr/fn.read_volatile.ht...
> Rust does not currently have a rigorously and formally defined memory model, so the precise semantics of what “volatile” means here is subject to change over time. That being said, the semantics will almost always end up pretty similar to C11’s definition of volatile.
Which isn't exactly "exact", you know?
I mean, the box says: https://doc.rust-lang.org/stable/std/ptr/fn.read_volatile.ht...
> Rust does not currently have a rigorously and formally defined memory model, so the precise semantics of what “volatile” means here is subject to change over time. That being said, the semantics will almost always end up pretty similar to C11’s definition of volatile.
Which isn't exactly "exact", you know?
It's not. But I feel confident (perhaps my confidence is misplaced) that this is because what's really going on here is that this does what LLVM does, and there's some risk LLVM changes exactly what it does, most likely to suit clang so in that case Rust has to go along for the ride or perhaps get itself a new suite of compiler backends...
If so I feel that risk is small and continues to shrink.
If so I feel that risk is small and continues to shrink.
Totally. Hence my original question, some folks want an actual airtight guarantee, some are comfy with where things are now.
> semantics of "volatile" in C++ or Java
But the semantics of "volatile" are completely different in C++ and Java! And I've seen lots of bugs from people assuming Java semantics for volatile in C++.
In C++ land, volatile is mostly useless and sometimes harmful (see [0]). It's dangerous to use it in concurrent code and hardly ever useful for memory mapped i/o without appropriate memory barriers (which make the volatile qualifier redundant).
In Java land, volatile can actually provide some useful guarantees when it comes to multithreaded uses but afaik they will usually just be turned into atomic load/store instructions so you might as well be writing atomics explicitly.
And it can be debated whether "volatile" should be a type qualifier in the first place. I would argue that it's better to make load/store operations on volatile memory explicit (for those very rare cases when you need it).
There is experimental support in Rust for the latter, `volatile_load` and `volatile_store` in `std::intrinsics` [1].
But still, doing volatile loads and stores in multithreaded code is hardly ever the right thing to do, at least without accompanying them with the relevant memory barriers (which are especially important on ARM CPUs).
[0] https://github.com/torvalds/linux/blob/master/Documentation/... [1] https://doc.rust-lang.org/std/intrinsics/fn.volatile_store.h...
But the semantics of "volatile" are completely different in C++ and Java! And I've seen lots of bugs from people assuming Java semantics for volatile in C++.
In C++ land, volatile is mostly useless and sometimes harmful (see [0]). It's dangerous to use it in concurrent code and hardly ever useful for memory mapped i/o without appropriate memory barriers (which make the volatile qualifier redundant).
In Java land, volatile can actually provide some useful guarantees when it comes to multithreaded uses but afaik they will usually just be turned into atomic load/store instructions so you might as well be writing atomics explicitly.
And it can be debated whether "volatile" should be a type qualifier in the first place. I would argue that it's better to make load/store operations on volatile memory explicit (for those very rare cases when you need it).
There is experimental support in Rust for the latter, `volatile_load` and `volatile_store` in `std::intrinsics` [1].
But still, doing volatile loads and stores in multithreaded code is hardly ever the right thing to do, at least without accompanying them with the relevant memory barriers (which are especially important on ARM CPUs).
[0] https://github.com/torvalds/linux/blob/master/Documentation/... [1] https://doc.rust-lang.org/std/intrinsics/fn.volatile_store.h...
> There is experimental support in Rust for the latter
It's not really experimental. You've linked the underlying core intrinsic, but the stabilised interface, which is what programmers should actually use, is std::ptr::write_volatile / std::ptr::read_volatile
Essentially you linked an implementation detail and that's why it's labelled unstable and you can't use it in stable Rust.
It's not really experimental. You've linked the underlying core intrinsic, but the stabilised interface, which is what programmers should actually use, is std::ptr::write_volatile / std::ptr::read_volatile
Essentially you linked an implementation detail and that's why it's labelled unstable and you can't use it in stable Rust.
I see, thanks for the clarification. My search engine pointed me to the std::intrinsics as first hit.
But yeah, you can have your volatile in Rust then. It's not a type level annotation, but an explicit (unsafe) operation.
But yeah, you can have your volatile in Rust then. It's not a type level annotation, but an explicit (unsafe) operation.
> which make the volatile qualifier redundant
That's not entirely true e.g. `read;read` can be very relevant in an MMIO context (a read can have side-effect), but atomic reads can technically be optimised out (though it might not currently be done by most compilers).
That's not entirely true e.g. `read;read` can be very relevant in an MMIO context (a read can have side-effect), but atomic reads can technically be optimised out (though it might not currently be done by most compilers).
> but atomic reads can technically be optimised out (though it might not currently be done by most compilers).
The memory model should explicitly forbid this optimization, especially if you use an explicit memory ordering with your loads and stores. I'm not 100% sure what the default (sequential consistency) does in this case.
And if you do `read(); barrier(); read(); barrier();` like you should on most CPU architectures, the compiler definitely is not allowed to reorder the reads (barriers affect the compiler AND the CPU). Which barrier you need depends on the MMIO semantics and CPU architecture you're on. Adding volatile does not change this but may inhibit other, useful optimization.
In your example of `volatile_read(); volatile_read()`, the compiler is not allowed to optimize or reorder the loads but the CPU is still allowed to reorder them with each other or other instructions. Adding `volatile` does not make this example work.
The memory model should explicitly forbid this optimization, especially if you use an explicit memory ordering with your loads and stores. I'm not 100% sure what the default (sequential consistency) does in this case.
And if you do `read(); barrier(); read(); barrier();` like you should on most CPU architectures, the compiler definitely is not allowed to reorder the reads (barriers affect the compiler AND the CPU). Which barrier you need depends on the MMIO semantics and CPU architecture you're on. Adding volatile does not change this but may inhibit other, useful optimization.
In your example of `volatile_read(); volatile_read()`, the compiler is not allowed to optimize or reorder the loads but the CPU is still allowed to reorder them with each other or other instructions. Adding `volatile` does not make this example work.
> The memory model should explicitly forbid this optimization, especially if you use an explicit memory ordering with your loads and stores.
It has no reason to. Reading the same location twice, even atomically, has no “reason” to change the value so reusing the same read is perfectly valid.
If there is an other operation between the two and that has a sequencing implication then yes, otherwise no.
> In your example of `volatile_read(); volatile_read()`, the compiler is not allowed to optimize or reorder the loads but the CPU is still allowed to reorder them with each other
If there are memory reads then sure but i’m assuming actual mmio here (since volatile is otherwise useless) and i’d think an mmio implementation which reorders mmio reads is completely broken.
It has no reason to. Reading the same location twice, even atomically, has no “reason” to change the value so reusing the same read is perfectly valid.
If there is an other operation between the two and that has a sequencing implication then yes, otherwise no.
> In your example of `volatile_read(); volatile_read()`, the compiler is not allowed to optimize or reorder the loads but the CPU is still allowed to reorder them with each other
If there are memory reads then sure but i’m assuming actual mmio here (since volatile is otherwise useless) and i’d think an mmio implementation which reorders mmio reads is completely broken.
> It has no reason to. Reading the same location twice, even atomically, has no “reason” to change the value so reusing the same read is perfectly valid.
No, this is definitely not how atomics with memory orderings work. If you do two atomic reads, the CPU will do two load instructions. Neither the compiler or the CPU is allowed to optimize this.
> and i’d think an mmio implementation which reorders mmio reads is completely broken.
This is exactly how ARM SoCs work. The CPU core does not know if a load address is mmio or not and may reorder the loads as it sees fit. It's up to the memory subsystem to deal with this, the CPU core does not care.
I've done lots of mmio code on ARM at $work in the past few years, and we've spent a lot of time to make sure we have all our memory barriers right. They are quite tricky on ARMv8, as mmio can be either on the system bus or the main memory bus which need distinct kinds of memory barriers.
Using volatile in mmio code on modern CPUs is almost certainly a bug. Some microcontrollers may be an exception.
No, this is definitely not how atomics with memory orderings work. If you do two atomic reads, the CPU will do two load instructions. Neither the compiler or the CPU is allowed to optimize this.
> and i’d think an mmio implementation which reorders mmio reads is completely broken.
This is exactly how ARM SoCs work. The CPU core does not know if a load address is mmio or not and may reorder the loads as it sees fit. It's up to the memory subsystem to deal with this, the CPU core does not care.
I've done lots of mmio code on ARM at $work in the past few years, and we've spent a lot of time to make sure we have all our memory barriers right. They are quite tricky on ARMv8, as mmio can be either on the system bus or the main memory bus which need distinct kinds of memory barriers.
Using volatile in mmio code on modern CPUs is almost certainly a bug. Some microcontrollers may be an exception.
> No, this is definitely not how atomics with memory orderings work.
Of course it is.
> If you do two atomic reads, the CPU will do two load instructions. Neither the compiler or the CPU is allowed to optimize this.
If you perform two atomic loads on the same location in sequence, then it is completely feasible and normal that the two loads would return exactly the same value, thus the second load can be optimised away. Even under sequential consistency, this is perfectly, well, consistent. It's also perfectly valid to fold atomic writes. See http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n445... for more discussion on the subject
No compiler currently bothers doing that (that I know), but it's not because they can't, it's because the effort is probably not worth it, at least at the moment.
> I've done lots of mmio code on ARM at $work in the past few years, and we've spent a lot of time to make sure we have all our memory barriers right. They are quite tricky on ARMv8, as mmio can be either on the system bus or the main memory bus which need distinct kinds of memory barriers.
I can believe that.
> Using volatile in mmio code on modern CPUs is almost certainly a bug. Some microcontrollers may be an exception.
Not using volatile in mmio code on modern compilers is almost certainly a bug as well.
Of course it is.
> If you do two atomic reads, the CPU will do two load instructions. Neither the compiler or the CPU is allowed to optimize this.
If you perform two atomic loads on the same location in sequence, then it is completely feasible and normal that the two loads would return exactly the same value, thus the second load can be optimised away. Even under sequential consistency, this is perfectly, well, consistent. It's also perfectly valid to fold atomic writes. See http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n445... for more discussion on the subject
No compiler currently bothers doing that (that I know), but it's not because they can't, it's because the effort is probably not worth it, at least at the moment.
> I've done lots of mmio code on ARM at $work in the past few years, and we've spent a lot of time to make sure we have all our memory barriers right. They are quite tricky on ARMv8, as mmio can be either on the system bus or the main memory bus which need distinct kinds of memory barriers.
I can believe that.
> Using volatile in mmio code on modern CPUs is almost certainly a bug. Some microcontrollers may be an exception.
Not using volatile in mmio code on modern compilers is almost certainly a bug as well.
> No, this is definitely not how atomics with memory orderings work. If you do two atomic reads, the CPU will do two load instructions. Neither the compiler or the CPU is allowed to optimize this.
Yes they are. And other optimizations are allowed too. If you do an 32-bit atomic load and then mask the result with `& 0xff`, the compiler can theoretically narrow it into an 8-bit atomic load, as long as the architecture guarantees that such a load still provides the necessary ordering guarantees – which I think is true on most architectures. This won't necessarily work for MMIO, where incorrectly-sized accesses may trap or return the wrong data. But the compiler is not responsible for preserving MMIO semantics if you only asked for atomic.
That said, I'm not aware of any major compilers performing /any/ significant optimizations on atomics, so you won't get in trouble for this in practice.
> This is exactly how ARM SoCs work. The CPU core does not know if a load address is mmio or not and may reorder the loads as it sees fit. It's up to the memory subsystem to deal with this, the CPU core does not care.
You can configure in memory attributes whether to allow reordering or not.
> They are quite tricky on ARMv8, as mmio can be either on the system bus or the main memory bus which need distinct kinds of memory barriers.
Which SoC?
Yes they are. And other optimizations are allowed too. If you do an 32-bit atomic load and then mask the result with `& 0xff`, the compiler can theoretically narrow it into an 8-bit atomic load, as long as the architecture guarantees that such a load still provides the necessary ordering guarantees – which I think is true on most architectures. This won't necessarily work for MMIO, where incorrectly-sized accesses may trap or return the wrong data. But the compiler is not responsible for preserving MMIO semantics if you only asked for atomic.
That said, I'm not aware of any major compilers performing /any/ significant optimizations on atomics, so you won't get in trouble for this in practice.
> This is exactly how ARM SoCs work. The CPU core does not know if a load address is mmio or not and may reorder the loads as it sees fit. It's up to the memory subsystem to deal with this, the CPU core does not care.
You can configure in memory attributes whether to allow reordering or not.
> They are quite tricky on ARMv8, as mmio can be either on the system bus or the main memory bus which need distinct kinds of memory barriers.
Which SoC?
What does "volatile" have to do with concurrency? It's for hardware access and benchmarking, not for multithreading.
AFAIK Rust volatile accesses follow C++ semantics, no?
Meaning they’re completely different and way incompatible with Java’s.
Meaning they’re completely different and way incompatible with Java’s.
Cool, makes sense. Thanks :)
>Rust's growing connection to the deep embedded world rules out the memory models of dynamic languages such as Java and Javascript.
Since when is Java a dynamic language?
Since when is Java a dynamic language?
Java is actually pretty dynamic under the hood. You can create classes on the fly, write byte code, reflect, cast at runtime… in comparison all Rust abstractions are static, hence compiled away.
[deleted]
Maybe mentioning it in the same breath as Javascript rubbed you the wrong way, but Java _is_ pretty dynamic.
Object being a valid write destination for any reference means the VM has to maintain type information in all objects (small 'o'), which is a distinctively dynamic thing to do. I mean, simply declare all variables to be Object and you can see a faint shadow of a python-like class system lurking underneath the static types. The fact that nobody does this (I hope!) is of no use to the VM, the semantics say it must do this.
All object allocations are on the heap (and only ever moved to stack if escape analysis proves they could), all methods are dynamically dispatched. Hell, even class loading (i.e. linking libraries) is done at startup, which means you can recompile libraries without re-linking the client code which uses them.
Object being a valid write destination for any reference means the VM has to maintain type information in all objects (small 'o'), which is a distinctively dynamic thing to do. I mean, simply declare all variables to be Object and you can see a faint shadow of a python-like class system lurking underneath the static types. The fact that nobody does this (I hope!) is of no use to the VM, the semantics say it must do this.
All object allocations are on the heap (and only ever moved to stack if escape analysis proves they could), all methods are dynamically dispatched. Hell, even class loading (i.e. linking libraries) is done at startup, which means you can recompile libraries without re-linking the client code which uses them.
I can do similar stuff with C++, so it is a dynamic language now?
The reference Python interpreter is implemented in C, does that invalidate the common sense observation that python is dynamic while C is static ? What counts is the default zero-effort mode. Sliding into "All languages are turing complete so all distinctions are meaningless" territory is very rarely helpful for conversations.
Sure C++ offers you both stack and heap allocations, virtual and ordinary methods, static types and std::any for values of unknown/irrelevant runtime type. But the default zero-effort thing to do is to use statically-typed stack-allocated objects with non-virtual methods. Java only offers two of these things and one of them needs to be explicitly stated upfront. And, furthermore, Java's runtime still maintains the full machinery needed to do dynamic typing and dynamic method dispatch (the final modifier on methods is purely a promise to javac, the JVM has no concept of a method call that doesn't involve looking up the class it refers to and potentially all of its superclasses), it doesn't matter that you don't use it, your calling code or called code might, and the specification says that it can.
C++ objects compile to dumb bit buckets that contain exactly what's asked of them and nothing more. No matter what you do, you can never have all class types in C++ share a common ancestor, and the ones you can will know nothing about their parents unless specifically asked to with 'virtual', they can only remember this when allocated on the heap.
Sure C++ offers you both stack and heap allocations, virtual and ordinary methods, static types and std::any for values of unknown/irrelevant runtime type. But the default zero-effort thing to do is to use statically-typed stack-allocated objects with non-virtual methods. Java only offers two of these things and one of them needs to be explicitly stated upfront. And, furthermore, Java's runtime still maintains the full machinery needed to do dynamic typing and dynamic method dispatch (the final modifier on methods is purely a promise to javac, the JVM has no concept of a method call that doesn't involve looking up the class it refers to and potentially all of its superclasses), it doesn't matter that you don't use it, your calling code or called code might, and the specification says that it can.
C++ objects compile to dumb bit buckets that contain exactly what's asked of them and nothing more. No matter what you do, you can never have all class types in C++ share a common ancestor, and the ones you can will know nothing about their parents unless specifically asked to with 'virtual', they can only remember this when allocated on the heap.
Yeah, because there isn't such thing as C++ language runtime, RTTI, dynamic_cast<>() and loading C++ classes via shared objects. /s
>RTTI, dynamic_cast<>()
I don't get the snark, these things are only allowed for virtual-method-containing classes, something I explicitly mentioned and said it doesn't count because it's not on by default, not idiomatic to be on by default, doesn't exist in binaries or at runtime when not in the source, and is extremly limited compared to the rich metadata every java object hold no matter what you do or say (so even objects of final classes have it).
>loading C++ classes via shared objects
A capability of the underlying operating system, which _is_ a "runtime", just not a standard or required part of the language, and shared objects or dynamic libraries know a tiny drop of water about their source compared to the ocean of metadata in every .class file and the runtime representation of them.
>C++ language runtime
What does that even mean in this context ? I have heard it said to refer to the standard library, which is ridiculous in our context because it's neither required (let alone mandatory) part of the language nor carries information or controls execution of the calling code like the java runtime does. I have heard it being said to refer the variant of C++ that Microsoft runs on the CLR, which might as well be C# in this context. The closest thing would be the Exception Handling Mechanism and the runtime info it requires, but that's again extremely limited compared to what java does and a qualitatively different thing. Dumb tables of pointers or return addresses can never be comparable to the very rich representation of types and other metadata about code that the JVM or the CLR does.
I don't get the snark, these things are only allowed for virtual-method-containing classes, something I explicitly mentioned and said it doesn't count because it's not on by default, not idiomatic to be on by default, doesn't exist in binaries or at runtime when not in the source, and is extremly limited compared to the rich metadata every java object hold no matter what you do or say (so even objects of final classes have it).
>loading C++ classes via shared objects
A capability of the underlying operating system, which _is_ a "runtime", just not a standard or required part of the language, and shared objects or dynamic libraries know a tiny drop of water about their source compared to the ocean of metadata in every .class file and the runtime representation of them.
>C++ language runtime
What does that even mean in this context ? I have heard it said to refer to the standard library, which is ridiculous in our context because it's neither required (let alone mandatory) part of the language nor carries information or controls execution of the calling code like the java runtime does. I have heard it being said to refer the variant of C++ that Microsoft runs on the CLR, which might as well be C# in this context. The closest thing would be the Exception Handling Mechanism and the runtime info it requires, but that's again extremely limited compared to what java does and a qualitatively different thing. Dumb tables of pointers or return addresses can never be comparable to the very rich representation of types and other metadata about code that the JVM or the CLR does.
First of all, an ISO C++ compliant implementation can run bare metal, there isn't any requirement that an OS must exist, that is the whole point of a systems programming language.
Secondly a C++ runtime is responsible for running all global variable contructors and destructors, handling exceptions, soft floating operations, loading shared libraries on demand for dynamic linking (depending on OS capabilities or bare metal), how new/delete translation into the underlying OS/MMU.
As for the metadata, so when I use static reflection in C++, either via template metaprogramming, or with the features that sadly will only come in C++26, then what?
And really I just picked C++ as first example that came into my mind.
We can also start discussing Eiffel, Delphi, Swift or Go for example, with their rich type information, dynamic dispatch, rich runtimes, and native compilation model, so they are also dynamic?
Secondly a C++ runtime is responsible for running all global variable contructors and destructors, handling exceptions, soft floating operations, loading shared libraries on demand for dynamic linking (depending on OS capabilities or bare metal), how new/delete translation into the underlying OS/MMU.
As for the metadata, so when I use static reflection in C++, either via template metaprogramming, or with the features that sadly will only come in C++26, then what?
And really I just picked C++ as first example that came into my mind.
We can also start discussing Eiffel, Delphi, Swift or Go for example, with their rich type information, dynamic dispatch, rich runtimes, and native compilation model, so they are also dynamic?
>an ISO C++ compliant implementation can run bare metal, there isn't any requirement that an OS must exist
Ok? so This point contradicts you, the language defines no runtime support, so it's very difficult to do the things that a language with a runtime in its specification considers for-granted.
>a C++ runtime is responsible for running all global variable contructors and destructors, handling exceptions, soft floating operations, loading shared libraries on demand for dynamic linking (depending on OS capabilities or bare metal), how new/delete translation into the underlying OS/MMU.
This is a misuse of the term as used in our context, almost all of the above can be considered as simply linked/injected code. The global variable initialization is done by startup code injected by the linker, the construction and destruction code is routine boilerplate inserted by the compiler at specific fixed locations, the new/delete operators simply wrap calls to the underlying memory allocators to call constructors and destructors properly, etc... Nothing of these features knows anything of value about the source code it's injected into, they are just boilerplate function calls inserted for housekeeping. Only loading dynamic libraries can be considered as remotely "runtimey", and I have never heard of them done on anything without an OS.
>so when I use static reflection in C++, either via template metaprogramming,
This is compile-time reflection, it's neat but has nothing to do with dynamacity or runtime support. The original point was 'C++ is drastically less dynamic than Java because it's binaries are very thin and it's philosophy is that the compiler shaves as much of the high-level semantics as it can and leaves nothing but raw, dumb, optimized bits'. I don't see how compile-time metaprogramming contradicts this.
>or with the features that sadly will only come in C++26,
What are those features exactly? do they include, for example, that the compiler injects the name, fields and method signatures of every class into an inspectable object? If yes, then those features, when they come, _will_ make C++ significantly more dynamic. (and its binaries significantly fatter)
>really I just picked C++ as first example that came into my mind
A very bad example to say the least.
>We can also start discussing Eiffel, Delphi, Swift or Go
Let's do it indeed, I love discussing programming languages! What exact features or abstractions of each of those language that you think will classify it as dynamic? If they are similar in quantity or quality to what the JVM does it should indeed qualify as more dynamic than C/C++ (almost everything is). Dynamic-Static is a spectrum, not a binary category.
Ok? so This point contradicts you, the language defines no runtime support, so it's very difficult to do the things that a language with a runtime in its specification considers for-granted.
>a C++ runtime is responsible for running all global variable contructors and destructors, handling exceptions, soft floating operations, loading shared libraries on demand for dynamic linking (depending on OS capabilities or bare metal), how new/delete translation into the underlying OS/MMU.
This is a misuse of the term as used in our context, almost all of the above can be considered as simply linked/injected code. The global variable initialization is done by startup code injected by the linker, the construction and destruction code is routine boilerplate inserted by the compiler at specific fixed locations, the new/delete operators simply wrap calls to the underlying memory allocators to call constructors and destructors properly, etc... Nothing of these features knows anything of value about the source code it's injected into, they are just boilerplate function calls inserted for housekeeping. Only loading dynamic libraries can be considered as remotely "runtimey", and I have never heard of them done on anything without an OS.
>so when I use static reflection in C++, either via template metaprogramming,
This is compile-time reflection, it's neat but has nothing to do with dynamacity or runtime support. The original point was 'C++ is drastically less dynamic than Java because it's binaries are very thin and it's philosophy is that the compiler shaves as much of the high-level semantics as it can and leaves nothing but raw, dumb, optimized bits'. I don't see how compile-time metaprogramming contradicts this.
>or with the features that sadly will only come in C++26,
What are those features exactly? do they include, for example, that the compiler injects the name, fields and method signatures of every class into an inspectable object? If yes, then those features, when they come, _will_ make C++ significantly more dynamic. (and its binaries significantly fatter)
>really I just picked C++ as first example that came into my mind
A very bad example to say the least.
>We can also start discussing Eiffel, Delphi, Swift or Go
Let's do it indeed, I love discussing programming languages! What exact features or abstractions of each of those language that you think will classify it as dynamic? If they are similar in quantity or quality to what the JVM does it should indeed qualify as more dynamic than C/C++ (almost everything is). Dynamic-Static is a spectrum, not a binary category.
I got you have created your own definition of what a "dynamic" language is supposed to be, live happy.
[deleted]
The runtime is highly dynamic. You can do trickery with class loading or wizardry with bytecode manipulation.
Given the use of COM and ability to create code pages and turn them into executable code on the fly, C and C++ are also dynamic then.
>C and C++ are also dynamic then
More correctely : C/C++ + Windows, because Windows is the runtime implementing the dynamicity, the language standards alone say nothing about generating executables or operating on them, for all they care you can generate .class files from them and obtain the same benfits of java classes, it still would be the java runtime doing all the work. Java, in contrast, defines the executable generation and runtime loading to be part of the language semantics, anything you write that can't do what they say is by defintion not a "java runtime". It's like the difference between the process abstraction in Erlang and the process abstraction in linux-hosted C.
More correctely : C/C++ + Windows, because Windows is the runtime implementing the dynamicity, the language standards alone say nothing about generating executables or operating on them, for all they care you can generate .class files from them and obtain the same benfits of java classes, it still would be the java runtime doing all the work. Java, in contrast, defines the executable generation and runtime loading to be part of the language semantics, anything you write that can't do what they say is by defintion not a "java runtime". It's like the difference between the process abstraction in Erlang and the process abstraction in linux-hosted C.
[deleted]
What is a memory model?
In this context the Memory Model tells us about how memory seems to work in our concurrent program.
It only matters if your program has concurrency (more than one apparently simultaneous execution context, today often because there is physically more than one CPU core) and the concurrent elements of the program are able to read or write the same memory. Notice that at the speeds CPUs run, the actual main "memory" of the computer may take a relatively long time to read from or write to and so we use multiple layers of caches further complicating the problem.
We want: A model that's fast yet also a model that's understandable for merely human programmers. This turns out to be a difficult problem.
It only matters if your program has concurrency (more than one apparently simultaneous execution context, today often because there is physically more than one CPU core) and the concurrent elements of the program are able to read or write the same memory. Notice that at the speeds CPUs run, the actual main "memory" of the computer may take a relatively long time to read from or write to and so we use multiple layers of caches further complicating the problem.
We want: A model that's fast yet also a model that's understandable for merely human programmers. This turns out to be a difficult problem.
A definition of what exactly happens when you write to RAM.
It turns out to be much more complicated than "the data is just written", because there are other threads that could be reading and writing at the same time, multiple layers of inconsistent caches, and clever compiler optimizations or hardware that can change execution order of the code.
It turns out to be much more complicated than "the data is just written", because there are other threads that could be reading and writing at the same time, multiple layers of inconsistent caches, and clever compiler optimizations or hardware that can change execution order of the code.
The shallowness of rusts correctness has pretty much killed my interest in it.
Much of its purported virtue seems to be mostly lore and myth, and doesn't keep its promises under close inspection and scrutiny.
For example the "Rustonomicon" is only half finished and doesn't have that much real insight that couldn't be gained from a lot of basic-ish rust tutorials.
The memory model is not propery defined and only kinda sorta borrowed from C++ as the article mentions.
The borrow checker has only been formally specified and veryfied in a much simplified form and only very recently [2] ,with the actual model "just being what the borrow checker implementation does".
Having a fancy datalog based borrow checker is cool, but I'd much rather have a formal specifiation of the semantics.
Rust took the big bang approach of building everything at once, implementing stuff first without any formal semantics and just floating on an iceberg of implementation definedness, with endless discussions on how things COULD be formalized.
That's pretty much the antithesis to rusts safety philosophy; it's teaching water but drinking barrels of wine. It wants your code to be well defined and follow its rules, but its rules are completely arbitrary, not properly defined nor written down.
I wish instead they had just started with a small borrow checked core language, and grown it from there, like described in Guy Steeles talk on "growing a language" [1].
I feel that Zig is doing a much better job in this regard, they have a tiny well thoughought language with a rigorous semantic description, and a good reference document.
I'm sure it would be much easier to add a borrow checker to the well-defined-ness and correctness of Zig, than to make Rust well defined and correct.
Super friendly community though. Who knows, maybe someday the language that people think rust is, will crystalize from that community through their collective effort, like a self fulfilling prophecy.
1. https://m.youtube.com/watch?v=lw6TaiXzHAE
2. https://arxiv.org/pdf/1903.00982.pdf
Much of its purported virtue seems to be mostly lore and myth, and doesn't keep its promises under close inspection and scrutiny.
For example the "Rustonomicon" is only half finished and doesn't have that much real insight that couldn't be gained from a lot of basic-ish rust tutorials.
The memory model is not propery defined and only kinda sorta borrowed from C++ as the article mentions.
The borrow checker has only been formally specified and veryfied in a much simplified form and only very recently [2] ,with the actual model "just being what the borrow checker implementation does".
Having a fancy datalog based borrow checker is cool, but I'd much rather have a formal specifiation of the semantics.
Rust took the big bang approach of building everything at once, implementing stuff first without any formal semantics and just floating on an iceberg of implementation definedness, with endless discussions on how things COULD be formalized.
That's pretty much the antithesis to rusts safety philosophy; it's teaching water but drinking barrels of wine. It wants your code to be well defined and follow its rules, but its rules are completely arbitrary, not properly defined nor written down.
I wish instead they had just started with a small borrow checked core language, and grown it from there, like described in Guy Steeles talk on "growing a language" [1].
I feel that Zig is doing a much better job in this regard, they have a tiny well thoughought language with a rigorous semantic description, and a good reference document.
I'm sure it would be much easier to add a borrow checker to the well-defined-ness and correctness of Zig, than to make Rust well defined and correct.
Super friendly community though. Who knows, maybe someday the language that people think rust is, will crystalize from that community through their collective effort, like a self fulfilling prophecy.
1. https://m.youtube.com/watch?v=lw6TaiXzHAE
2. https://arxiv.org/pdf/1903.00982.pdf
The borrow checker wasn't conceived of until Rust had been in the works for years, so that's one reason why that wasn't what happened.
The language was grown. It just wasn't grown with an initial central feature, it was grown with an initial problem that it was trying to solve: a concurrent, safe systems language. As Rust explored the problem space, tradeoffs were identified, some were weighed differently than previously, some new strategies for solving these problems were discovered, and things were refined.
Verification is actively underway. It's totally understandable to want to wait until it's here, but that doesn't mean it's impossible, just nontrivial.
(Also, there are more papers that are older, like https://people.mpi-sws.org/~dreyer/papers/rustbelt/paper.pdf from 2018, which this paper cites.)
The language was grown. It just wasn't grown with an initial central feature, it was grown with an initial problem that it was trying to solve: a concurrent, safe systems language. As Rust explored the problem space, tradeoffs were identified, some were weighed differently than previously, some new strategies for solving these problems were discovered, and things were refined.
Verification is actively underway. It's totally understandable to want to wait until it's here, but that doesn't mean it's impossible, just nontrivial.
(Also, there are more papers that are older, like https://people.mpi-sws.org/~dreyer/papers/rustbelt/paper.pdf from 2018, which this paper cites.)
Thanks for the Paper! I'm looking forward to spend my weekend with it.
Normally I'm a huge fan of a philosophy of backwards compatibility. Clojure has shown that it is possible with enough dedication. But I feel like it's a liability with the lineage you describe for Rust, the problem space was just too unexplored and vast, and the language has grown too large.
Joe Armstrong has a joke in one of his keynotes [1], that after you've written the first version of something you should throw it away completely, because you didn't understand the problem at all when you wrote your solution, and now you understand it a bit better.
A bit like how the web would be a much less ghastly ecosystem if we hadn't abandoned versioning for living documents which force every behaviour ever to interact, and god forbid be compatible, with one another, in a combinatorial hairball.
Modern browsers would be less unwieldy if they just shipped 30 different rendering engines, one for each major revision, than ship a blob that purportedly is able to still render everyones Dogs website from the 90s (which is not true of course, it's just that nobody from the 90s is here to complain).
It's a bit ironic that Rust fell into the same trap that caused the complexity that inspired its inception in the first place.
Maybe once the major "features" for safe system programming were identified a new language (revision) should have started with only those features at its core. I fear that if such a language were to crop up now, Rust would just eat its children.
[1]: https://www.youtube.com/watch?v=lKXe3HUG2l4
Normally I'm a huge fan of a philosophy of backwards compatibility. Clojure has shown that it is possible with enough dedication. But I feel like it's a liability with the lineage you describe for Rust, the problem space was just too unexplored and vast, and the language has grown too large.
Joe Armstrong has a joke in one of his keynotes [1], that after you've written the first version of something you should throw it away completely, because you didn't understand the problem at all when you wrote your solution, and now you understand it a bit better.
A bit like how the web would be a much less ghastly ecosystem if we hadn't abandoned versioning for living documents which force every behaviour ever to interact, and god forbid be compatible, with one another, in a combinatorial hairball.
Modern browsers would be less unwieldy if they just shipped 30 different rendering engines, one for each major revision, than ship a blob that purportedly is able to still render everyones Dogs website from the 90s (which is not true of course, it's just that nobody from the 90s is here to complain).
It's a bit ironic that Rust fell into the same trap that caused the complexity that inspired its inception in the first place.
Maybe once the major "features" for safe system programming were identified a new language (revision) should have started with only those features at its core. I fear that if such a language were to crop up now, Rust would just eat its children.
[1]: https://www.youtube.com/watch?v=lKXe3HUG2l4
Any time :)
We didn't have backwards compatibility in those days. Radical change happened. I personally think of Rust as being three or four different languages before 1.0 happened. The problem is the exact same as others have said; doing that degree of breaking change all at once is effectively starting a new language project, which means that you also throw away all of the community and things that they've built so far.
Also, Rust does have a kind of "core" semantics, that is, MIR. You can't code in it directly, but it is a rich target for verification and analysis tools.
We didn't have backwards compatibility in those days. Radical change happened. I personally think of Rust as being three or four different languages before 1.0 happened. The problem is the exact same as others have said; doing that degree of breaking change all at once is effectively starting a new language project, which means that you also throw away all of the community and things that they've built so far.
Also, Rust does have a kind of "core" semantics, that is, MIR. You can't code in it directly, but it is a rich target for verification and analysis tools.
> throw away all of the community and things that they've built so far.
The sad truth is probably, that Rusts complexity is just a mirror of the entire industry.
If we, as a profession, placed a lot of value onto tiny, simple, yet well thought out (and therefore powerful) things, rewriting everything once a year wouldn't be as much of a problem.
No codebase would have more than 5kLOCs, Browsers would only do layouts with the thing we build after learning from Flexbox, Grid, and Column, and Rusts language spec would fit into a white-paper like Scheme did.
At least Rusts borrow checker uses such a tiny, well thought out, piece of code with its datafrog engine.
The sad truth is probably, that Rusts complexity is just a mirror of the entire industry.
If we, as a profession, placed a lot of value onto tiny, simple, yet well thought out (and therefore powerful) things, rewriting everything once a year wouldn't be as much of a problem.
No codebase would have more than 5kLOCs, Browsers would only do layouts with the thing we build after learning from Flexbox, Grid, and Column, and Rusts language spec would fit into a white-paper like Scheme did.
At least Rusts borrow checker uses such a tiny, well thought out, piece of code with its datafrog engine.
The problem with such a focus on smallness and simplicity, particularly at higher layers of the stack, is that it requires throwing out lots of people's must-have features. For me and my blind friends, that feature is a screen reader. For someone else, it's right-to-left or bidirectional text rendering (hello TrojanSource). For someone else, it's complex writing systems or support for Unicode characters outside the Basic Multilingual Plane. And so on. I doubt that any UI framework that fits in 5000 lines of code or less would support all these things. Sure, some of the complexity in the industry can be eliminated, but some is truly necessary. One good thing about Rust mirroring the complexity of the industry is that at least one Rust GUI toolkit, Druid, is taking accessibility and internationalization seriously. (Disclosure: the team at Google that's funding a lot of the work on Druid is also funding me to work on accessibility.)
It sounds like you'd like the work Alan Kay was doing with VPRI, though in my understanding that is over now.
Definitely! Although I'm not sure if Morphic[1] is the way forward for UX on the web, despite it only being 10kloc. XD
[Anybody stumbling upon this comment, and wondering what Alan Kay is/was up to, this might be a good philosophical starter: https://www.youtube.com/watch?v=FvmTSpJU-Xc]
1. https://lively-kernel.org
[Anybody stumbling upon this comment, and wondering what Alan Kay is/was up to, this might be a good philosophical starter: https://www.youtube.com/watch?v=FvmTSpJU-Xc]
1. https://lively-kernel.org
It's a bit unsatisfactory, but it's juste how software works: with limited time, between a complete specification and no implementation, and an implementation without specification, the former will almost always win, because you get feedback and start building traction way earlier. Obviously it feels a bit of a mess, but messy fast-moving bazaars almost always win against cathedrals.
Also, completely unrelated, I think it's really sad for Zig that it has become (against its creator will) the champion of the rust-hating crowd. Zig is cool and Andy's work on cross-compilation is a piece of art, but why is Zig in this discussion? Zig is definitely a “growing” language, not a language built on top of a formal spec. Zig doesn't have a defined memory model either!
Also, completely unrelated, I think it's really sad for Zig that it has become (against its creator will) the champion of the rust-hating crowd. Zig is cool and Andy's work on cross-compilation is a piece of art, but why is Zig in this discussion? Zig is definitely a “growing” language, not a language built on top of a formal spec. Zig doesn't have a defined memory model either!
I'm not arguing that Rust should be a Common Lisp like, Waterfall style cathedral. But for something a little less unwieldy that _could_ be specified without the collaborative effort of a hundred people. (Even more relevant now that Mozilla is no longer paying for those people.)
Rust has a lot of great ideas, but it feels more like a prototype that got out of hand, than a focused safety oriented tool. Combine that with a healthy dose of over-optimistic marketing and wishful thinking and you got yourself a bubble.
Rust is way too much cathedral than bazaar. Does it really have the need for a Macro system yet? Except for C++ template envy, JS has shown that transpilers are a workable alternative. Is the complex module system with access control necessary yet? Does the Std library need to be this comprehensive?
> but why is Zig in this discussion?
Simply because it's simple, and is perceived to target the same niche as Rust. I would have used Carp as a stand-in, but that's even more esoteric.
I'd argue that neither Rust nor Zig actually target that niche properly, Rust is too complex to get the implementation and models right, while Zig is too conservative and not having the right model as a goal.
Yet if I had to bet on either, it'd be on Zig simply because:
There are two methods in software design. One is to make the program so simple, there are obviously no errors. The other is to make it so complicated, there are no obvious errors.
-Tony Hoare
Rust has a lot of great ideas, but it feels more like a prototype that got out of hand, than a focused safety oriented tool. Combine that with a healthy dose of over-optimistic marketing and wishful thinking and you got yourself a bubble.
Rust is way too much cathedral than bazaar. Does it really have the need for a Macro system yet? Except for C++ template envy, JS has shown that transpilers are a workable alternative. Is the complex module system with access control necessary yet? Does the Std library need to be this comprehensive?
> but why is Zig in this discussion?
Simply because it's simple, and is perceived to target the same niche as Rust. I would have used Carp as a stand-in, but that's even more esoteric.
I'd argue that neither Rust nor Zig actually target that niche properly, Rust is too complex to get the implementation and models right, while Zig is too conservative and not having the right model as a goal.
Yet if I had to bet on either, it'd be on Zig simply because:
There are two methods in software design. One is to make the program so simple, there are obviously no errors. The other is to make it so complicated, there are no obvious errors.
-Tony Hoare
(TL;DR; Zig isn't simpler than Rust from an implementation perspective, quite the opposite. And the respective compiler size is a good illustration of that point)
> Simply because it's simple
But by what metric? For instance, why do you consider comptime being simpler than macros? Macros are a dedicated tool built for one specific job (compile-time pre-processing). Comptime means re-using the same syntax for both compile-time and run-time execution. Yes it is pretty elegant, and I like it but it is more complex that the Rust model because you now have a tool that needs to work with both cases.
Same async, Rust has async functions and regular function, Zig has functions that are automagically coerced to the right type by the compiler. Is it ergonomic, probably (but not always), but it's much more complex to implement under the hood.
Last but not least: memory safety, Zig's goal is to achieve a reasonable level of memory safety by using a collection of built-in sanitization tools. Those tools are much more complex than the borrow-checker (which rely on 2 simple rules: move semantic and shared XOR mutable)and for the most part they don't even exist!
Zig's goal is developer ergonomics, and it's really cool to see radical experimentations in that space (and as I said, what zig has done with regard to cross-compilation is inspirational for every language author on this planet) but it's not simplicity. Otherwise, the Zig compiler probably wouldn't be 120% bigger than the rust one right?! (Rustc has 1.6 million loc[1], Zig has 3.5[2], more than twice as much.
[1]: https://tokei.rs/b1/github/rust-lang/rust
[2]: https://tokei.rs/b1/github/ziglang/zig
> Simply because it's simple
But by what metric? For instance, why do you consider comptime being simpler than macros? Macros are a dedicated tool built for one specific job (compile-time pre-processing). Comptime means re-using the same syntax for both compile-time and run-time execution. Yes it is pretty elegant, and I like it but it is more complex that the Rust model because you now have a tool that needs to work with both cases.
Same async, Rust has async functions and regular function, Zig has functions that are automagically coerced to the right type by the compiler. Is it ergonomic, probably (but not always), but it's much more complex to implement under the hood.
Last but not least: memory safety, Zig's goal is to achieve a reasonable level of memory safety by using a collection of built-in sanitization tools. Those tools are much more complex than the borrow-checker (which rely on 2 simple rules: move semantic and shared XOR mutable)and for the most part they don't even exist!
Zig's goal is developer ergonomics, and it's really cool to see radical experimentations in that space (and as I said, what zig has done with regard to cross-compilation is inspirational for every language author on this planet) but it's not simplicity. Otherwise, the Zig compiler probably wouldn't be 120% bigger than the rust one right?! (Rustc has 1.6 million loc[1], Zig has 3.5[2], more than twice as much.
[1]: https://tokei.rs/b1/github/rust-lang/rust
[2]: https://tokei.rs/b1/github/ziglang/zig
Um that lines of code figure is nowhere near accurate.
I reported recently[1] that the zig compiler is 140,331 lines.
[1]: https://mobile.twitter.com/andy_kelley/status/14539106409432...
I reported recently[1] that the zig compiler is 140,331 lines.
[1]: https://mobile.twitter.com/andy_kelley/status/14539106409432...
Oh, interesting, thanks. Do you have any idea of where all that code comes from?
(I'm a big fan of your work btw, I'm just really sad that a few people on this forum seams to like Zig for the sole reason it's not Rust)
(I'm a big fan of your work btw, I'm just really sad that a few people on this forum seams to like Zig for the sole reason it's not Rust)
yeah I agree, it's one of my least favorite reasons that Zig gets mentioned.
All what code exactly? Here's a file-by-file breakdown of the line count:
https://clbin.com/iuMTn
All what code exactly? Here's a file-by-file breakdown of the line count:
https://clbin.com/iuMTn
“Well thought out languages with rigorous semantic descriptions” never win because those are mostly meaningless metrics. The ability to get things done and the barrier to entry are the only metrics that count. See: JavaScript.
While Rust is well thought out it’s killer feature is it’s the somewhat unique ability to allow people to get things done relatively quickly (sometimes/often with the expressiveness you’d expect from Ruby or Python) whilst also offering gigantic correctness guarantees and a (initially) low barrier to entry.
But hey, Zig has a reference document. Watch out World!
While Rust is well thought out it’s killer feature is it’s the somewhat unique ability to allow people to get things done relatively quickly (sometimes/often with the expressiveness you’d expect from Ruby or Python) whilst also offering gigantic correctness guarantees and a (initially) low barrier to entry.
But hey, Zig has a reference document. Watch out World!
> “Well thought out languages with rigorous semantic descriptions” never win because those are mostly meaningless metrics. The ability to get things done and the barrier to entry are the only metrics that count.
Perl was successful for a time but its messiness eventually caught up to it. OCaml is of the same vintage and grew much more slowly but I dare say it has a brighter future.
Perl was successful for a time but its messiness eventually caught up to it. OCaml is of the same vintage and grew much more slowly but I dare say it has a brighter future.
I don't think Rust is messy in the same way Perl is simply because nobody has written a formal specification.
Unsafe Rust, the undecided interactions between &mut and raw pointers and Pin (whether &mut should invalidate raw pointers permanently or until the &mut ends, whether Pin<&mut T> should be noalias), whether Vec is marked noalias (it isn't) or Box should be (it is), is a mess of undecided semantics that people are nonetheless writing code that hopefully won't be declared illegal in a future compiler version.
The scope of noalias semantics can be formalized quite precisely by using separation logic, which can also act as a simpler and more elegant foundation for Rust's reference semantics and the so-called borrow-checker. Future editions of Rust will probably do this at some point in order to gain these benefits.
C++ has a standard that is thousands of pages long and I needed to look something up in it exactly zero times. On the other hand, I encountered hundreds of segfaults and race conditions and unlike Rust the well defined C++ language did nothing to prevent it. I would take a poorly specified borrow checker over extremely pedantic spec in a heartbeat.
My experience is different. The C/C++ (draft) standards documents are regular reference material for me, alongside web searches, man pages, & experimentation. (the draft standards are available for free in pdf format and for all practical purposes I've been able to treat them as the real thing)
Experimentation is great for determining what the implementation you have right in front of you does, but not what is actually guaranteed. Trusted reference web pages are great (in fact, cppreference is AWESOME particularly for highlighting text that applies version-by-version of the C++ standard) but don't always cover everything. "I googled my error message" hits are so-so (but they may help you solve your problem)
But the standard is the document that really lets you get into "how SHOULD it work", even while doing battle with 2 or 3 different compilers each of which implement a subset of the language you are theoretically programming in. (It's getting better, but finding a compatible subset of C++ supported by MSVC, gcc and clang used to be an interesting puzzle. And what a bummer to "switch to C++14" -- only with a list of caveats longer than most people will even read through, because of differential language & library support)
I'd also just recommend becoming comfortable finding & extracting data from a daunting document like the C++ standard! It's a skill that will stand you in good stead, and I feel like being able to do this is one of the things that led to having my technical expertise well appreciated at work.
Experimentation is great for determining what the implementation you have right in front of you does, but not what is actually guaranteed. Trusted reference web pages are great (in fact, cppreference is AWESOME particularly for highlighting text that applies version-by-version of the C++ standard) but don't always cover everything. "I googled my error message" hits are so-so (but they may help you solve your problem)
But the standard is the document that really lets you get into "how SHOULD it work", even while doing battle with 2 or 3 different compilers each of which implement a subset of the language you are theoretically programming in. (It's getting better, but finding a compatible subset of C++ supported by MSVC, gcc and clang used to be an interesting puzzle. And what a bummer to "switch to C++14" -- only with a list of caveats longer than most people will even read through, because of differential language & library support)
I'd also just recommend becoming comfortable finding & extracting data from a daunting document like the C++ standard! It's a skill that will stand you in good stead, and I feel like being able to do this is one of the things that led to having my technical expertise well appreciated at work.
I drink milk daily and I've had to milk a cow exactly zero times. On the other hand, I've had to go to the grocery store to buy milk hundreds of times.
Obviously the spec isn't meant to be a necessary read for end users.
Obviously the spec isn't meant to be a necessary read for end users.
> I feel that Zig is doing a much better job in this regard, they have a tiny well thoughought language with a rigorous semantic description, and a good reference document.
Zig doesn't have a "rigorous semantic description" that I can find. The reference manual is nowhere close. A "rigorous semantic description" would either be something like that of Java or, ideally, the operational semantics of Standard ML.
I glanced through the "undefined behavior" section in the Zig reference and didn't see use-after-free anywhere. If it's that easy to find missing pieces then it isn't even close to complete. Wait until you start trying to define things like pointer provenance; it's not even clear what semantics LLVM is upholding.
> Having a fancy datalog based borrow checker is cool, but I'd much rather have a formal specifiation of the semantics.
There's a formal specification of the semantics of a subset of Rust via the RustBelt project. These things almost always start with a subset of the language; very few languages have full formal specifications, including type safety proofs and so forth. I should note that I'm not aware of any such work being done for Zig.
> The memory model is not propery defined and only kinda sorta borrowed from C++ as the article mentions.
Where's Zig's memory model? I didn't find it. I did find a GitHub issue [1], which has had far less discussion than that surrounding Rust's memory model.
> I'm sure it would be much easier to add a borrow checker to the well-defined-ness and correctness of Zig, than to make Rust well defined and correct.
No. This is just like the "just add a borrow checker to C++" meme that we used to see until it was shown to be infeasible. The problem is simple: if you can have mutable aliasing today in Zig, which you can, then the ecosystem has surely come to depend on it. So by forbidding mutable aliasing, you will break tons of code. It is not a reasonable subset of code to break; in C++ the set of code you break is "all methods" (because "this" has unrestricted aliasing), and I expect Zig to be no different. The borrow checker is not some magic dust that you sprinkle on a language to make it memory safe; the entire language, including all libraries, must be designed around it. Are you really going to break the doubly linked lists everyone has written?
It is impossible to overstate how much memory safety in Rust is balanced on a delicate precipice; if the language has already fallen off the precipice I'm confident in saying it will never go back. (Well, unless you add a GC, which is what I'd do if I were in charge of Zig. There's nothing wrong with GC; it's the tool people have created for the very purpose of ensuring memory safety with unrestricted aliasing.)
[1]: https://github.com/ziglang/zig/issues/6396
Zig doesn't have a "rigorous semantic description" that I can find. The reference manual is nowhere close. A "rigorous semantic description" would either be something like that of Java or, ideally, the operational semantics of Standard ML.
I glanced through the "undefined behavior" section in the Zig reference and didn't see use-after-free anywhere. If it's that easy to find missing pieces then it isn't even close to complete. Wait until you start trying to define things like pointer provenance; it's not even clear what semantics LLVM is upholding.
> Having a fancy datalog based borrow checker is cool, but I'd much rather have a formal specifiation of the semantics.
There's a formal specification of the semantics of a subset of Rust via the RustBelt project. These things almost always start with a subset of the language; very few languages have full formal specifications, including type safety proofs and so forth. I should note that I'm not aware of any such work being done for Zig.
> The memory model is not propery defined and only kinda sorta borrowed from C++ as the article mentions.
Where's Zig's memory model? I didn't find it. I did find a GitHub issue [1], which has had far less discussion than that surrounding Rust's memory model.
> I'm sure it would be much easier to add a borrow checker to the well-defined-ness and correctness of Zig, than to make Rust well defined and correct.
No. This is just like the "just add a borrow checker to C++" meme that we used to see until it was shown to be infeasible. The problem is simple: if you can have mutable aliasing today in Zig, which you can, then the ecosystem has surely come to depend on it. So by forbidding mutable aliasing, you will break tons of code. It is not a reasonable subset of code to break; in C++ the set of code you break is "all methods" (because "this" has unrestricted aliasing), and I expect Zig to be no different. The borrow checker is not some magic dust that you sprinkle on a language to make it memory safe; the entire language, including all libraries, must be designed around it. Are you really going to break the doubly linked lists everyone has written?
It is impossible to overstate how much memory safety in Rust is balanced on a delicate precipice; if the language has already fallen off the precipice I'm confident in saying it will never go back. (Well, unless you add a GC, which is what I'd do if I were in charge of Zig. There's nothing wrong with GC; it's the tool people have created for the very purpose of ensuring memory safety with unrestricted aliasing.)
[1]: https://github.com/ziglang/zig/issues/6396
> The problem is simple: if you can have mutable aliasing today in Zig, which you can, then the ecosystem has surely come to depend on it. ... The borrow checker is not some magic dust that you sprinkle on a language to make it memory safe; the entire language must be designed around it.
That's a good description of the issue. It's a bit frustrating to see this notion that Zig is "safe" or comparable to Rust in regards to memory safety at all. I don't prefer to use Rust as it's verbose, but at least I realize that if you don't do the lifetime analysis dance in the language you essentially need a GC to manage memory.
That's a good description of the issue. It's a bit frustrating to see this notion that Zig is "safe" or comparable to Rust in regards to memory safety at all. I don't prefer to use Rust as it's verbose, but at least I realize that if you don't do the lifetime analysis dance in the language you essentially need a GC to manage memory.
> I don't prefer to use Rust as it's verbose, but at least I realize that if you don't do the lifetime analysis dance in the language you essentially need a GC to manage memory.
Yep. And that's totally valid! I like using GC'd languages; garbage collectors are some of the most successful systems ever developed in computer science.
Yep. And that's totally valid! I like using GC'd languages; garbage collectors are some of the most successful systems ever developed in computer science.
It's true, GC's can often even outperform manual memory management or Rust's lifetime analysis. Having a GC doesn't necessarily prevent a language from being a good system language either. I'm using Nim with its ARC garbage collector on embedded devices and it's been fantastic.
ARC garbage collectors can suffer from reference cycles right? Is that something this Nim configuration suffers from? If so, is it something you noticed in practice?
Yes, it can. In practice most data seems non-cyclic, and Nim has an 'acyclic' annotation to support it.
However as Rust has shown, creating non-cyclic async is really hard, and Nim isn't an exception. That means async requires you run ARC with a cycle collector (ORC), and it works on embedded with tweaks. Overall the non-locking ARC + move semantics runs really fast even on MCUs so it's worth avoiding cyclic strctures.
However as Rust has shown, creating non-cyclic async is really hard, and Nim isn't an exception. That means async requires you run ARC with a cycle collector (ORC), and it works on embedded with tweaks. Overall the non-locking ARC + move semantics runs really fast even on MCUs so it's worth avoiding cyclic strctures.
> It is impossible to overstate how much memory safety in Rust is balanced on a delicate precipice
I've been working with Rust since pre 1.0 and I've felt fearless running on bare metal coming from a Higher Level Language for most of my career. It's almost embarrasing how care free it is to never have to think about memory management. But I did take a look at Zig earlier this year and noped out because of it's lack for memory safety. Although it does look nicer than C, for me it feels like I would be going backwards
I've been working with Rust since pre 1.0 and I've felt fearless running on bare metal coming from a Higher Level Language for most of my career. It's almost embarrasing how care free it is to never have to think about memory management. But I did take a look at Zig earlier this year and noped out because of it's lack for memory safety. Although it does look nicer than C, for me it feels like I would be going backwards
> Well, unless you add a GC, which is what I'd do if I were in charge of Zig.
That is probably not feasible either, for the same reasons the borrow checker isn't feasible. Zig has this pattern where they manually pass in an allocator into functions that allocate memory.
That is probably not feasible either, for the same reasons the borrow checker isn't feasible. Zig has this pattern where they manually pass in an allocator into functions that allocate memory.
[I'm moving my comment from the flagged tree over here because I feel that it captures the spirit of my lament of Rust, and your interpretation of it being a pure endorsement of Zig, rather well.]
TL;DR: Yes! But Zig is a Stand-in for simpler languages, and while in its infancy, I can foresee it to be "done" and "fit into my head". Rust requires a suspension of disbelief that all this complexity can be tamed and "gotten right" with a leap of faith for the language, compiler and everybody in-between including myself, that I'm not confident enough in the "proofs" it provides.
To quote from Dijkstras essay "On the cruelty of really teaching computing science".
> Right from the beginning, and all through the course, we stress that the programmer's task is not just to write down a program, but that his main task is to give a formal proof that the program he proposes meets the equally formal functional specification. While designing proofs and programs hand in hand, the student gets ample opportunity to perfect his manipulative agility with the predicate calculus. Finally, in order to drive home the message that this introductory programming course is primarily a course in formal mathematics, we see to it that the programming language in question has not been implemented on campus so that students are protected from the temptation to test their programs. And this concludes the sketch of my proposal for an introductory programming course for freshmen.
There are more ways to proof correctness than through the type system alone. Clojure programs are surprisingly high quality despite being dynamically typed.
I feel almost sorry to not write a lengthy counter rebuttal, but sadly I agree with everything you said! Would I prefer Zig to have a strong type system? Absolutely! But the reason why I choose Zig as a comparator is not because I feel that it does a significantly better Job at correctness than Rust, neither do.
If I had to build something (computer)-verifiably correct I'd probably Idris with linear types or something, but that's not a real contender, and neither is Carp or Linear Haskell.
But I feel that with Zig I'd at least have the chance to proof the correctness of my program manually (with pen & paper). Although Zig is still heavily in flux it's simple and focused enough that it "being done", and me thus being able to understand it in its entirety, is at least visible on the horizon (especially if one compares the number of people working on Zig with the number of People working on Rust, although Carp might be an even better contender in that metric). With Rusts complexity that date is much further out into the future, if it is ever achievable. And even then I'm not sure that I could fit enough of the language into my head to be satisfied with the correctness of my proofs, ignoring the fact that it also has to fit into the compiler devs heads sufficiently to ensure that the compiler is behaving correctly.
My critique is that little of this complexity actually comes from the parts that make the language unique, non of it is essential, it's all incidental complexity. It's in the macro system, the standard library, the hand-wavy semantics of "box" and compiler intrinsics, of magic low level traits, an overzealous module system, way to much fondness of abstractions and their removal by compiler magic, and a general nonchalantness towards C++'s complexity.
TL;DR: Yes! But Zig is a Stand-in for simpler languages, and while in its infancy, I can foresee it to be "done" and "fit into my head". Rust requires a suspension of disbelief that all this complexity can be tamed and "gotten right" with a leap of faith for the language, compiler and everybody in-between including myself, that I'm not confident enough in the "proofs" it provides.
To quote from Dijkstras essay "On the cruelty of really teaching computing science".
> Right from the beginning, and all through the course, we stress that the programmer's task is not just to write down a program, but that his main task is to give a formal proof that the program he proposes meets the equally formal functional specification. While designing proofs and programs hand in hand, the student gets ample opportunity to perfect his manipulative agility with the predicate calculus. Finally, in order to drive home the message that this introductory programming course is primarily a course in formal mathematics, we see to it that the programming language in question has not been implemented on campus so that students are protected from the temptation to test their programs. And this concludes the sketch of my proposal for an introductory programming course for freshmen.
There are more ways to proof correctness than through the type system alone. Clojure programs are surprisingly high quality despite being dynamically typed.
I feel almost sorry to not write a lengthy counter rebuttal, but sadly I agree with everything you said! Would I prefer Zig to have a strong type system? Absolutely! But the reason why I choose Zig as a comparator is not because I feel that it does a significantly better Job at correctness than Rust, neither do.
If I had to build something (computer)-verifiably correct I'd probably Idris with linear types or something, but that's not a real contender, and neither is Carp or Linear Haskell.
But I feel that with Zig I'd at least have the chance to proof the correctness of my program manually (with pen & paper). Although Zig is still heavily in flux it's simple and focused enough that it "being done", and me thus being able to understand it in its entirety, is at least visible on the horizon (especially if one compares the number of people working on Zig with the number of People working on Rust, although Carp might be an even better contender in that metric). With Rusts complexity that date is much further out into the future, if it is ever achievable. And even then I'm not sure that I could fit enough of the language into my head to be satisfied with the correctness of my proofs, ignoring the fact that it also has to fit into the compiler devs heads sufficiently to ensure that the compiler is behaving correctly.
My critique is that little of this complexity actually comes from the parts that make the language unique, non of it is essential, it's all incidental complexity. It's in the macro system, the standard library, the hand-wavy semantics of "box" and compiler intrinsics, of magic low level traits, an overzealous module system, way to much fondness of abstractions and their removal by compiler magic, and a general nonchalantness towards C++'s complexity.
> I feel almost sorry to not write a lengthy counter rebuttal, but sadly I agree with everything you said! Would I prefer Zig to have a strong type system? Absolutely! But the reason why I choose Zig as a comparator is not because I feel that it does a significantly better Job at correctness than Rust, neither do.
In other words your argument was incoherent and you have no rebuttal. What a nice way to tap out.
Saying that Rust isn’t verified enough is fine. But the post became incoherent when you brought up another language.
In other words your argument was incoherent and you have no rebuttal. What a nice way to tap out.
Saying that Rust isn’t verified enough is fine. But the post became incoherent when you brought up another language.
> TL;DR: Yes! But Zig is a Stand-in for simpler languages, and while in its infancy, I can foresee it to be "done" and "fit into my head".
I don't understand how something that isn't type-safe (memory safety is a subset of type safety) can be more "correct" than a language that is type-safe. It is certainly hard to prove Rust type-safe. But that's infinitely easier than proving something type-safe that trivially isn't type-safe!
> way to much fondness of abstractions and their removal by compiler magic
Heavy reliance on abstractions is what makes lifetimes and the borrow check practical. For example, Arc, Mutex, Cell, and RefCell are all abstractions that serve as library-defined extensions of the core lifetime rules and are generally optimized away by the compiler. I'm extremely skeptical that lifetimes/borrow checking can be made usable without this sort of thing. If a goal of Zig is to minimize abstractions, then that's yet another reason why adding a borrow check to it after the fact will not work.
When Zig is "done", it will have to either (a) become memory safe by addition of Rust's lifetimes/borrow check (which is probably impossible, but if it were done it would bring about a lot of the things you say you don't like about Rust); (b) become memory safe by addition of a GC (which is the best option, but I doubt it will do so); (c) not be memory safe (which is probably what will happen). There is no free lunch. And then, assuming it chooses option (c), you run into this problem: Try as hard as you want, you will never be able to prove a language type-safe that isn't type-safe, no matter how simple that language is.
I don't understand how something that isn't type-safe (memory safety is a subset of type safety) can be more "correct" than a language that is type-safe. It is certainly hard to prove Rust type-safe. But that's infinitely easier than proving something type-safe that trivially isn't type-safe!
> way to much fondness of abstractions and their removal by compiler magic
Heavy reliance on abstractions is what makes lifetimes and the borrow check practical. For example, Arc, Mutex, Cell, and RefCell are all abstractions that serve as library-defined extensions of the core lifetime rules and are generally optimized away by the compiler. I'm extremely skeptical that lifetimes/borrow checking can be made usable without this sort of thing. If a goal of Zig is to minimize abstractions, then that's yet another reason why adding a borrow check to it after the fact will not work.
When Zig is "done", it will have to either (a) become memory safe by addition of Rust's lifetimes/borrow check (which is probably impossible, but if it were done it would bring about a lot of the things you say you don't like about Rust); (b) become memory safe by addition of a GC (which is the best option, but I doubt it will do so); (c) not be memory safe (which is probably what will happen). There is no free lunch. And then, assuming it chooses option (c), you run into this problem: Try as hard as you want, you will never be able to prove a language type-safe that isn't type-safe, no matter how simple that language is.
I've written correctness proofs for C code, both as part of my university classes and after.
Unless rust has some kind of de brujn criterion (and properly formalising and bootstrapping it from a minimal core would be a step into that direction) the proof it produces is only as good as the entirety of the codebase.
Correctness proofs are an inherently human thing, it's all about convincing your opponent of the proofs validity.
One could of course build a language on inuitionistic math a a la coq, but that's neither practical nor realistic, so human checked proofs will have to do, and simplicity helps a lot with those.
Edit: It feels like we're talking about subtly different definitions of correctness. I mainly care about correctness of specific program instances, whereas you are more concerned about the correctness of every instance expressable by the language.
Unless rust has some kind of de brujn criterion (and properly formalising and bootstrapping it from a minimal core would be a step into that direction) the proof it produces is only as good as the entirety of the codebase.
Correctness proofs are an inherently human thing, it's all about convincing your opponent of the proofs validity.
One could of course build a language on inuitionistic math a a la coq, but that's neither practical nor realistic, so human checked proofs will have to do, and simplicity helps a lot with those.
Edit: It feels like we're talking about subtly different definitions of correctness. I mainly care about correctness of specific program instances, whereas you are more concerned about the correctness of every instance expressable by the language.
> I feel that Zig is doing a much better job in this regard
Let's wait until Zig gets a proper specification before trying to argue that we're doing a good job. Rust is much more mature and I'm sure people have found, fixed and standardized things that we probably have never even experienced once, ever.
Right now the compiler doesn't even implement the full language.
Let's wait until Zig gets a proper specification before trying to argue that we're doing a good job. Rust is much more mature and I'm sure people have found, fixed and standardized things that we probably have never even experienced once, ever.
Right now the compiler doesn't even implement the full language.
Uh oh, I sense a sequel Tannenbaum v Linus debate developing ...
More like: pcwalton leaves a comment and the OP never replies to his comment
OTOH - TIL : LiveJournal.com still exists, even when giants such as Friendster, MySpace, Google Plus #cough have perished. Awesome.
[deleted]
I’m but a mere mortal but as an outside observer of rust and from my playing around with it, doesn’t the borrow checker currently handle this for you? Ie part of fearless concurrency?
The lifetimes and borrow check make sure that, no matter what order your threads execute in, nothing bad (memory-unsafe) will ever happen. But it doesn't say anything about what that order is. That's what a memory model is for.
(This is handwavy and imprecise, but it's the easiest way I can explain it.)
(This is handwavy and imprecise, but it's the easiest way I can explain it.)
I agree, the “fearlessness” is that you can’t share things which must not be shared (e.g. unprotected writables). And there are a fair amount of quite ergonomic constructs to manage concurrency.
However if you start manipulating atomics directly, while the atomics themselves are safe the behaviour can get quite complicated below seqcst. The latest Crust of Rust is rather illuminating to the subject for people like me who had not had to really dive into the logic and implications.
However if you start manipulating atomics directly, while the atomics themselves are safe the behaviour can get quite complicated below seqcst. The latest Crust of Rust is rather illuminating to the subject for people like me who had not had to really dive into the logic and implications.
That's very interesting. I would have intuitively expected that memory reordering could have been used to subvert memory safety. Can you point me to more discussion on the topic?
Atomics and mutexes constrain memory reordering. Stores and loads while holding a mutex can't be reordered so that they happen outside the mutex lock (because there are compiler/cpu memory barriers in mutex lock/unlock).
Given that Rust's "shared xor mutable" is guarded with mutexes, atomics or other synchronization primitives, memory reordering can't violate Rust's memory safety guarantees (assuming the sync primitives are not buggy, of course).
But this does not guarantee that your program logic is correct if you are sloppy with memory ordering. If you do two atomic stores, they are not guaranteed to occur in that order if they use "relaxed" ordering.
This gets more complex if you're writing device drivers and communicating with peripheral devices using memory mapped i/o.
Given that Rust's "shared xor mutable" is guarded with mutexes, atomics or other synchronization primitives, memory reordering can't violate Rust's memory safety guarantees (assuming the sync primitives are not buggy, of course).
But this does not guarantee that your program logic is correct if you are sloppy with memory ordering. If you do two atomic stores, they are not guaranteed to occur in that order if they use "relaxed" ordering.
This gets more complex if you're writing device drivers and communicating with peripheral devices using memory mapped i/o.
But atomicptr can be used with relaxed constraints, so in principle a consumer can see the pointed object before it was initialized.
I think rust gets away because atomic ptr is a raw ptr so it can only be dereferenced in unsafe code. So in practice relaxed atomics require unsafe where it matter (which makes sense to me).
I think rust gets away because atomic ptr is a raw ptr so it can only be dereferenced in unsafe code. So in practice relaxed atomics require unsafe where it matter (which makes sense to me).
Rust will ensure that you don't have any undefined behavior or use-after-free when you write a concurrent program.
The memory model is something different: it governs how memory operations are ordered in a concurrent system. You can have a program with no memory safety issues, but nonetheless is not correct (that is, it doesn't do what you expect it to do) because you have specified the wrong memory ordering of some operations.
The memory model is something different: it governs how memory operations are ordered in a concurrent system. You can have a program with no memory safety issues, but nonetheless is not correct (that is, it doesn't do what you expect it to do) because you have specified the wrong memory ordering of some operations.
[deleted]
The purpose of the unsafe keyword is to mark code that could cause safety issues (undefined behavior) if its expectations about the surrounding code are not met. (e.g., an unchecked array access expects its index to be in bounds).
With unlimited time and resources, it should be possible to give a mathematical proof about those unsafe blocks, showing that their expectations are met and that undefined behavior will not actually occur (by for example proving that the array index supplied will always be in bounds).
But since relaxed and consume have unclear semantics, it hard to formally reason about the correctness of any code that uses them (and it is also hard to build tools to do that checking, as mentioned).
So in an ideal world, we would have a 100% clear semantics for all parts of the language, _including_ the unsafe fragment. Unsafe should not be a place to put poorly specified languages features.
Of course, we don't live in this ideal world, but I would rather hope for fixing the memory model so that it be made completely formal and precise (if necessary, giving up some currently allowed compiler/hardware optimizations if they're more trouble than they are worth). As an extreme example, sequential consistency is easy to understand as long as you don't mix it with other types of atomic accesses and avoid non-atomic data races.