Hacker News new | past | comments | ask | show | jobs | submit login
Changing the Rules of Rust (without.boats)
190 points by kevincox on Sept 17, 2023 | hide | past | favorite | 127 comments



Since a good number of people will arrive at this thread without a lot of surrounding context, would some of us 'in the know' please share some remarks about:

1. Who should read this? Who should care? Why?

2. Help me situate this relative to other Rust commentary, ideas, and recommendations. Where does this sit?

3. What's been happening in the Rust world that may have motivated boats to write this blog post now?

Context: For many years, I've enjoyed and written a lot of Rust, but I don't necessarily keep up with the 'inner sanctum' conversations.


Hi, this is boats.

1. If you don't read and comment on Rust RFCs, this post is probably not relevant to you.

2. This post is not even a suggestion for Rust to do anything, it's about how if you want to make a certain kind of change, the barrier to do so backward compatibly is very high.

3. A lot of Rust contributors have recently talked about how they think Rust should have linear types (which involves adding a trait like "Leak"). Some of the commentary suggests people don't realize how big of a hurdle there is to doing this. This is a detailed explanation of how difficult it is.

I don't know why my blog posts show up on the front page of Hacker News all the time!


Your blog posts rise on HN because, from what little I've read, you don't mince words and you talk about the issues directly.

Other big-names play word games and rely on the "I'm popular with the community" shtick to back up their weak ideas. Your take on Rust feels strong and honest by comparison. I particularly appreciate the awareness of the importance of "non-technical" work in shepherding an OSS project.


This is mostly about async Rust. Today, async Rust works, but it's a lot jankier and less polished than the rest of the language; in some ways, it feels kind of beta-quality. The maintainers are extremely aware of this problem and are making serious efforts to fix it. These efforts are expected to take years, because it is a difficult technical problem (most other languages solve it in ways that compromise runtime performance or low-level programmer control, which Rust prioritizes) and because a community-driven open source project has limited resources and takes time to arrive at decisions.

This blog post discusses one potential technical direction for addressing a subset of the problems with async Rust, and in particular why the need to preserve backwards compatibility with earlier design decisions, made before async Rust was designed, makes it harder than it might otherwise have been.

If you don't use async Rust, you can mostly ignore this. If you do use async Rust, and are interested in how it might (backwards-compatibly!) evolve in the coming years, then the report of the Async Foundations Working Group is probably the best starting point to understand what's going on: https://rust-lang.github.io/wg-async/welcome.html


With respect to Rust, a big part of the issue here is that Rust is roughly attempting to turn Javascript into a systems programming language. While `async` as a language feature is a key part of the ideas of JS, it's essentially fundamentally incompatible with 0-overhead systems programming (despite most systems programming using asynchronous concepts).

Asynchronous systems code is usually done completely stackless, using state machines and queues of objects, and batched functions that run to completion to transition objects to the next state. This gives you maximum cache-friendliness, and since good performance fundamentally comes from limiting your options, making unbounded state growth hard is a pretty good way to get it. Async functions with stacks are already a performance compromise in comparison. You can do this type of programming in Rust by just not using async.

Unfortunately, some fraction of Rust maintainers are pulling back towards Rust being a web backend language (see the earlier comment about Javascript), and that really needs a concept of stackful async, but really doesn't need the same level of performance as a kernel or a filesystem. Rust green threads would even have worked really well for this use case, despite being a huge mismatch with traditional systems programming. In addition, those async functions need some sort of runtime anyway, so it's not even really a standalone language feature distinct from the packages that enable it.

So here we are, in a situation around async that makes nobody happy. C++ coroutines, despite the hate they get, and Go green threads, despite the performance, actually seem to be closer to the mark.


> So here we are, in a situation around async that makes nobody happy. C++ coroutines, despite the hate they get, and Go green threads, despite the performance, actually seem to be closer to the mark.

The story that gets told -- and for some reason, I tend to believe it, even though I'm much less idealistic than 10 years ago -- is that folks in the Rust community (and not just them of course, also C++ people and lots of others) tend to really push hard against abstractions that aren't both ergonomic and zero-cost. It is frankly kind of amazing to see the solutions that emerge after months and years of what somewhere resembles either head-butting or crazy experimentation. The path to getting there is messy, and it seems like maybe that's where we are now.


Rust async is stackless. While it's not as seamless as javascript yet, it too started with generators and janky promise libraries before - a decade later - support for it became ubiquitous.

Rust will undergo a similar journey where the compiler will continually make improvements to ease of use, arriving at a very elegant solution that's just as easy to use.


I'm pretty sure Rust people invented a new meaning for the word "stackless": in Rust's context it means "does not depend on an underlying architectural stack." It does not mean "does not use the concept of a stack." That would be why you can have stack traces of async functions in Rust.

In practice it means allocating a stack for your function of the maximum size you would need.


Rust didn't invent the terminology.

Rust has stackless coroutines. Go has stackful coroutines. The literal distinction is that stackless coroutines exist independently of a stack: you have a state machine object that, when it's resumed into, is put onto the stack and calls normal functions through the run-time stack. Stackful coroutines are essentially lightweight threads (hence "green threads" or "virtual threads"): the coroutine comes with a miniature run-time stack.

In practice, stackless coroutines are more space efficient and have lower context-switch costs. A big factor for Rust specifically is interoperating with C. Stackful coroutines make writing "straight-line" code simpler (no function coloring) but introduce more overhead.


async is just a syntactic sugar to transform a function into the state machine you would have otherwise written. If you don't trust the compiler to perform this transform adequately, you can write a Future by hand. There are users using async/await syntax to write software for microcontrollers without dynamic memory allocation, runtimes like tokio are not a hard dependency of async/await syntax or the Future abstraction.

C++ coroutines perform the same transform, but they dynamically allocate that state machine for every coroutine (unless the optimizer can eliminate the allocation). In contrast, Rust inlines every coroutine into a single state machine until you explicitly tell it to allocate it. I don't know why C++ chose a less "zero cost" solution; I suspect the fact that they don't have memory safety and so letting you own a coroutine's state is very footgunny in C++ played a part in their choice.


