4 ms·
Yeah, the problem with jumping into Rust headfirst is that you assume that the most complex parts of the language are what you need to use to solve your problem
by mmastrac 6y ago
Yeah, the problem with jumping into Rust headfirst is that you assume that the most complex parts of the language are what you need to use to solve your problem.
It turns out that the vast majority of Rust code is just fine using Arc and letting the reference counts handle your memory management.
The lifetimes are a really cool feature for library/data structure authors, but if you're writing "web server" style code just use Arc, call clone, and be done with it, unless you've got a specific perf issue.
- brundolf 6y ago> It turns out that the vast majority of Rust code is just fine using Arc and letting the reference counts handle your memory management. There are disadvantages to Rc/Arc compared with GC, though: - They tend to be less performant - Reference cycles can leak - In languages where they exist at a library level instead of a language level, they're much less ergonomic than GC So if your core problem domain involves creating and disposing of lots of heap-allocated stuff all the time, you're probably better off just using a language with a proper GC. The Rust version may not even perform better if you're just using Arc, everywhere, by default. Use Rust when your core domain/hot paths can mostly stick to the single-ownership model (and ideally the stack). Rc/Arc/RefCell are a band-aid for when that fails.
- mc10 6y agoAs someone who's relatively new to Rust, I'm curious: what is an example of a situation where someone might lean on Rc/Arc as a "band-aid", and what would be a more "idiomatic", non-Rc/Arc solution?
- brundolf 6y agoRust's key feature - the borrow-checker - relies on the idea that each value has a single "owner" at any given time. This owner can be a function, another value (a parent struct), etc. You can put these values on the heap, but if you use Box (the go-to for heap allocation), that pointer still has to have a single logical "owner". Under idiomatic Rust, each value effectively lives in one single "place". This allows the compiler to determine with 100% confidence at what point it's no longer being used and can therefore be de-allocated. Now, these values can be lent out ("borrowing") to sub-functions and such via references (mutable or immutable). Multiple immutable references can be handed out at once, but a mutable reference to a value has to be the only reference to that value of any kind, at a given time. The problem is, some domains really don't lend themselves to this restricted model. No two objects or functions can point, mutably, to the same object at the same time. You simply can't create a graph of inter-referenced objects where a single value may have multiple "parents". And sometimes even with a perfectly tree-like ownership structure moving values around can get complicated, because Rust has to know for sure that the ownership model is adhered to. This is where explicit lifetimes and such can come into play. Even writing a linked-list in Rust without using unsafe { } (or Rc's) is hard (https://rust-unofficial.github.io/too-many-lists/ https://rust-unofficial.github.io/too-many-lists/). In Rust, Rc's are kind of an admission of defeat. You're telling Rust not to perform its normal "compile-time" automatic deallocation, instead having it track references at runtime (which comes with overhead) to know when to de-allocate. What this buys you is basically an out from the ownership system: instead of handing off a plain reference to multiple places, which Rust may not let you do, you just clone the Rc and hand off that "new" value which can go anywhere it wants. That Rc is then what gets tracked by the ownership system and de-allocated, and when de-allocated it decrements the count (again, at runtime), and eventually that runtime mechanism (hopefully) decides the real value can be de-allocated. Basically any part of your code that uses Rc/Arc is giving up one of the biggest features of Rust. Which is totally fine, if you're reaping those advantages elsewhere and you just need to bridge a gap where ownership is too limiting. But if heap-juggling is going to be primary thing your program is doing, you'll probably have a better overall time with a GCed language.
- steveklabnik 6y agoHere's an example. You want to do some computations on an array of values: fn main() { let mut v = vec![1, 2, 3]; for i in &mut v { *i += 1; } println!("v: {:?}", v); } They want to speed this up with threads. So they ask "how do I do threads in Rust" and get pointed to std::thread. So they write this code: use std::thread; fn main() { let mut v = vec![1, 2, 3]; for i in &mut v { thread::spawn(move ||{ *i += 1; }); } println!("v: {:?}", v); } and they get this error message: error[E0597]: `v` does not live long enough --> src/main.rs:6:18 | 6 | for i in &mut v { | ^^^^^^ | | | borrowed value does not live long enough | argument requires that `v` is borrowed for `'static` ... 13 | } | - `v` dropped here while still borrowed (there's more to the error message but I'm cutting it to the start) So they ask "hey how do I make v live for 'static" and someone says "you use Arc" so they write this: use std::thread; use std::sync::Arc; fn main() { let v = Arc::new(vec![1, 2, 3]); for i in v.iter_mut() { thread::spawn(move ||{ *i += 1; }); } println!("v: {:?}", v); } and get this error: error[E0596]: cannot borrow data in an `Arc` as mutable --> src/main.rs:7:18 | 7 | for i in v.iter_mut() { | ^ cannot borrow as mutable | = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `std::sync::Arc<std::vec::Vec<i32>>` So then they ask "hey I have an arc, but I want to mutate things inside of it, how do I do that?" and the answer is "use a mutex", so they write this: use std::thread; use std::sync::{Arc, Mutex}; fn main() { let v = Arc::new(Mutex::new(vec![1, 2, 3])); for i in v.lock().unwrap().iter_mut() { thread::spawn(move ||{ *i += 1; }); } println!("v: {:?}", v); } but this still doesn't work, because the lock is held during multiple threads of execution. So they figure out that they can do this: use std::thread; use std::sync::{Arc, Mutex}; fn main() { let v = Arc::new(Mutex::new(vec![1, 2, 3])); let mut joins = Vec::new(); for i in 0..3 { let v = v.clone(); let handle = thread::spawn(move ||{ v.lock().unwrap()[i] += 1; }); joins.push(handle); } for handle in joins { handle.join().unwrap(); } println!("v: {:?}", v); } I've skipped a few iterations here because this comment is already too large. The point is, they've now accomplished the task, but the boilerplate is way way way out of control. A more experienced Rust person would see this pattern and go "oh, hey, these threads don't actually live forever, because we want to join them all, but the compiler doesn't know that with thread::spawn because it's so general. What we want is scoped threads" and writes this: use scoped_threadpool::Pool; fn main() { let mut pool = Pool::new(3); let mut v = vec![1, 2, 3]; pool.scoped(|scope| { for i in &mut v { scope.execute(move ||{ *i += 1; }); } }); println!("v: {:?}", v); } and moves on with life. Way more efficient, way easier to write, extremely hard for a new person to realize that this is what they should be doing.
- zozbot234 6y ago> Rc/Arc/RefCell are a band-aid for when that fails. I would not describe Rc/Arc/RefCell as "band-aids", let alone as "admissions of failure". Rc<> is precisely the right approach for objects that might need to have their lifecycle extended by multiple 'owning' references; Arc<> applies when the 'owning' references might span separate threads. Cell<> and RefCell<> are for shared mutable state within a single thread, whereas Mutex<> and Rwlock<> serialize concurrent access from multiple threads. To reiterate, these are not clumsy "band-aids" or "hacks"; they're elegant, self-contained solutions to rather well-defined resource management problems. It's true that some "core domains" are inherently unsuitable to Rust-as-it-currently-exists, due yo the need of general GC. But these domains are not very common, and future versions of Rust might well add some support for optional, self-contained GC's.
- brundolf 6y agoMaybe "trap door" is a better word. My point is that these constructs exist so that you can do things with Rust that Rust isn't really ideal for. Any language or framework has cases that don't fit its golden path, and any (useful) one will have these trap-doors so that those cases aren't totally impossible. Another (more extreme) one that Rust has is unsafe { } blocks. Rc/Arc/RefCell move guarantees to runtime; unsafe { } removes them completely. And this is well and good: real-world requirements are messy and varied and rarely fit neatly into a predetermined model. But heavy use of trap-doors is a code smell, and may be an indicator that you're using the wrong language or framework for the job.
- zozbot234 6y ago> My point is that these constructs exist so that you can do things with Rust that Rust isn't really ideal for. And this is where I disagree. Shared mutable state and multiple ownership via automated reference counting are legitimate patterns in a system programming language, and using Rc, Cell, RefCell, etc. enables these patterns in a way that preserves memory safety. That's pretty close to ideal, especially when compared to what you often see in C/C++.
- 6y ago
- steveklabnik 6y agoIt is not my experience that Arc is used in the vast majority of Rust code.
- throwaway894345 6y ago> It turns out that the vast majority of Rust code is just fine using Arc and letting the reference counts handle your memory management. I wish there was more content in the Rust community that stressed this, or rather that made it clear what the happy path was so we don’t have to go off and learn it by trial and error. I always get the feeling that using Arc, etc gratuitously is a bad practice that may bite me later instead of “your code will be fine unless it really is perf sensitive (Steve’s sibling comment also kind of illustrates the lack of consensus about what is the happy path).
- steveklabnik 6y agoI think that folks are happy to say "oh just toss an Arc on it", and there's no like, moral opposition to doing so. But the issue is that it is much easier said than done. So for example, let's take a really simple program: fn takes_ref(r: &i32) { println!("r: {}", r); } fn main() { let v = 5; takes_ref(&v); } Sure. Not a big deal. But imagine that, for some reason, we are having issues here. We can toss an Arc on it, sure, no big deal: use std::sync::Arc; fn takes_ref(r: &i32) { println!("r: {}", r); } fn main() { let v = Arc::new(5); takes_ref(&v); } That's not too bad, we're only wrapping up the constructor. Okay, sure. But what happens when our requirements change, and we need to mutate something inside takes_ref? We can do that very easily in our original program: fn takes_ref(r: &mut i32) { *r += 1; println!("r: {}", r); } fn main() { let mut v = 5; takes_ref(&mut v); } But if we want to do this in our Arc world... we have to do this: use std::sync::{Arc, Mutex}; fn takes_ref(r: &mut i32) { *r += 1; println!("r: {}", r); } fn main() { let v = Arc::new(Mutex::new(5)); takes_ref(&mut v.lock().unwrap()); } This is way more boilerplate. And, I even messed it up the first time. So the end experience here is "ugh so much boilerplate in Rust", when often times, the answer is "don't use Arc/Rc Mutex/RefCell" They exist because, in some cases, you legitimately do need to use them. But if we encourage people to reach for them too early, they can have an even worse time.
- throwaway894345 6y agoThat makes sense, and this is exactly the kind of thing a newbie has to wade through without the benefit of knowing the tradeoffs in choosing one path or the other. There probably are docs that say when to use Arc and when not to, but there's still a cacophony of voices saying "just use Arc and deal with performance issues later" and it's hard for a newbie to understand whom to listen to. Note that this isn't a criticism of Rust, but rather a rough edge that I'm confident will eventually be polished off.