4 ms·
I don't believe that rust solves the right problems in the right ways. This is specifically with respect to the single-owner raii/lifetime system; the rest of
by moonchild 6y ago
I don't believe that rust solves the right problems in the right ways. This is specifically with respect to the single-owner raii/lifetime system; the rest of the language is imo pretty nice (aside from the error messages, which are an implementation problem).
For starters, ATS[1] and f-star[2] both provide much stronger safety guarantees, so if you want the strongest possible guarantees that your low-level code is correct, you can't stop at rust.
_____________________________________________
Beyond that, it's helpful to look at the bigger picture of what characteristics a program needs to have, and what characteristics a language can have to help facilitate that. I propose that there are broadly three program characteristics that are affected by a language's ownership/lifetime system: throughput, resource use, and ease of use/correctness. That is: how long does the code take to run, how much memory does it use, and how likely is it to do the right thing / how much work does it take to massage your code to be accepted by the compiler. This last is admittedly rather nebulous. It depends quite a lot on an individual's experience with a given language, as well as overall experience and attention to detail. Even leaving aside specific language experience, different individuals may rank different languages differently, simply due to different approaches and thinking styles. So I hope you will forgive my speaking a little bit generally and loosely about the topic of ease-of-use/correctness.
The primary resource that programs need to manage is memory[3]. We have several strategies for managing memory:
(Note: implicit/explicit below refers to whether something something is an explicit part of the type system, not an explicit part of user code.)
- implicitly managed global heap, as with malloc/free in c
- implicit stack-based raii with automatically freed memory, as in c++, or c with alloca (note: though this is not usually a general-purpose solution, it can be[4]. But more interestingly, it can be composed with other strategies.)
- explicitly managed single-owner abstraction over the global heap and possible the stack, as in rust
- explicit automatic reference counting as an abstraction over the global heap and possibly the stack, as in swift
- implicit memory pools/regions
- explicit automatic tracing garbage collector as an abstraction over the global heap, possibly the stack, possibly memory regions (as in a nursery gc), possible a compactor (as in a compacting gc). (Java)
- custom allocators, which may have arbitrarily complicated designs, be arbitrarily composed, arbitrarily explicit, etc. Not possible to enumerate them all here.
I mentioned before there are three attributes relevant to a memory management scheme. But there is a separate axis along which we have to consider each one: worst case vs average case. A tracing GC will usually have higher throughput than an automatic reference counter, but the automatic reference counter will usually have very consistent performance. On the other hand, an automatic reference counter is usually implemented on top of something like malloc. Garbage collectors generally need a bigger heap than malloc, but malloc has a pathological fragmentation problem which a compacting garbage collector is able to avoid.
This comment is getting very long already, and comparing all of the above systems would be out of scope. But I'll make a few specific observations and field further arguments as they come:
- Because of the fragmentation problem mentioned above, memory pools and special-purpose allocators will always outperform a malloc-based system both in resource usage and throughput (memory management is constant-time + better cache coherency)
- Additionally, implicitly managed memory pools are usually easier to use than an implicitly managed global heap, because you don't have to think about the lifetime of each individual object.
- Implicit malloc/free in c should generally perform similarly to an explicit single-owner system like rust's, because most of the allocation time is spent in malloc, and they have little (or no) runtime performance hit on top of that. The implicit system may have a slight edge because it has more flexible data structures; then again, the explicit single-owner system may have a slight edge because it has more opportunity to allocate locally defined objects directly on the stack if their ownership is not given away. But these are marginal gains either way.
- Naïve reference counting will involve a significant performance hit compared to any of the above systems. However, there is a heavy caveat. Consider what happens if you take your single-owner verified code, remove all the lifetime annotations, and give it to a reference-counting compiler. Assuming it has access to all your source code (which is a reasonable assumption; the single-owner compiler has that), then if it performs even basic optimizations—this isn't a sufficiently smart compiler[5]-type case—it will elide all the reference counting overhead. Granted, most reference-counted code isn't written like this, but it means that reference counting isn't a performance dead end, and it's not difficult to squeeze your rc code to remove some of the rc overhead if you have to.
- It's possible to have shared mutable references, but forbid sharing them across threads.
- The flexibility gains from having shared mutable references are not trivial, and can significantly improve ease of use.
- Correctness improvements from strictly defined lifetimes are a myth. Lifetimes aren't an inherent part of any algorithm, they're an artifact of the fact that computers have limited memory and need to reuse it.
To summarize:
- When maximum performance is needed, pools or special-purpose allocators will always beat single-owner systems.
- For all other cases, the performance cap on reference counting is identical with single-owner systems, while the flexibility cap is much higher.
_____________________________________________
1. http://www.ats-lang.org/ http://www.ats-lang.org/
2. https://fstar-lang.org/ https://fstar-lang.org/
3. File handles and mutex locks also come up, but those require different strategies. Happy to talk about those too, but tl;dr file handles should be avoided where possible and refcounted where not; mutexes should also be avoided where possible, and be scoped where not.
4. https://degaz.io/blog/632020/post.html https://degaz.io/blog/632020/post.html
5. https://wiki.c2.com/?SufficientlySmartCompiler https://wiki.c2.com/?SufficientlySmartCompiler
- twic 6y ago> if it performs even basic optimizations—this isn't a sufficiently smart compiler[5]-type case—it will elide all the reference counting overhead I'm skeptical. How do you know this? Are you assuming that borrows wouldn't be reference counted? If so, how would the compiler know that no borrow outlives the value?
- moonchild 6y agoThe borrows are reference counted. The compiler sees that a function unconditionally increments the reference count at the beginning and unconditionally decrements it at the end. It further knows the semantics of reference counting, so it knows it's ok to elide both of those. Don't know enough obj-c or swift to show an example/disassembly, but here's a suggestive quote from the llvm docs[1] > ARC may assume that non-ARC code engages in sensible balancing behavior and does not rely on exact or minimum retain count values except as guaranteed by __strong object invariants or +1 transfer conventions. For example, if an object is provably double-retained and double-released, ARC may eliminate the inner retain and release; it does not need to guard against code which performs an unbalanced release followed by a “balancing” retain. See also from nim[2]: > Plain reference counting with move semantic optimizations 1. https://clang.llvm.org/docs/AutomaticReferenceCounting.html#optimization https://clang.llvm.org/docs/AutomaticReferenceCounting.html#... 2. https://nim-lang.org/docs/gc.html https://nim-lang.org/docs/gc.html
- rcxdude 6y ago> then if it performs even basic optimizations—this isn't a sufficiently smart compiler[5]-type case—it will elide all the reference counting overhead. Granted, most reference-counted code isn't written like this, but it means that reference counting isn't a performance dead end, and it's not difficult to squeeze your rc code to remove some of the rc overhead if you have to. This is only the case if the compiler can effectively inline all functions. When compiling a function on its own, the compiler has no idea if the function incrementing a reference count is the first to do so or not. In rust the type signatures of the called functions are all that is needed to verify the type and lifetime correctness of a given function implementation. > Correctness improvements from strictly defined lifetimes are a myth. Lifetimes aren't an inherent part of any algorithm, they're an artifact of the fact that computers have limited memory and need to reuse it. Rust's lifetime analysis and 'mutability xor shared' semantics are also useful for correctness, both in threading (as you mention), but also in the case of unexpected mutation in the same thread: iterator invalidation is probably the most obvious case of this (and it's not just because 'computers have finite memory', it's intrinsic to how a lot of datastructures work). What's more, Rust's lifetime and ownership system works neatly with pools and other special-purpose allocators, and implementing such patterns in a safe manner is frequently done in rust (in some cases Rust lets you get away with patterns which would be so wildly unsafe in C++ as to be impractical). If Rust didn't care for allowing such control over memory allocation it probably would not have much of the features it does have.