Can you explain what makes C++ coroutines work better than Rust futures?


The lack of a Leak-style trait is so painful for me that I’d strongly consider switching to a different language that was basically just rust + Leak. (Adding move and removing pin would be nice to have too.)

The problem with not having Leak is that it forces all futures to be ‘static, which effectively disables the borrow checker whenever you cross an async spawn boundary.

Arc is the standard solution, but, for multi-threaded code, it can be expensive (though also more predictable, in fairness) than having a GC.

The only other solution (which I prefer) is to use an unsafe version of spawn that assumes the future it returns will not be leaked.

This certainly breaks the rust promise of supporting safe, zero cost abstractions.


I have seen many comments like this, and I would like to understand better I feel like I'm missing something. In my daily use case(web api) of async rust we use rarely spawn. What are the use case of intensive spawn usage ?

And there is tokio::spawn_local() that doesn't require Send


> Arc is the standard solution, but, for multi-threaded code, it can be expensive (though also more predictable, in fairness) than having a GC.

Do you have measurements to back that up? I would expect it to be a lot cheaper than GC because GCed language such as Java/Go/etc. GC every heap allocation (and Java in particular doesn't have unboxed objects, so there are a lot of heap allocations!) where Arc<RequestState> would bundle a bunch of things together.

That said, I'm not in love with the Arc solution either. It's not just the performance impact. The programming model of scoped concurrency is just much more pleasant. Giving up that and then also having to deal with putting everything into an Arc, and likely also then wrapping it in a MutexLock, even when it should really be one thread at a time dealing with the value in question, just doesn't do anything good for readability and writability of code...


The reason Arc hurts is that it's global and atomic. Multiple cores competing to inc/dec a shared reference count is pretty much a worst case scenario for modern CPUs.

Under modern garbage collection, there's very little need to coordinate across cores, just some barriers every now and then to mark things safe.


True, but impact depends on both total core count you're using in the process and what you're doing with Arc. I'm imagining Arc<RequestState> with low concurrency on each RequestState so impact of cache line bouncing should be negligible.

If you're talking about Arc<GlobalStuff>, I'd probably use Box::leak instead. There's also a few crates that do epoch gc. I haven't really written super high request rate multicore stuff in Rust, but I have in C++, and there for global-ish occasionally updated config stuff, I used a epoch gc implementation on top of Linux rseq to totally eliminate the effect you're describing.


The classic case where Arc hurts and GC really shines is Arc<Config>.

Arc makes otherwise read-only activity write.


How many independent things do you need to access the config? A dozen? Then there is a dozen calls to inc the RC at startup, but there is no later cost to read from object behind Arc. So you may read your config a million times and Arc changes nothing. While GC will have to scan the Config structure every time a full GC is needed, so there is an added cost.


The C++ server I mentioned had an "experiment config" that would be used to roll out changes (user-visible features, backend code path changes for data store migrations, etc.) incrementally, and it picked up config changes without a restart. Each request needed to grab the current experiment config once and hold onto it for a consistent view. This server reserved ~16 cores and had pretty high request rates, so Arc<Config> would indeed hit the sort of problem yencabulator is describing. And I imagine it'd get pretty bad if each server crossed NUMA node boundaries (although I recommend avoiding that if you can regardless).

In this case, the Linux rseq-based epoch GC worked perfectly. It is technically a form of garbage collection, but it's not all-or-nothing like Java-style or Bohm GC; it's just a library you use for a few highly used, infrequently updated data structures.

btw, Arc<Config> doesn't really seem relevant to the discussion of scoped concurrency. Scoped concurrency can often replace or reduce the need for Arc<RequestState> but not Arc<Config>.


I'm not really understanding why you need to clone the Arc so many times. At most it seems that you'd do so once per request.


"So many times" = one increment + one decrement per request = 100k/sec maybe, bouncing cache lines across 16 cores. This is suboptimal but not world-ending.


Definitely suboptimal, no question.


In the trouble scenario, config may change at any time. A getter function has to return a freshly-incremented Arc<Config>, which lives for roughly one request.


>because GCed language such as Java/Go/etc. GC every heap allocation

GC is not run on every allocation.


They don't run GC, but they subject the extra pointers to GC, whereas good RC languages generally support values. Doing nothing is usually cheaper than doing something.

This is more a Java problem than a GC problem; C# supports values just fine, and some heroic JVM-targetting languages also manage it.


But then mark and sweep also doesn’t have to free the memory (it just does a bulk sweep occasionally), and RC languages do need to free memory used to hold the values later (they have to run expensive compaction and locking steps). So definitely more a Java problem with poor allocation hoisting and reuse than a difference between the RC and mark and sweep implementations of GC.


Rust isn't an "RC" language like Java is a "GC" language. It's more flexible than that. Rust has actual unboxed/value structs; it has Box (heap allocation without reference counting); it has Rc (single-thread/no-atomic reference counting); it has Arc (atomic reference counting); it has epoch gc libraries. And, regarding this phrase:

> RC languages do need to free memory used to hold the values later

It also has arena allocator libraries, so you can group allocations that will be freed at a similar time. You don't need to walk through these to find them all and individually free them. Any Drop impls (destructors/finalizers) get handled via a lifo; then the arena's relatively few, large blocks get freed.

I've used this in C++ to squeeze some more performance out of a high-request-rate server. I haven't done it in Rust, but it's the same idea (but safer of course).

btw, little confused by this:

> [RC languages?] have to run expensive compaction and locking steps

Was this meant to refer to GC languages? I've never seen a non-GC language that does compaction (we're talking about the same thing? moving all the heap allocations around to reduce fragmentation?) That's more of a GC language thing that e.g. Java does; it would require the language/runtime to be able to precisely spot pointers and be able to throw in a trap when one it's changing is dereferenced or to entirely stop the world first, and I don't know why you'd take on that complexity and expense if you're not doing GC...


