Why Dark didn't choose Rust
blog.darklang.com
blog.darklang.com
https://rust-lang.github.io/async-book/04_pinning/01_chapter...
> Apparently, recursion adds a new level of complexity to async.
It really doesn't - it's just some users may be surprised by the lack of magic when it comes to Rust. Async is implemented with futures, futures are types, and you can't recursively define a type without boxing.
> Apparently, this boxing and pinning is what you get when you don't have a GC, and that when you do have a GC, you simply don't need to deal with it. So that was the final straw for me.
It's kind of weird to me that language developers would pass on Rust because they don't understand memory management. How is memory going to be managed in Dark? If it's GC'd, how is that implemented?
This is all hypothetical, I actually don't know, just my best guess.
That's one more thing you can afford to not understand and still be productive.
For instance:
- You shouldn't rely on finalizers to manage resources due to the lack of determinism. No compiler support to help you through that.
- Similarly, when the magic blob GC starts acting up, how do you solve that?
- What about memory usage being orders of magnitude higher in a GC'd system?
It's just different problems. To an extent the line where you have to care is much closer in Rust than Java, but in Java, when you cross the line, good luck. Rust forcing you to be expressive from the get-go pays dividends down the line, allowing you to be relatively more productive, later.
I guess it's like skiing vs. snowboarding. Skiing is easy to get going, but really difficult to get good at. Snowboarding is really hard to get started, but once you do, it's relatively much easier to be great at it.
> when you cross the line, good luck
And if you never cross the line? You wasted thousands of dollars for developing something in Rust that you could have written in Python in 1/4 of the time and be fine.
To me the job of the programmer is not to reason about memory. The job of the programmer is to reason about algorithms, data structures, and thus a language that abstracts the memory management is better if you can afford it. It has a cost, sure like it has a cost using C instead of assembly.
Of course in certain circumstances where it really doesn't matter you get your choice of languages.
> To me the job of the programmer is not to reason about memory.
The job of a programmer is to describe what needs to happen to a set of inputs to yield a set of outputs, and there are many different ways to do so. Sometimes you do need to describe the relationships between data. Rust attempts to leverage your description of the transformation and relationships to make memory management something you don't have to worry about either.
There are limits, of course, and sometimes you need to help it out, however this amounts to improving or clarifying the description of your system. On the other hand resolving garbage collection issues often involves making surprising and seemingly irrelevant changes to your system that happen to influence via spooky action at a distance the behavior of an unrelated component of the system. And if you remove them it all falls apart again.
To borrow a turn of phrase, it has a cost, sure like it has a cost using C instead of assembly.
This type of thought process is how we get Electron applications and when several of them are running at the same time they can bring your OS to a crawl by having consumed every bit of memory available for some simple GUIs. Memory absolutely always matters.
Also the people who I see saying memory doesn't matter are usually developers who are running on 32GB+ work stations. They forget about the user still running with 4GB or 8GB of memory.
This was weird to me as well, but it looks like Dark is more like a RAD tool with a custom integrated scripting language. In that case I don't think you need a sophisticated GC.
Just the logic seemed out of nowhere to me, like this shouldn't be terribly surprising to a language developer, and it's framed like they're discovering things for the first time.
The language in which the Dark interpreter is built has a GC, and we just use that.
A GC is a huge undertaking, and we already have to build an editor, a language, a stdlib, and infrastructure.
Side note, I've written a GC in rust (stop the world, compacting). It's not a cutting edge, concurrent generational GC but it's not that big of an undertaking. And it performs very well for small problem sets since memory is all slab allocated. Only a few hundred lines in Rust.
I do definitely see how writing a tree walking interpreter is non ideal in Rust if you don't have a ton of experience writing Rust. I have a tiny lisp in Rust compared to doing it in dynamic languages it is quite verbose and hard to grok.
- we're able to deploy (safely!) in 50ms rather than minutes because of it. [1]
- we completely host the infra and you don't need to think about it (including DBs and queues, which are nicely integrated into the language) [2]
- trace driven development, where we use real values from production and show you them in your editor [2]
[1] https://blog.darklang.com/how-dark-deploys-code-in-50ms/
[2] https://darklang.com/launch/demo-video> ...but remember that garbage collectors are great. By having a GC, we don't have to do any of the stuff that causes all these problems in Rust. Maybe that costs performance, but I need the ability to quickly write code a lot more than I need the extra performance.
This is very valid and sensible. Worth noting is that C++ would be a ridiculous choice for this type of application, and Rust manages to be a not ridiculous choice which is already a huge accomplishment. But that doesn't mean it's always a good choice.
For whatever it's worth, I wish I had more time to contribute some code back when you were asking for examples. It kinda felt to me like a lot of your code was trying to write OCaml in Rust, which causes a lot of this friction. But it was only a feeling; I didn't have the capacity to really dig in. It is also very true that taking time to get up to "what is the Rust-y way to do this" is a challenge for folks learning Rust, and a valid reason to use a language that works closer to how your brain works.
I'm excited to see how F# works out for you; it's something I've always wanted to spend some time with, but never found the chance to actually do so.
I suspect I would have liked the original green threads more, despite the drawbacks.
Their current code base is in OCaml. Porting code from OCaml to F# sounds about as painful as moving from Python 2 to 3[1], which while painful, is waaay better than a total re-write. F# has much better libraries and ecosystem (because it is built on .NET). Their frustration was with the libraries and ecosystem of OCaml, not its syntax[2]. So, if they switch to F#, much of their core logic remains syntactically the same, and simply needs reworking (rather than rewriting). They also have to retool/call .NET libraries instead of C/OCaml ones, of course, but this is really the point of the move anyway.
So: better libraries for what they're doing, not a total rewrite (just a re-work), preserve the overall codebase's structure, less disruption. It just makes good business sense to me.
[1]: https://stackoverflow.com/questions/4239121/code-compatibili...
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.
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.
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/).
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.
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.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.
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.
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++.
I guess it also depends on how much memory "churn" your program has. Maybe an operating system keeps objects around for a long time, in which case the overhead of reference-counting diminishes towards negligible. But a web server will typically allocate a bunch of memory and then throw it all away on a per-request basis. If all of this is happening behind ref-counters, I think it will start to become noticeable. Of course, for many kinds of web servers it's very possible to accomplish your goal using single-ownership. But it sounded like that wasn't true in the OP's case.
A typical web server application has absolutely no problems with Arcs. They will be just a small percentage of overall number of objects allocated. Most of the time it's just your application dependencies\components that you need shared access to.
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).
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.
That being said, I have a feeling that the author lacks programming expertise to develop a language (I am not saying this in an offensive way). They are dodging things that are needed to build a language (memory management, choices of libraries etc.).
A google tech talk related to this work is on youtube https://www.youtube.com/watch?v=kKySEUrP7LA
You can find multiple other papers on google scholar where Paul was an author all related to programming languages, compilers etc.
The author is Paul Biggar, a former Sr. Compiler Engineer @ Mozilla and founder of CircleCI.
Off by one. Classic ;)
I’m not even arguing that Go would be the best or even a good choice here, but it seems strange to not even mention it as it also qualifies as great judging by the arbitrary statements of what does and doesn’t “suck” at the very end.
The author is coming from OCaml and eventually switched to F#. Having a powerful type system probably eliminated the author from considering Go.
This is true in the narrow sense that Go doesn't use the keywords "try" and "catch" .
Its false, though, in that the defer/recover/panic set of tools provides similar functionality to try/catch/finally/throw, with somewhat different syntax.
Idiomatic Go uses panics and the associated handling infrastructure less than many other languages use exceptions and their handling infrastructure, though.
Writing an interpreter in Go is doable, but interpreters are both very CPU-intensive and built on variant data structures that need to be fast, and Go interfaces are the only data type available that does variants containing pointers. Overhead is high compared to C. You don't have low-level access to things like NaN-packing and you also don't have a JIT available at runtime to speed things up.
In many cases it's fast enough, but if you really need to write code in a different language that's both fast on a CPU-bound task and interoperates well with Go packages, a better approach might be to write a compiler that generates Go code.
It's a bit of a weak point, but for a specialized use case. Most code is either not that performance sensitive or can be written to operate on homogenous arrays.
This is the key point. When your target and host language match in semantics is easier to be done in the host. You se how much gymnastics is necessary to implement near all langs on C.
---
I done a lot of F# before move to Rust, and frankly, F# is great and Rust take a time to "click". I'm doing a lang on it (https://tablam.org) and the truth is that Rust make things harder.
Some of the points of the author are not that "bad" and with some time it will see that are non the real issues, but the fact is that anyone, specially (IMHO) if have done a lot of other langs (not named C or pascal) will have a miserable time the first try.
Eventually, you Jump the wall of complexity and a lot of stuff click.. but then hit the REAL showstoppers (that are different depending according to what you do), and lets by real: Are truly hard. (maybe: But doing a lang, you hit this stuff more easily than "regular" coding. Doing a erp backend Rust is super productive to me)
---
2 things that make harder this stuff: Rc/Arc make complicated how mutate things. You need to wrap again with RefCell. I wish exist a super-charged RcRefCell that is "blessed" by the rust team as is with Rc, so the correct idioms to deal with this become widespread.
Also, the trait system and the restrictions of object safety somehow make harder to mimic a OO system, that in part could make a lot easier to deal with lang implementation.
Plus, I wish I can take alias everywhere, so i can cut the noise in syntax...
The syntax for that is Rc<RefCell<…>> and yes, this is clearly intended. The idea is that Rc is limited to managing multiple owners, while the RefCell part deals with shared mutability.
But yes, as you say, we decided to split them up, because it is easy to combine them, and you may want to combine other pieces in different ways. Rc<T> on its own is good in many cases, you may want Arc<RwLock<T>> instead of Arc<Mutex<T>>, etc.
> Good parts: library ecosystem is great
> Bad parts: having to fight the compiler
My limited impression of Rust was the opposite on both points - that the compiler is super helpful but the crate ecosystem sucks.
I typically work more in the area of systems administration and automation, so I don't have much experience in the way GUI development, but there are Rust bindings for dear-imgui; and I would be very surprised if there weren't QT/GTK bindings.
edit: in https://blog.darklang.com/adventures-in-async/ there's a short clue saying "The Dark web server is currently synchronous, and so long or slow requests--at sufficient volume--can cause operational issues for us" but it doesn't go into details.
Conversely, Scala has the expectation of (partial) type erasure as is done on JVM, and that's probably why the .NET port didn't get very far.
Articles like this one make me think that maybe the Rust community should develop more resources to help people make this jump because the language is more likely to attract people with this background than other non-GC languages that are typically seen as lower level and relegated to high performance applications.
I'm very anti-GC myself: I think the pros don't outweigh the cons in the overwhelming majority of the cases, they hide a complexity that you'll have to deal with eventually if you ever need to optimize your memory consumption and they encourage sloppy coding by not forcing you to think about ownership relations in your program's data.
Having this debate over the years (often here on HN) I notice that the GC-fans tend to fall in two groups: those who really understand the tradeoffs and, unlike me, really think that GCs are worth it and those who simply have never really used languages with manual memory management - or not for very long - and seem to base their dislike of them on a lack of comprehension, cargo culting and associating it with C-style memory management where tracking memory allocations and frees adds a true cognitive overhead (unlike languages like C++ or Rust where usually RAII will take care of it 99% of the time).
I realize that I'm building a strawman here, but when I see the way the author talks about memory management (and how much they hate it) I can't help thinking that they're Doing It Wrong:
>I was actually surprised at how little the actual memory management bothered me. I'm a big believer in garbage collection, and not having to think about memory, so I expected to hate this part of Rust, but it turns out it's kinda OK. You put everything in a Box::new (regular heap memory) or RC::new (reference counted memory) or Arc::new (reference counted memory suitable to be used concurrently in different threads), and then when they go out of scope they'll be cleaned up.
See, here's the problem. You don't like to think about memory management and ownership, so you think "oh, I'm just going to put everything in a Box/Rc". Spoiler alert: (A)Rc is garbage collection, it's just a very simplistic one. You haven't switched from GC to manual memory management, you've reimplemented a lousy GC in Rust and, unsurprisingly, it fails to compete with the good GCs of the languages you're used to.
It's like when I started using Python and I naively tried to do RAII with destructors, only to realize that it's a very bad idea because you never know when the destructor is going to run in such a language.
I'm currently implementing a rather asynchronous multicast Rust applications where I have 3 threads processing messages and passing them around to deal with retransmits, packet reconstruction etc... I have one(1) Arc, zero Rc and one(1) Box in the entire codebase (and I intend to get rid of this Box soon).
It may be a bit trickier when I start on a new project but I find that it pays off in the long run by making maintenance a lot easier.
Ok, here's my attempt to explain Pin. First a smidgen of background. In garbage collected languages, we can more or less say "everything is a pointer". And given how garbage collection works, we can extend that a little bit to say "anything can keep anything else alive, by holding onto a pointer to it." Now in C/C++/Rust, this is very much not the case. The most common example is if you try to return a pointer to one of your local variables. Your pointer cannot keep that variable alive, it becomes a "dangling pointer", and your code definitely does not work (but might appear to for a while). Nothing new so far.
Now Rust comes along and decides to try to catch most of these bugs at compile time. So it makes you keep careful track of who points to what, and how long everything is planning on staying alive. As an important special case of this, one of the things Rust will essentially never let you do, is create object that holds a pointer to itself. It's basically the same problem we just mentioned: you might try to do something like returning that object, and the original memory location you took a pointer to would be destroyed, and the pointer in the returned copy would now be dangling. It's almost never safe, so Rust almost never allows it. That was pretty much the whole story for a while.
But then things got trickier. Folks started working on async, and designing the new "await" keyword. Like in most other languages, the goal there was that you could write something that looked a lot like a regular function, but secretly the compiler would generate some sort of struct for you, and the variables in your function would actually be the fields of that struct. (Why anyone wants to do this is a long topic, but the reason is the same as in many other languages, basically because having lots of threads is slow.) And this runs is into a very tricky problem: Having one variable point to another variable is perfectly legal. We do that all the time. You can't write normal code without doing that. But now that variables are actually struct fields...that means we've got an object that points to itself. And we just said that was forbidden.
The solution the Rust team landed on for this problem is called Pin. When you "pin" an object, you're essentially swearing that it will always stay in the same spot in memory, so that any pointers it has to itself will never become dangling. The exact way this is implemented involves a lot of Rust specifics (Pin is a struct, but Unpin is a trait), and the fact that it sits right at the boundary of safe code and unsafe code makes things trickier. But that's the general idea of pinning: a promise that I will never move this object or copy its bits somewhere else in memory.
This is posted at https://blog.darklang.com/new-backend-fsharp/
> It received 39 pretty enthusiast[ic] replies.
And factually I think your opinion is incorrect. The first post didn’t seem like it was tailored for HN, and he said he was surprised:
https://news.ycombinator.com/item?id=24981505
https://news.ycombinator.com/item?id=24980661
Meanwhile it looks like this discussion has been modded down to remove it from front page due to the low quality of comments (yes: this comment doesn’t meet standards either but maybe it helps you?...)
Kotlin and TypeScript got null right. Rust got it wrong - it's not such a big deal because compiler will tell you but it's still wrong, it's still breaking change and it's also a kind of information leak.