I built a garbage collector for a language that doesn’t need one
claytonwramsey.github.io
claytonwramsey.github.io
0.0.x crates are special in that they can have breaking changes even in minor versions (so 0.0.1 isn't compatible with 0.0.2), so they convey a level of stability that is even less than a typical 0.1.x crate
I couldn't find documentation on that but here's a reddit thread about it https://www.reddit.com/r/rust/comments/n4ni2d/arent_many_rus...
Surely oxidation is better than carcinization. And rust evolving to Java would be mineralization? Or maybe germination?
Makes more sense to me that a language/framework/etc. would 'oxidise' to Rust, and someone learning/getting hooked on Rust would 'carcinise'.
But this is all silly and doesn't matter anyway, ha!
If you have values A and B, both instances of T that implement Drop and hold a Gc<T> to one another, then either A sees a dropped B or B sees a dropped A, and you get UB. Technically this isn't a problem because you already marked it as an unsafe trait, but your documentation should also mention this problem.
You have a safe derive macro for Collectable, however, so it needs to reject Drop. This is possible with some weird macro magic[0].
For those wondering, while Python doesn't have UB, it used to enforce the same rules until PEP 442[1]. Circularly linked garbage that implemented __del__ would instead get stored in sys.gc.garbage and you'd have to go in there and break references on __del__ types. However, native code objects will actually still trigger this behavior[2] presumably to avoid UB.
I have no clue if Java finalizers need to worry about this.
[0] In Ruffle we use gc_arena as our garbage collector. It enforces no_drop in it's Collect derive macro. See: https://github.com/kyren/gc-arena/blob/master/src/gc-arena-d...
Actually, I lied: no_drop is one of two safe constraints you can use Collect with. The alternative is require_static, which only works because gc_arena treats the entire GC heap as a data structure that is owned and has a lifetime that all Gc pointers borrow. This doesn't work for dumpster, though, so ignore it.
It's not that, it's just that the source code has no meaning for a particular code path, so the compiler can do anything so that other code paths or conditions can be faster/more optimized.
Just nitpicking.
If you look at the Hylo (formerly Val as of 3 days ago) language [0] that folks like Dave Abrahams have been working on, they're outright saying to just not use reference semantics if you can avoid it. In this talk at CppCon about Value Semantics, Dave argues that we should be decoupling object graphs from access to objects [1]. That's right folks, using `usize` indexes into collections, or adjacency lists, isn't just a hack to get around the borrow checker as people like Jonathan Blow have famously critiqued [2], it may just be the right way of doing things.
I know there's a nontrivial overhead to using index-based references, especially since CPUs can't easily predict load operations. This gives me an idea: create a CPU architecture where pointers are actually (object, offset) tuples, enabling better on-metal performance with index-based references. I've neither the time, money, expertise, nor energy to implement such a thing, but it would be really cool if it existed - maybe CHERI is a close approximation, though I haven't looked very closely at it.
Implicit arguments! I'm more and more convinced that this form of controlled dynamic scoping dynamic scoping should be embraced by more languages.
> create a CPU architecture where pointers are actually (object, offset) tuples
Segments might yet see a renaissance (and CHERI might be indeed be a form of it).
This might be an odd comment, but this is sorta similar to React's Context mechanism. For what it's worth, it makes a lot of things easier but it can definitely also add a lot of surface area for complexity. Although it's certainly better than relying on globals all over the place.
Also, many process like servers are long lived, so the OS clean up strategy does not work for them, and they are exactly where you want some strategy for memory allocation like arenas to prevent memory fragmentation.
Here's a blog post from 2021 https://tmandry.gitlab.io/blog/posts/2021-12-21-context-capa...
This isn't implemented yet, and the proposal never matured into a proper RFC, but there's high interest for some solution along those lines
In other words, receiving an implicit parameter is a kind of effect. So another way to provide this feature is to have a full blown effect system (which is much more general and is probably overkill, but there's also some interest for that)
I dunno, isn't a usize index into a collection essentially just the same as a pointer? Ok it's a bit safer because you at least know the type of the object you're accessing is correct, but you can still get e.g. use after free bugs.
I think Jonathan Blow is right and wrong - it is a hack to get around the borrow checker, but also that's totally fine. The borrow checker still works for the other 95% of code you are writing. Nobody ever claimed it was the perfect solution to every problem.
Though arenas are useful even outside of "hacks".
In the end, a pointer (or a reference, even) is "just" a usize index into the heap, which can be thought of as a heterogenous collection.
With an index into Vec<T> you are at least guaranteed that the object will be a T.
You are guaranteed memory safety, but you aren't guaranteed to have a correct reference.
Heap integrity / reachability is a dynamic property of a running program, and so all the static solutions basically end up as half-measures. (I imagine this would include using macros, though I don't know exactly what you mean),
FWIW this is my experience - https://www.oilshell.org/blog/2023/01/garbage-collector.html
Take it with however many grains of salt, but I heard many static solutions proposed, and they're all "wrong" for the simple reasons in theory of computation.
Also, Rust's static memory management inherently clashes a bit with dynamic memory management. That's also a fundamental thing, and you can have a bazillion mechanisms to ameloriate it for some cases (which may be valuable), but the problem will still be there no matter what.
Rust already has precise heap allocations with Box and Rc/Arc without the need for GC. GC implies uncomputed heap allocation liveness deferring work until later.
this is nice
I definitely think a language that has room for abstractions that aren't zero cost, while still having a borrow-checker makes sense. Using rust to make that language also makes sense. I think implementing a vm or interpreter entirely in safe rust would effectively force borrow checking on the resultant language wouldn't it?
Ruby seems like a good place to start the experiment. I'd prefer it end up with something more like julia or python just for my own use cases.
As for having both, I rather take Swift memory ownership or Linear Haskell approach, or even D/C#/Nim/Go.
The productivity of automatic memory management, with the tooling to go low level C and C++ style, if and when it is really needed.
If the idea is for a safe but more ergonomic language at the cost of performance, I think you'd be focused on making clone and copy implicit when a move doesn't work, getting rid of explocit borrows/references, and perhaps figuring out mutability without needing to annotate it. The absolute type safety is probably the biggest speedbump I hit in daily usage though, not the borrow-checker. A strong but duck/dynamic type system might solve that?
Borrow checker alone makes several algorithms and data structures quite hard to implement.
Swift memory ownership model, Linear Haskell, Hylo (née Val), Chapel, Ada formal proofs, D's ownership model, are all examples where language designers decided borrow checker alone is too much to ask for.
No UB to worry about.
Oh you can absolutely say that.
It's just a dumb take. Because there are already languages which have a solid type system, a functional bent, and a GC. And you can probably bang out one yourself if you want.
If that was what Rust was, it probably wouldn't have existed in the first place, Mozilla would not have been interested in it in the second place, and no community would have gelled around it in the third place. Hell, it was specifically dragged further downstack by the people who coalesced around its potential in that space.
If OCaml/StandardML/Haskell/etc had anything close to the ergonomics and convenience of cargo for testing, setting up projects, adding deps, deploying them to any OS/platform, I would probably write about 30-40% less Rust.
I'm not saying we should add GC to Rust - but I think there is space for a Rust+GC language (or just improved ecosystem and tooling for other functional langs)
I rather have one package that does it right, than 20 doing a different set of 80% from what is needed.
As just one example: there are no OCaml or Haskell packages for natively reading or writing parquet files. There are at least 2 such packages for Rust.
Likewise, despite crates.io, Rust is missing lots of stuff I miss from Java, C# and C++.
Plenty of stuff isn't there yet.
Ahem... Excuse me, sir, do you have time to talk about our lord and savior Robert Pike?
Politely closes door in your face
https://github.com/golang/go/issues/57644
Although I'm curious, what is your use case that cannot be fulfilled by interfaces, but can with sum types?
Sum types allow you to express type-level OR. Structs/tuples etc are AND (product types). I like to be able to use both OR and AND at both the expression and type level.
But that doesn't mean Rust with GC wouldn't be a nice language! I don't know of any language that really fits that description.
In fact, I'll pick just four things I like about Rust: a good type system, a supportive, productive community with a focus on doing real stuff, enough mindshare and buzz not to be irrelevant, and being not totally insane.
The first criterion rules out all untyped languages, Go, and the blue collar languages like Java and C#. The second rules out all existing functional languages. The third rules out whatever niche language you're about to suggest. The fourth rules out C++.
There really isn't a mainstream managed language which has managed to thread both the design and community needles the way Rust has.
Rust as a midpoint between Haskell and C, so really C with a nicer type system, combined with momentum/meme status is more important today than it's memory safety story. Memory safety just isn't the buzzword that it was even in 2019 let alone 2015.
> And you can probably bang out one yourself if you want.
I've written my own dataflow analysis code, aimed at lifetime checking, too, but that's not really the point is it?
Rust is C++ with stronger typing and a better memory management model. Comparisons between Rust and C aren't really accurate because Rust is the modern stainless steel kitchen sink that C++ can't be. Without its lovely ADTs and borrow checker it's just C++ with a better package management story. Nothing wrong with that. It won't replace C.
> requisite time trudging through the lifetime swamp
Rust really isn't like that. It gets a bad rap.
> I couldn't couldn't imagine running my whole life on the stack
Most of the things you touch in Rust are heap allocated.
> this would be a really nice language if it were garbage collected
It is a nice language, and all of your complaints go away once you start using the language idiomatically.
Is it just me or do release semantics not make sense for a load? Release is for stores (I'm coming from the C/C++ atomics model). Hm, the docs[1] say a Release load will panic:
> Panics if order is Release or AcqRel.
Maybe just a blog transcription typo for tag.store(TAG.load(Relaxed), Release).
[1]: https://doc.rust-lang.org/std/sync/atomic/struct.AtomicUsize...
It is in general undecidable whether an object is unreachable, but we can get good performance with heuristics.
I understand that "forever" is a nebulous term, but it works both ways. We both know that no computer program could actually run forever.
So, at what point in time the person who is observing a running program is going to declare it doesn't halt? What if they conclude the program doesn't halt, just for it to actually complete execution the very next second?
In practical terms, that's why we have timeouts. And in some cases relying on a reasonable timeout is fundamentally the best we can do -- exactly because we can't deterministically answer if a program ever halts.
> We both know that no computer program could actually run forever.
No program can actually run forever, of course. But any program will stop eventually due to external factors -- me hitting Ctrl + C or pulling the power plug or whatever. The halting problem asks whether it's possible to tell if a program will ever stop or not according to its internal logic.
Here's a (likely dumb) analogy off the top of my head regarding a program stopping due to external reasons. Imagine, I toss a coin, lightning strikes and evaporates the coin while its midair. Do I get to say it was heads or tails? Guess, it's impossible to claim either way.
That "point in time" is within time itself condition on the program halting. If the program never halts, then that point in time is "never". We might disagree on whether "never" is a point in time at all... We might even disagree on whether "never" as a concept even exists or makes any sense.
Yet as you say, all programs will halt, one way or another. You divide such factors into "internal" vs "external", but if we really think about, no computer program is an island; Computation is a property of the material universe. I wouldn't be so quick to draw the distinction between internal logic and external interference. The program itself wouldn't exist without external input, and its result is only meaningful in the context of the reality to which it has effect.
I understand the nature of the abstract philosophical academic conundrum, but also that it's a flawed and incomplete way of modeling real world concepts. To me the halting problem is a practical engineering one; A matter of performance. A faster way to find the same answer, i.e. optimization.
Consider internal errors as well. The computer can have a hardware bug that causes the program to never halt even if in theory it should. Internal interference in the flow of electrons within its circuits could throw it off. The coin in your example could be faulty, and shatter due to internal tensions upon landing.
Reference counting helps the tracing phase out by pruning objects that are clearly garbage early — if a refcount hits zero, that object can clearly be deallocated safely. The converse is not true, which is why we need tracing.
Garbage collectors usually also make assumptions in the other direction (a non-conservative approximation) when it comes to finalizable resources (e.g., file descriptors). These uses do not involve the reference that is visible to the collector. This gives us Reference.reachabilityFence(), and again mistakes are blamed on the programmer.
Edit: Well, I didn't think it through. You're correct. A "perfect GC" is indeed undecidable, and all the real world, practical GC have "false positive" (a piece of memory is no longer used by the code, but there is no way to know it). A perfect GC without false positive is equal to halting problem.
Example:
var obj = new Object();
// === real code begin
... 50 lines of real code that never uses obj
if (something == true) return;
... another 50 lines of real code that never uses obj
// === real code end
obj.doThings();
A perfect GC can release obj while the real code is running. A real world, imperfect GC can't, since whether the real code returns early or not is undecidable.No, s/he's not. The original claim was:
> It is in general undecidable whether an object is UNREACHABLE. [Emphasis added]
Being unreachable is not the same thing as being "no longer used by the code". The latter is indeed undecidable, but the former is not.
It doesn’t help you write a garbage collector to think of it that way. It doesn’t provide insight into the problem. The problems are elsewhere. Focus on reachability, dynamic understanding of the heap, performance, etc.
What are some cases? The only I can think of is if a "remote" thing owned the memory. For example, if I sent a pointer elsewhere, then reused it later. Like, maybe with a device driver, or library.
If new Object() truly doesn't have any effects visible other than the valid reference, then it's creation can be moved to after the if statement.
The issue is not necessarily unreachability being undecidable. The issue is that reachability depends on control flow. And that implies being conservative which translates into having to deal with false positives.
That static analysis in a nutshell: how to get precision and soundness.
Probably a mere question of terminology.
1. The GC frees objects that could be reachable (program gets corrupted in spectacular ways).
2. The GC never frees anything (as it can't decide).
The GC may not dispose of objects immediately, but when it determines they're not reachable, they're not reachable, period. There's nothing undecidable about it.
GC are concerned with the potential eventuality, which is 100% decidable, not with the latter, that was never under discussion.
To say "reachability is undecidable" means you can't decide whether you MAY or MAY NOT reach it. Which is wrong. You can decide whether you MAY or MAY NOT reach it. And if you MAY NOT... then that's 100% unreachable and safe to collect.
The conservative approximation that GC's make is that they keep all objects that could be accessed in the future by the program (reach-able) instead of only keeping the objects that will be used in the future.
A trivial example is:
void main() {
var obj = new Object();
while (true) {
// Do other work...
if (dayOfWeek == 9) {
print(obj);
}
}
}
Since there are only seven days in the week, that `print()` statement will never be reached, and an optimal GC would free obj. But the GC can't determine that. All it knows is that it could possibly be reached, so the object stays in memory.https://web.archive.org/web/20130607161259/http://pcwalton.g...
But I can list a few thoughts. The actual memory model is (seemingly) a flat address space, in which processes allocate segments, and everything else arises from there. There's some remapping behind the scenes, some segments are shared, some not, but this is beside the point.
And a pattern I see often is arena allocation, where you allocate a large segment and then "virtually" allocate smaller segments inside, down to individual scalar variables. This can happen several times with a lot of memory, but tends to be shallow.
For this and many other reasons, I see mutable memory is best structured as a tree, a shallow tree, almost relational, but still permitting nesting, where every item has one owner, one caller, and anything else is routed through that tree. Of course once this semantic model is established, you can reduce and optimize many calls into more direct calls. But you get clear intercept points when you need to decorate, block, remap and so on behaviors in the system. No "action from distance" surprises.
Everything should be pass by copy. For larger structures, we need copy-on-write optimization. Which means underneath the mutable tree there is a Directed Acyclic Graph of immutable segments supporting copy-on-write.
This is also not new, it's how forking works on Unix for example. Every modern OS utilizes copy-on-write on the file system, in memory, it's also utilized, if we think about it, in network caching systems. But to the process it looks like normal flat address space, where everyone owns their own copy of the content.
For collecting unused segments in COW you'd naively need refcounting. There are other approaches that borrow from GC algorithms, in a much simpler and more performant way, because, well, there are no cycles in a DAG. The best part is that once you decide to use copy semantics, you have a plethora of algorithms at your disposal to try and switch between adaptively (including... just copying the thing if it's small enough, which turns out is the fastest approach), while maintaining the same exact semantics for the programmer who doesn't have to care how it works under the hood.
Another benefit of copy semantics is that things are copied between processes, or especially between machines in a network, and now your language locally has the same semantics as when it talks to the network, which removes barriers and unifies interfaces. "Write once, run at any scale" if you will.
Anyway, this is not comprehensive. And just my opinion. But the problem with graphs is that it is the data structure with least discipline, and most complications. And in most cases those complications do not pay off. Not only they require complex GC, but they result in heavily entangled code, tons of shared mutable state, that gave the whole OOP space a bad name (that it doesn't deserve). This was never a problem with objects, because objects were never supposed to share mutable state. It's a problem of graphs and handles, a system that pretends everyone "has" a mutable piece of state... which it doesn't have, as ownership must be exclusive to be reliable.
Discipline of dependency management (packages etc.) often removes cycles and results in a tree or a DAG. This is not coincidental. And there's no reason we can't replicate this at the lower levels to individual objects.
Of course, we still need graphs. But not everywhere, and not all the time.
You can also easily reproduce a graph when you need it, by using simpler data with computation on top which builds the graph "on the fly", without it having to be your baseline memory model. You can for example easily describe a graph in two SQL tables: edges and nodes. But SQL state itself is not just any graph, and for a very good reason.
You probably know that the first databases were hierarchical and graph. SQL won not because it gave more freedom, but because it removed freedom in a highly specific way, which enabled larger more reliable systems, contrary to naive expectations. Too much freedom is freedom to shoot yourself in the foot, whether you know better or not.
Today graph databases have enjoyed a renaissance, but despite they have solid uses cases, they'll never be the baseline type of database. They won't replace relations. People have learned their lesson in databases. But in programming languages, they haven't. It's one of those things where we can't translate knowledge from one area to another, because the words we use for the same effects are different and therefore we can't make the connection.
I wrote more here: https://news.ycombinator.com/item?id=37122934
And if you have a single edge not fulfilling the stricter structure's properties, you are left with a general graph. A single one, hence my point.
> but because it removed freedom in a highly specific way, which enabled larger more reliable systems, contrary to naive expectations.
That I agree with, but I personally believe it is too restrictive in case of memory models. Especially that the price really is not high -- modern GCs have insanely good throughputs with bounded latency, the only price being a slightly larger memory footprint.
And this is why it's not good to have a general graph as a baseline model, because one mistake or temptation to stray from a structure with given properties, and you lose all the benefits of a constraint.
"This object will be passed deep into a call tree, but never have mutable methods called on it from more than one place in a process, but it'll be read from multiple places". Good luck enforcing this in a general object graph.
But if you eliminate mutable shared state, say like Rust does (not that I approve the specifics of Rust exactly) then you don't have to worry about it. Rust doesn't implement just a general object graph where everyone can have a reference to everything all the time. It has constraints.
> That I agree with, but I personally believe it is too restrictive in case of memory models. Especially that the price really is not high -- modern GCs have insanely good throughputs with bounded latency, the only price being a slightly larger memory footprint.
However as I noted in the link, the GC is the smallest price you pay.
Also it's not a slightly larger memory footprint, GC languages typically take 2x the RAM.
You mentioned SQL. The reason it can be so performant is that it doesn't let the user over-specify their constraints, letting the computer optimize its plans, storage layout, etc. I'm sure one can physically write a particular query faster than a DB could execute it, but it is definitely not an easy task. So strangely enough, both strong and weak constraints can give you good performance. To get back at the exact topic at hand, let me reference Rich Hickey's term: "Place-Oriented Programming", referring to this notion that 'objects' are constrained to a given physical location, over being what they should be: semantics. When you write a program and you use a list, you don't actually care about that list being in a particular place, having to be moved when more items are added to it, etc, those are all implementation details. You care about its "list-ness" only. I see GCs with arbitrary graphs as allowing for this mental model. (Note, that an object not having identity, aka being a value type is a semantic question, and that allows for plenty optimizations by a decent runtime, so I'm not telling that we shouldn't care about performance).
> GC languages typically take 2x the RAM.
There is a niche where that is a huge price, but in 99% of cases I believe we can afford that. Also, GCd languages also have their own escape hatches when needed.
EDIT: Nonetheless, I hope I don't sound too argumentative, I genuinely enjoy this discussion and our differing view points - I'm engaging with curiosity, even if the tone tells otherwise, I'm no native speaker.
It will be very interesting to survey how many Rust implementations nowadays do away with memory safety [1].
Not sure if the author of the article referred to this seminal paper on Rust for GC for his GC implementation for Rust in Rust [2].
[1] What is a safe programming language?
https://cs.stackexchange.com/questions/93798/what-is-a-safe-...
[2] Rust as a Language for High Performance GC Implementation:
https://users.cecs.anu.edu.au/~steveb/pubs/papers/rust-ismm-...
Saw Python last at university and just because I wanted to.
I know nobody that works with Python on a day by day basis and I know many nerds like me :)
Just wanted to put it into perspective, because people tend to think their bubble is the whole world. (Oh the irony;-))
That being said I have observed that using Python as a web/backend language seems to be more common in North America than in Europe, but I have no evidence to back that up.
I actually haven't read that paper! Thanks for sharing it. What I'm doing is slightly different - it looks like they're building a conservative GC with an unsafe API, much like one might for C. My intent was closer toward building a GC which can be used with any safe Rust code more or less unconditionally.
Why? Excitement doesn't always follow correctness or even "goodness" (whatever that means in context), even if we'd like it to. Fashion and marketing matter more when it comes to excitement.
I worked very little with D, and a lot more with Rust. I cannot really speak to the qualities of D because, by and large, I dealt with a very few instances of it, and only at the interface level (mostly writing in another language). But, in terms of fame and popularity -- Rust developers and backers put a ton of effort into promoting the language. There were times when every other week there would be an article in some popular tech. media source from all kinds of "big names" about how Rust is superior to something (usually C++). There were massive efforts made to create a self-sustained community of Rust-evangelists who'd go and spread the word to the heathens...
I haven't been around to witness the birth of D and don't remember if it had any kind of adoption campaign, but from what I can tell, even if it had a campaign, it didn't succeed.
Now, here's another example from the same category of languages: Ada (esp. with Spark). I'm not very knowledgeable about it, but from the little I know, there are a lot of good reasons to prefer Spark over Rust. At least, on engineering merits, they should be roughly equivalent. But, in terms of popularity, Rust beats Spark by a lot.
I mean, I understand this is an attempt at being a snarky remark, but it gets really annoying as a joke that a lot of people repeat to the point they start believing it's true.
Rust is facing a similar problem in terms of sync vs async, which seems to limit options primarily for folks who want to avoid async, but it doesn't seem like a major blocker for adoption comparatively.
Overwhelming majority of mainstream programmers want important choices to be made for them.
- People want language designers to decide: Has GC or no GC. Choose one and be consistent with it. Then all the libraries will be written on top of it.
- People want language designers to decide: Has async green thread or not. Choose one and be consistent with it. Then all the libraries will be written on top of it.
- People want language designers to decide an auto format syntax style. Choose one and be consistent with it. Then all the libraries will be written on top of it.
etc. etc.
Rust takes its fundamentals from more advanced and well thought out functional languages. As a result it outclasses D and its ilk.
So if I understand correctly, every time a Gc drops you add it a hashmap and then periodically, you run through all Gc's in that map and trace all their children to see if they are part of a cycle? I still don't understand how this would work without knowing the rootset. Just because something is part of a cycle doesn't tell you if it is inaccessible. There must be something here that I am missing.