Yes, rust is a great language. I love what it does. It also has memory leaks, because it is missing the cleanup part of the GC so it can’t deal with reference counter cycles.

Well, there is boehm if you do want to take on that complexity. But no, I am referring to the book keeping operations that are done internal to the malloc and free calls. They are largely hidden from you as the user of the API, but you still have to pay for them in runtime.

I thought rust didn’t have any safe arena allocators, but that some people make them by tricking the system with indexes instead and such, though word was they might come soon https://news.ycombinator.com/item?id=33403324


Are you interpreting "GC every heap allocation" as "each heap allocation causes an entire cycle of GC"? That's...not what that phrase means.


Yes I am. Java's GC can stop the world at any safepoint. It isn't limited to when objects are allocated and it doesn't do it every time.


I'm aware. A cycle per allocation would be absurdly expensive. "GC every heap allocation" actually means that every heap allocation is subject to garbage collection and has to be traversed on every cycle in which it's reachable. If the GC algorithm uses compaction and/or generations, it gets moved around too. That all has a cost.


It's probably too complicated to explain in a HN comment, but I don't understand why not having Leak forces futures to be 'static.


There's a desire to have futures that run on other threads, but can borrow from temporary locations like stack of their caller.

This can't be implemented robustly, because there's no way to enforce the outer scope (the one lending its stack) won't return while the futures are still running. It needs a blocking mechanism that does "wait, your async code hasn't finished yet!", but implementing that via guards and destructors can be bypassed by leaking them.

https://without.boats/blog/the-scoped-task-trilemma/

----

However, running futures on the same thread, with a simple plain .await, does allow them to borrow from the caller's stack safely without restriction. So the first poster in this thread may be doing something unusual, perhaps just over-using multi-threaded spawn() where join_all or streams would suffice.


I assume you mean "spawned futures" - because regular Futures obviously don't have to be 'static. You can run as many of them as you want on a single task by using Future combinators like join/select.

If this is mostly about scoped async task and the ability to reference data from the parent task, then there also had been a different proposal being discussed which provides the same guarantee: A new Future type (e.g. CompletionFuture), which by represents an async function that needs to be driven to completion. See https://github.com/Matthias247/rfcs/pull/1 for details. However since this propsal is already 3 years old and hasn't gotten and interest by the Rust async WG, it's probably also not going forward.


I think eventually or at some point there might be a need for Rust 2.0 even if it won't be called Rust. Think something like JavaScript and TypeScript. A language that's a super set except the former would be soft deprecated.

I know this is highly unpopular opinion, but I don't consider permanent backwards compatibility a good thing.


What parts of Rust would you like changed, though?

I think the current version, with its minor warts, is a very pleasant and consistent experience for the most part -- very much unlike C++. So I'm not sure what I'd change to such a drastic degree that it'd require such breaking changes.


I would definitely want to see async totally overhauled. Also it would be great if major components of the standard library were all version 1.

And if I want to risk going too far, I'd also advocate for refactoring the type system to be slightly less generic in favor of chasing faster compile times.

Also, ideally the lead designer of SafeLang2.0 would be much more heavy on engineering experience than PL theory. Rust made a lot of tradeoffs that were beautiful in PL theory but are annoying to use on a day to day basis.

I honestly think rust is equal parts amazing ideas and mediocre ideas, and I think a slighky less ambitious language would still be almost as safe (world class compared to any other language) but 10x more fun to use.


A complete overhaul of async sounds nice in the abstract, but without exact details it’s difficult to see how it would be better than what exists.

Similarly, I’m curious as to what the details of a “slightly less generic” type system entails.


Besides the Move and Leak traits mentioned in this article, there are several changes I would make to the standard library if backwards compatibility wasn't a concern, including:

Changing Iterator to make use of Generic Associated Types.

Change std::env::set_var to be unsafe. Because using it in multithreaded program causes undefined behavior for any c code that reads the environment (which includes quite a few libc and posix functions).

Change channels to have a better API that is mpmc and closer to crossbeam.

And probably others I can't think of at the moment.


I'm curious about the "change channels to be mpmc" one. Why isn't it backwards-compatible?

About `std::env::set_var()`, there was a proposal for a `#[deprecated_safe]` attribute that triggers a warning if the function is used without an unsafe block, and eventually make that a hard error in future edition.


async still feels half-baked and not pleasant like the rest of the language.

Now, it may be something that still can be fixed and improved upon, but a Rust 2.0 with nicer async experience would be a big win.


I generally agree with you there. Permanent backwards compatibility has strong early rewards and strong late punishments.

I'd love to see programming languages and frameworks add a "gamma" phase to their initial releases. Alphas can be for the internal development, betas can be for stuff that's being tested for bugs and functionality on a public level, and gamma can be a state of "the code can be used out in production, but the API and functionality may change in the future once we see how the tool is used and can identify and resolve operational bottlenecks". Just a handy state that says "feel free to use this, but keep in mind that the interface will likely change for the better but in a non-backwards compatible way based on your experiences using it".

I'm pretty sick and tired of almost every project needing a version 2 because version 1 was essentially publicly released proof of concept that didn't know better.


We still don't have C++ 2.0

And if anybody brings up breaking changes in C++ I will point to how after any of those it sometimes takes almost a decade for everyone to update.

And that was for breaking changes designed to have minimal impact. See how Python 2 is still used in many places 15 years later for what happens if you change how strings work for example.


WG21 ships a completely new language - very similar to but technically unrelated to the previous one - every three years. C++ 11, C++ 14, C++ 17, C++ 20, and (later this year presumably) C++ 23. It is fascinating that people consider the resulting arbitrary source code breaks to be complete compatibility and yet Rust's choice to offer ongoing compatibility to Rust 2015 edition (Rust 1.0) is somehow not enough...

C++ already changed the definition of std::string, that's why your Linux distro went through painful C++ ABI changes many years ago. They went from one terrible string design to a different terrible string design, ruling out some optimisations GNU can chosen, but mandating optimisations other groups had chosen.

Don't worry though, they didn't standardize an actual string slice type (Rust's &str) until much later, as std::string_view in C++ 17, so most software which didn't actually care about string allocation needed to be thrashed around anyway because there was no vocabulary type for this most obvious thing aside from the hopeless C-style "nul terminated char *".

It's actually hard to over-estimate how terrible they are at this (language standardization). Is it really just that there are too many of them? I find it hard to imagine that just large numbers of people can explain it, plenty of people co-operate to create Rust and the results aren't anywhere close to as awful. Maybe it's the ISO process? I have never participated in ISO standardization of something this complicated, maybe the process is somehow poisonous.

But I think the best explanation is the culture. C++ has developed a culture which prizes arcane nonsense.


C++ isn't a language that anyone uses.

Everyone uses GCC C++ or MSVC C++ or Clang C++.

This includes the committee that makes the standard and requires a working battle tested implementation before inclusion.

So you get a Frankenstein result because that is the input.

And while yes in theory the standards define a kind of C++ that works on every compiler but the reality is most compilers don't fully support that for literal years best case.

So everyone uses what is available and writes abstractions to handle the harsh edges if they support multiple compilers.


> This includes the committee that makes the standard and requires a working battle tested implementation before inclusion.

This "requirement" is a mirage, as you can tell by simply installing any of the three compilers from say the week C++ 20 was published and trying to use the headline "modules" feature described in the standard. There are no working implementations.

If you've worked at a large dysfunctional organisation you'll recognise the pattern. Nobody wants to say "No" and make a decision themselves which may go badly - so instead they look for excuses and you're listing one of the excuses. But when they want to do it none of the excuses apply.


> ruling out some optimisations GNU can chosen

GNU didn't do any optimizations that are acceptable in a language with move semantics. The new `std::string` is _strictly_ better than the old one. Although there do exist very many superior string containers outside of the standard library. libstdc++ still supports the old ABI, by the way, as `std::__cxx11::string`.


Large part of why standard was changed so that GCC's copy-on-write strings became non-standard compliant was because CoW made std::string_view and such extremely hazardous. CoW has its benefits but also huge downsides, and small-string optimisation makes lot more sense with the C++11 move semantics.


For many people SSO would be a waste of time except you need to store the empty string.

There are people who make a lot of very short strings and need them to go fast, but not so many to justify making that a core language feature, if not for the fact that C++ empty string needs a zero byte for C compatibility.

That's why Rust got away without it, their empty string has length zero.


I'm not sure if it is an unpopular opinion; i'm not sure it's an opinion at all, since it seems like a statement of fact - no programming language lives forever, eventually being superseded by newer languages designed with the benefit of lessons learned with the older one.

Except Fortran, Fortran is forever.


And Ada, Ada was created perfect.


I agree, and would go even further: that all major versions of languages should be renamed.

Like pearl and parrot, as I understand it.

It completely avoids the chance that "python" will become ambiguous regarding which set of rules it refers to: version 2 or version 3.

And the downside is minimal, because anyone can find the most current name online.


Very interesting read! I really wish we could be unburdened by backwards compatibility so big changes like these are possible, but I realize that would open its own can of worms


Isn't that exactly why Rust has “Editions”?


Editions still have to interoperate with each other, they're not a free license to change anything and everything. In fact the post goes into detail on how they might or might not work for these specific changes!


The post goes into detail with why editions aren't a solid solve.

It does mention that dealing with the pain is the only potential solve.


Honestly backwards compatibility in rust is already half-assed anyway. Trying to use a fixed version of the compiler (important in some contexts such as security software) is a huge pain in the ass.


Rust has an excellent story for running old code on new compilers.

For example, I've been using Rust in production since just after 1.0, so about 8 years now. I maintain 10-20 libraries and tools, some with hundreds of dependencies. If I update a project with 200 dependencies that hasn't been touched in 3 years, that's 3x200 = 600 "dependency-years" worth of old code being run on a new compiler. And usually it either works flawlessly, or it can be fixed in under 5 minutes. Very occasionally I'm forced to update my code to use a new library version that requires some code changes on my end.

I've also maintained long-lived C, C++, Ruby and Python projects. Those projects tend to have far more problems with new compilers or runtimes than Rust, in my experience. Updating a large C++ program to newest version of Visual C++, for example, can be a mess.

However, Rust obviously does not support running new code on old compilers. And because stable Rust is almost always a safe upgrade, many popular libraries don't worry too much about supporting 3-year-old compilers. (This is super irritating for distros like Debian.) Which if you're working on a regulated embedded system that requires you to use a specific old compiler, well, it's a problem. Realistically, in that case you'll want to vendor and freeze all your dependencies and back-port specific security fixes as needed. If you're not allowed to update your compiler, you probably shouldn't be mass-updating your dependencies, either.

Basically, Rust got so exceptionally good at running old code on new compilers that the library ecosystem developed the habit of dropping support for old compilers too quickly for some downstream LTS maintainers. And as a library maintainer, I'm absolutely part of the problem—I don't actually care about supporting 5 year old compilers for free. On some level, I probably should, but...


> Updating a large C++ program to newest version of Visual C++, for example, can be a mess.

That's an MSVC problem. MSVC ignored the C++ specification for decades. Now it does follow the spec. So a lot of non-standard code broke.

The transition was pretty ugly, as I'm pretty sure you've figured out. The permissive mode never gave warnings for non-standard code, so you couldn't just fix warnings as they came up. You had to do a fix the world update, which are a dickpain in large codebases. The transition was relatively fast; VS2017 introduced the standard compliant parser, and C++20 requires it.

Honestly MSVC is such a mess. I have a hunch that before this decade is out, Microsoft will just replace it with Clang a la Internet Explorer/Edge/Chromium. That's why the switch to the standard compliant parser was so rushed; they're trying to force everyone to write standard compliant code so that Clang can compile it.


This is quite under-appreciated. I've lost count of how many times NPM or PIP broke because of "2000 lines of error dumps". The only occasion is happens with Rust has to do with TLS, and I learned to handle that one quick. Otherwise, "cargo install" is the best package manager out there.


Ah, yes, TLS. This is almost always the fault of Rust libraries that link against OpenSSL. So technically this is a C linking nightmare. :-/

I have configured cargo-deny for nearly all our projects to just ban OpenSSL from ever appearing as a dependency.


I think the answer is that when you break compatibility for an old compiler, you rev the major version. The folks on the old compiler then get to use the old library forever. Yeah, they can pin (and should) but it makes it a little cleaner.


Except that so few people are using older compilers, that tracking exactly when features were introduced becomes burdensome to the developer. For languages that have a few large releases every decade, such as C++, this is reasonable. As a C++ developer, I can remember that `std::unique_ptr` requires C++11, `std::make_unique` requires C++14, structured bindings require C++17, etc. It's tedious, but doable, and can be validated against a specific compiler in CI.

For languages with more frequent releases, that just isn't feasible. The let-else construct [0] was stabilized in 1.65 (Nov 2022), default values for const generics were stabilized in 1.59 (Feb 2022) [1], and the const evaluation timeout [2] was removed in 1.72 (Aug 2023). These are all reasonable changes, released on a reasonable timescale. However, when writing a `struct Position<const Dim: usize = 3>`, I don't check whether my library already uses language features that post-date 1.59, and I don't consider it a backwards-compatibility breakage to do so.

Incrementing the major version for a change that is source-compatible on most compiler versions (e.g. the first use of let-else in a project) just isn't justified.

[0] https://doc.rust-lang.org/rust-by-example/flow_control/let_e...

[1] https://blog.rust-lang.org/2022/02/24/Rust-1.59.0.html#const...

[2] https://blog.rust-lang.org/2023/08/24/Rust-1.72.0.html#const...


Is it a breaking change to not support an old compiler when the new compiler is the same major as the old one was? Transitive dependency upgrades that bump minor don't trigger major version bumps in downstream users. Why should compilers be different?


You would opt in to the future, not have to pin the past. It would give the ecosystem more stability.


I've never had any notable issues. I've had far fewer compatibility issues with compiling crates than I have compiling C++ libraries or installing Python packages.


It’s about a 10 line nix file, a 10 line dockerfile, a one line rust-toolchain.toml.


Backwards incompatibility in the language is different from backwards incompatibility in libraries.


Here's an idea, for a kind of soft rollout. Implement a optional feature for 2021-edition crates that allow specifying Leak. The trait has no effect in 2021.

In next-edition:

- 2021 crates that turned Leak feature uses it just as a next-edition crate would. Only things explicitly marked with Leak in the crate can leak.

- 2021 crate that didn't opt-in to Leak feature, automatically marks everything in the crate with Leak.


I think the big caveat mentioned in the editions section (https://without.boats/blog/changing-the-rules-of-rust/#editi...) still applies to your suggestion. The author concludes in that section "I don’t know if this is even possible to implement, probably it would have pretty bad effect on compile times at".

Your refinement seems like a nice addition to the author's suggestion, if it is possible and doesn't effect compile times too much. Hopefully somebody smarter than me takes a run at the problem.


from __future__ import Leak


The Leak trait could be interesting when it comes to C code that calls rust code that calls C code that might longjmp() over the rust code in the middle.

Right now longjmp()ing over rust code is not a great idea for a couple reasons, but because leaks are currently always allowed in rust, it arguably follows the rules at least in some cases (doing so could cause problems, of course).

If the Leak trait were introduced, then skipping destructors would be clearly the wrong thing to do if there are types on the stack that don't implement Leak. That would clearly require some way to either prevent longjmp() from ever jumping over rust code (e.g. always catch it in C code and turn it into a special return code), or find some way to catch the longjmp and turn it into an unwind in rust (e.g. a panic?) and then turn it back into a longjmp to go back into C.


Honestly, longjmp is just extremely problematic for any code that isn't pure C. Even just adding C++ into the mix can easily make longjmp something that can't be used without invoking UB.

"If replacing std::longjmp with throw and setjmp with catch would invoke a non-trivial destructor for any automatic object, the behavior of such std::longjmp is undefined."[1]

[1]: https://en.cppreference.com/w/cpp/utility/program/longjmp


setjmp/longjmp exist in a lot of real world C code, so there is a lot of value in allowing rust to deal with longjmp in a defined way at least in some narrow cases.

And I don't think it's impossible -- it probably requires some special wrappers (perhaps bindgen could generate them?) that would call setjmp before entering the C code. setjmp has its own set of problems, but I don't believe those are impossible to solve, either.


> Right now longjmp()ing over rust code is not a great idea for a couple reasons, but because leaks are currently always allowed in rust, it arguably follows the rules at least in some cases (doing so could cause problems, of course).

In the general case, skipping destructors of objects you don't own (through longjmp(), killing a single thread, etc.) has never been allowed in Rust: at any particular point in the program, you're only allowed to skip destructors of objects that you currently own, or can otherwise gain ownership of.

For instance, the thread::scope() API, after running the spawning thread's closure, uses a destructor* to join all the created threads. However, if a program could longjmp() past that destructor, then the spawning thread could access its variables again while the created threads are still using them, resulting in a data race. (This is the same issue that the original thread::scoped() API had.)

  let mut x = 0;
  let mut jmp_buf = JmpBuf::default();
  if setjmp(&mut jmp_buf) == 0 {
      std::thread::scope(|s| {
          s.spawn(|| loop { x += 1; });
          longjmp(&jmp_buf, 1);
      });
  } else {
      loop { println!("{x}"); } /* the created thread is still changing the value of x! */
  }
That is to say, longjmp() has always been a very unsafe operation, which can only be sound if you're in control of every single destructor which it skips. A Leak trait wouldn't change anything in that regard.

* Or rather, a catch_unwind() followed by a resume_unwind(), which is mostly equivalent to a destructor in this context.


You have not described a mechanism or API by which the compiler or programmer could enforce that only Leak types are on the stack and that a longjmp will not traverse `!Leak` types. And the only real way to get this would be to describe an interface that would have to be variadic to allow calling functions in general, as you would have to ensure that all args to a given `impl Fn*` are `T: Leak` for `T0` through `Tn`, and Rust currently lacks variadic generics. At that point, you might as well just do the same for `!Drop`.


I'd be happy if there were a way to always unwind when a longjmp() happens, regardless of whether there are !Leak types on the stack.


Rust has extern "C-unwind" now, so if you can modify the C code to call Rust's panic instead of longjmp, it will unwind and run destructors properly.


I wonder if the new-edition approach could be done by interpreting pre-2024 code as if everything had a Leak bound. So instead of a not-Leak type being disallowed when passed to a pre-2024 module, it would not pass type checking because everything in the pre-2024 module required Leak.


The article addresses this: "However - and this is the big, enormous caveat of this section - this would rely on the compiler effectively implementing some kind of unbreakable firewall, to prevent a type that does not implement Leak from ever getting used with pre-2024 code."


What I mean is: instead of having a “firewall” in the compiler, is there a way to interpret pre-2024 code such that it has correct but conservative Leak bounds? Then there would be more confidence that, if a mixed-edition program type-checks, then it’s correct.


It took me a minute, but the firewall is talking about the reverse direction of preventing post-2024 code without Leak being used in pre-2024 code.


How would it know the code was pre 2024?


I’m actually glad the Move and Leak didn’t get in. They both have fairly niche usecases but they’d have to be spattered all over basically all rust code.


Why do you say they'd have to be splattered over basically all rust code? The post compares them to Send and Sync, and I don't have to use those very frequently (though I don't write a lot of rust code, either).


People use Arc and Rc all the time, which would need +?Leak annotations. Things like Option would, I think, also need + ?Move + ?Leak annotations.


If it is an unobtrusive as the existing ?Trait annotations such as ?Sized it's not very much of a burden.


Because features are always used in the worst (most evil?) way possible by inexperienced programmers. One of the benefits of Rust is that it makes many evil things either impossible or easily detectable.


Why would Move/Leak being tied to editions hurt compile times? Presumably old editions would have the required trait bounds added immediately after parsing, and the existing trait machinery would handle that. You'd need extra checks for error messages so you can tell people using !Leak/!Move with old code why they have extra trait bounds, but that's about that.


The idea is that this would be a huge number of extra bounds that need to be processed. This is one of the more computationally expensive parts of the frontend.


> this would rely on the compiler effectively implementing some kind of unbreakable firewall, to prevent a type that does not implement Leak from ever getting used with pre-2024 code

Couldn't this be done just by changing how 2021 code is compiled, such that:

All types impl Leak

All generic type parameters have a Leak constraint added

All trait objects are changed to include "+ Leak"

In other words, treat pre 2024 code as if it was the equivalent code in the presense of the Leak trait, requiring that all types are Leak.


That's what my post is suggesting. The tricky part is making sure you don't sneak a !Leak type from 2024 code into some 2021 code.


If you inserted `+ Leak` on every trait bound in 2021 code at parsing time, then I don't think you'd need any extra firewall later.

You'd just treat all code the same, as supporting Leak, and process trait bounds as usual.


Ariel Ben-Yehuda has sent me an example which shows how much harder it is than that, and which this wouldn't cover. I'm going to write a follow up blog post.

EDIT: https://without.boats/blog/follow-up-to-changing-the-rules-o...


With the solution I imagine, this example wouldn't compile. In the crate bar, edition 2021, the code written as:

    fn foo<T>(input: T)
would actually parse as if:

    fn foo<T: Leak>(input: T)
An implicit Leak bound would be inserted into AST of every trait bound in the 2021 edition. Then all code from all editions would be interpreted the same.

This way, the 2021 code wouldn't compile, because the 2024 trait requires `<T>` bound, and the 2021 code can only express `<T: Leak>` and nothing less.

2021 code could not work at all with any 2024 code that allows !Leak. libstd would have to add `+ Leak` to all existing APIs. Upgrading to 2024 edition would need a migration to change the bounds.


With what I described, bar would not compile, because the compiler would add a "+ Leak" bound to T.

The unfortunate part is that means that upgrading foo to the 2024 edition means you either have to break backwards compatibility (at least for 2021 code) or add Leak bounds to type parameters and trait objects. There could be a cargo fix rule to do that update for you.

It is really unfortunate that existing APIs can't be made more general without breaking compatibility though.


A bit late, but see also https://news.ycombinator.com/item?id=37588046 (which at least seems to cover that and most of the other issues mentioned).


I think a "linter" that does formal verification would be a good solution. People use things like clippy already and my hope is that using one of the verification tools such as Kani could become equally widespread for checking common things like absence of panics and leaks.


rust is _almost_ as complex as C++. Introducing these new "edition" would grow the complexity exponentially.

I hope they would think twice about the effects on new learners before even experimenting


"rust is _almost_ as complex as C++."

I don't agree, but even if it were true, C++ has so much accidental complexity (because of its legacy) that you cannot meaningfully compare it with Rust, which by and large is very well thought out.

In general, if something is complex in Rust that is because it's in the nature of the problem and not because of some quirkiness in the solution.

Borrowing is the prime example. People find it complex, but just because the C++ compiler doesn't check the rules for you, doesn't mean you have follow them in C++ as well (which is tedious and error-prone) or know exactly why you don't have to follow them (which is even harder).

I admit though, that the Leakpocalypse (which the article talks about, even if it doesn't call it like that) is an area where accidental complexity lurks.


Pin is another one. I still can’t wrap my head around it. I’m sure it’s simple and the docs are great, but having read them multiple times I’m not really more clear. I probably need to see a bunch of hands on examples. I wish they’d gone the Move route instead of rushing to 1.0 and then retrofitting in Pin.

But I agree, in general things in rust are a lot more thought out and experiments are relegated to unstable which is fine with breaking changes.

I do wish though that they had a better versioning story in the language for making backward incompatible changes more a matter of course. It’s great already for “user space” code via editions, but as the author notes making certain kinds of changes brings up risk and uncertainty because there’s not a well trodden path to support changing core semantics in the language and having it work gracefully without needing a migration en masse.


> I wish they’d gone the Move route instead of rushing to 1.0 and then retrofitting in Pin.

That is easy to say, and I don't disagree with the sentiment, but there are valid reasons for setting a 1.0 release date and cutting corners to get there. If they had not done that, there probably was a real risk of running an infinite loop of "let's wait for Move to be implemented", "great, now let's wait for Leak to be implemented", "great, now let's wait for..." and a 1.0 never gets put out. Rust may not be perfect, but at least it's stable and people are actually using it.


Pin used to confuse me, but now it's clearer. It's right in the name: Pin this thing to where it is in memory, never try to move it.

Rust can and does move things around as it feels the need. So you need something like Pin if what you've built can't move.

It's confusing if you're coming from a C++ background because in C++ something only "moves" if you explicitly std::move it. In Rust its effectively the reverse. A crude way of thinking about Rust's call pattern is that it's basically the opposite: as if every call was a std::move operation.

So Pin is basically just saying: you can't move this, don't try. The memory can't be moved. And it's mostly used in corner cases where some kind of explicit memory management is involved.

Most Rust users should not have to worry about it. It's one of those things that becomes more of a concern if you're writing library code for low level datastructures, or interfacing with C/C++ libraries.


> Most Rust users should not have to worry about it. It's one of those things that becomes more of a concern if you're writing library code for low level datastructures, or interfacing with C/C++ libraries.

Don’t all Rust users writing async code have to worry about Pin?


I've never had to and I have stuff that uses async all over. The only time I've seriously had to deal with Pin is for some pretty low level stuff: manual memory/heap management for database datastructures (paged btree) and for some work stuff related to working with a C++ library that uses zeromq, etc. The point being that in both cases these were frobby dark corners where the mechanics of the pinning ends up being hidden from the user.

People writing application code in Rust should not encounter it often.


You get to avoid pin until you can’t, and then it’s because of some terribly unintuitive advanced usage corner case.

For instance, the async_stream crate would be much nicer if you didn’t need to pun_mut! the stream instances it provides:

https://docs.rs/async-stream/latest/async_stream/


Not really, the async libraries hide the implementation details from you. It's an issue for library developers.


Conceptually I understand the motivation. But trying to figure out how to express it frustrates me so much that eventually I just gave up.


Fair. The documentation made my eyes glaze over and I got confused. It was only once I needed it that it made sense to me. And I still don't understand some uses of it.


One advantage C++ has over Rust is that there are multiple fairly compliant C++ implementations, and the process of making them created some shared understanding of how the language actually works from an implementation perspective. There are several aspects of the current Rust language that were effectively defined by arbitrary decisions made by the implementors, which aren't really spelled out anywhere besides the code.


Do you have examples?

I think in many ways Rust is doing a better job of tying down the tricky details than C++ has.

There is no effort to explain how pointer provenance works in C++ at all. The state of the art is if you write a provenance bug, someone from a compiler vendor will gesture at DR/260 which says provenance exists but WG14 (so C, not even C++) declines to explain how it works, and that's all you get. In contrast Aria's "Experiment" (https://doc.rust-lang.org/std/ptr/index.html#strict-provenan...) has gone rather well. The vast majority of people who want to do acrobatics with pointers can do what Aria says you have to do to get coherent behaviour, and by following her rules they can make code which works in Rust.


WG14 produced a document clarifying provenance in C (which Aria knew): https://open-std.org/JTC1/SC22/WG14/www/docs/n3005.pdf


Given your username, I assume I'm addressing Martin Uecker and I want to commend you for putting the work in on this problem over a long period. While a draft TS is closer to actually resolving a 20+ year old problem it's not there yet and this draft is, if you pardon me, in my opinion more than three months work from publishable.

In contrast, Aria went from "We should see whether strict rules are workable" to the current set of Rust nightly features relatively quickly.

If I want to attempt pointer stunts in C, N3005 is probably worth reading first, but it isn't actionable from what I can tell. Contrast Aria's changes, I can go try that (in nightly Rust) and see it works or why I can't / mustn't do what I wanted under Strict Provenance and who I ought to talk to about that.

I wish you luck for C 29 (?) but that's a distant future, and we're contrasting C++ here, so as with #embed you might take this obvious must-have for C29 and find that somehow WG21 are still so far behind they can't do the same for C++ 29.


You realize that this experiment is basically our proposal translated to Rust? Also I wonder why it is an issue that N3005 is still a draft? Rust also does not have an ISO standard.


In what sense do you believe it's "basically our proposal translated to Rust" ?

N3005 explains four provenance variants, but settles on specifically PNVI-ue-udi for the future of C. But Aria's experiment focuses on roughly what you'd call PNVI-plain, reasoning that Rust largely does not need the extra headache of exposure.

N3005 doesn't provide an implementation, so Aria can't have merely "translated" that. It doesn't provide an API design (so not that). Which leaves basically the fact that you've extensively discussed the semantics of these variants.

Let's try specifics: Take a common feature of Rust's design, ptr::map_addr. Where is equivalent functionality described for C in N3005 ? In Rust it's perfectly natural to write a lambda here, in C perhaps you'd spell this very differently, but N3005 doesn't seem to offer such a thing.

It's an issue that N3005 is still a draft because it goes to how long this wound has festered in C comparatively and will still be there. If this was merely a theoretical problem I wouldn't be bothered, but the whole reason DR260 was raised is that it's not a theoretical problem.


The "expose" mechanism as described in the document you linked to corresponds exactly to PVNI-ae-udi (including the "expose" terminology which was taken from N3005 or an earlier draft). Aria's document is very vague (is there a precise version?), but the explanation using angelic determinism ("guessing the right provenance") corresponds to PVNI-ae-udi. "Strict-provenance" does not need the "ae-udi" part but the doc seems to acknowledge that this is not enough and if you ignore the uintptr_t conversions which make us of this in C, you also have strict provenance. N3005 doesn't provide an implementation, but there are - of course - implementations. First, because it captures what most C compilers do (although there are still some differences for some). Then there is a precise mathematical model in N3005 and there is Cerberos which you can run code and analyze. You are right that we do not have the convenience tools such as ptr::map_addr. Maybe someone should build a header for C that provides similar convenience macros. But the hard part is getting all the bugs in the compiler backends fixed. Last time I checked, Rust was also still affected by the same bugs in LLVM as C (but those may be fixed by now).


I should also give a bit of background information, because I find it funny that some surface-level API for Rust is portrait as "solving" the problem in three months which C struggled for years. (And by that I do not want to downplay Arias impressive work, it is a very nice and clean interface). The fundamental problem is that compiler writers build optimizations without a clear mathematical understanding of the rules. One could argue that this is the fault of the C standard by not being a mathematically precise specification, but this was also never really the goal of ISO C. The main issue is that compilers implemented rules that were not consistent even between different optimizer passes. WG14's main mission is to standardize existing practice. DR260 (failed) attempt to clarify some of this came out this. I agree that this should have been done better, but the important thing to understand is that WG14 as a consenus-based standardization committee is not really equipped to do this kind of work. The basic assumption is that compilers implement sound models, which can then later be standardized, only harmonizing differences between different compilers. But here we had to clean up the mess compiler vendors created. Not at all what WG14 was meant for. To be able to start fixing compilers, one had to find a common and consistent formulation for provenance. For C this meant also considering what all existing code needs and what optimization different compilers implement, and deciding which version to pick and what specific behavior is a useful optimization, what is a compiler bug, and what optimization can be scarified to come to a consistent formulation. Only with this can one decide what an optimizer bug is and what not. To the extend these bugs are still there they affect Rust too and to they extend they are fixed, Rust benefits. (vice-versa C benefits from Rust's push towards safety which also puts pressure on compilers to fix such bugs). But in any case, the API on top is not at all the most difficult part and Aria's document is not precise enough to even differentiate between subtle points of provenance.


I think you're correct that it's a big problem that the serious compilers don't actually have coherent internal behaviour, but I think that's a distinct problem which has been masked by the provenance problem in C and thus C++. It meant that real bugs in their software can be argued as "Works as intended" pointing to DR260 rather than anybody needing to fix the compiler.

For LLVM the effect of Rust has been to drag some of these issues into the sunlight, as it did for aliasing problems previously. https://github.com/llvm/llvm-project/issues/45725

Once the GCC work is closer to finished, I expect we'll see the same there.

I disagree that you need to solve the C problem first to solve the compiler problem, and I think it was misguided to start there. You seem to have focused on the fact that exposure is even an option in Aria's implementation, but let me quote: "The goal of the Strict Provenance experiment is to determine whether it is possible to use Rust without expose_addr and from_exposed_addr". Setting PNVI-ae-udi as "the" provenance rule is your end goal for C, but there's a reason it's called the "Strict Provenance" experiment in Rust, the goal is something like what you call PNVI-plain.

APIs like map are key to that goal, Rust has them and N3005 does not.

So like I said, rather than just being a "translation" I think the most that can be said is you've got the same problem albeit in a very different context, and your solutions are related in the way we'd expect when competent people attack similar problems.


We have no "end goal". PVNI-ae-udi is intended to capture the semantics of most existing C code. I mention terminology such as "exposure" etc. just because this makes it obvious that this work builds on C's provenance model (the terminology did not exist before as far as I know). PVNI plain is a stricter subset. You can already use it in C and a lot of C code would just work fine with it. Note that some compilers people and formal semantics people were pushing for PVI.

If you think that "map_addr" etc. is an important API, then I agree that this is an innovation on top of what we did. But I personally do not quite see the importance of this API. Yes, it allows some things in the scope of strict provenance which in C would now require PVNI-ae-udi. We envisioned future extensions that prevent exposure of pointers for certain operations, but somehow this seems more academic at this point in time. If you are not using hardware such as CHERI, this does not matter. On the other hand, PVNI-ae-udi makes most existing C code follow a precise provenance model, which I think is a huge step forward.


C++ versions are practically a new language. Last two Rust editions required no changes on any of my code. Why are people constantly harping on this obviously untrue idea. New Rust versions and editions make the language _simpler_.


> C++ versions are practically a new language. Last two Rust editions required no changes on any of my code.

This juxtaposition kind of makes it seem like that new C++ versions did require changes to your code, did they?

My understanding is that C++ maintains backwards compatibility.


Rust editions are allowed to break backwards compatibility. If you have 2015 edition code and change your Cargo.toml and set it to 2021 edition, the code may no longer compile.

My code went from 2015 to 2018 and 2021 by simply changing Cargo.toml. No code changes were necessary and it was still completely idiomatic code. Nothing was deprecated or even outdated.

An idiomatic C++11 solution is not likely to be the idiomatic C++17 solution however. Every version of C++ makes deep changes to the actual language and adds a ton of features. Whereas Rust versions and editions tend to make the language simpler as they generally remove limitations.


Every release of C++ since 11 has relaxed limitations on `constexpr`, and several relaxed limitations on `template`. Many other features have had various rules relaxed in certain releases, including `for` loops, lambdas, labels, moves, and atomics.

It's true that there are usually new features which arguably make older code no longer idiomatic, but modernizing code is always optional, so what's really the issue there?


The issue is the language gets more complex over time. The origin of the discussion was the statement Rust is just as complex as c++, which is completely wrong. Rust changes in small increments and even when they decide on a new edition that can break compatibility, it still barely changes.


This can be problematic when working on a project where updating the version of rust is difficult, limiting what libraries can be used - or ci/cd where devs may not have direct control of the environment. It’s less of a language compatibility issues and more deployment logistics issues.


Rust already has editions. There have been three releases since 1.0.


Everytime I see Rust, I feel like terrorism happening in PL




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: