back
144 comments
Nice blog post! I also wrote a concurrent reference counted cycle collector in Rust (https://github.com/chc4/samsara, https://redvice.org/2023/samsara-garbage-collector/) though never published it to crates.io. It's neat to see the different choices that people made implementing similar goals, and dumpster works pretty differently from how I did it. I hit the same problems wrt concurrent mutation of the graph when trying to count in-degree of nodes, or adding references during a collection - I didn't even think of doing generational references and just have a RwLock...
That's really cool! Thanks for the kind words. I remember peeking at it while hunting for collectors to benchmark against.
Why not publish to crates.io? This seems useful
Having some marine background the first sentence confused me, I didn't realize people use the crab mascot for rust so much to use evolutionary terms. But there is already a term for the "opposite" of carcinization, it's just decarcinization. This analogy can come full circle if the next variant of this language uses the coconut crab or a hermit crab as its mascot.
You hear that crab-lang? :D Decarcinization is the correct opposite of carcinization. Hermit crab would be the perfect mascot in both function and metaphor.
> If carcinization happens when languages evolve to be more like Rust, then what do you call it when Rust evolves to be more like Java? Caffeination?

Surely oxidation is better than carcinization. And rust evolving to Java would be mineralization? Or maybe germination?

Eh, carcinization is a better ontological fit. Many separate things evolving into the same structure, versus the process of losing electrons and/or structurally degrading.
And it's the version I've actually heard used (multiple times) before now - having looked up 'carcinisation', I assume that comes via 'rustacean' (cf. crustacean), which makes it a bit more contrived and then sort of implies the result is a Rust developer, not Rust itself?

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!

I'm going to point out one potential footgun with your library: circularly-linked Drops.

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.

[1] https://peps.python.org/pep-0442/

[2] https://docs.python.org/3/library/gc.html#gc.garbage

These issues are currently handled by my library. It’s not mentioned in the blog post because it would be a little distracting. Dereferencing a “dead” GC during Drop currently yields a panic, while all GCs must be ‘static.
This comment would be significantly improved by fully spelling out what UB is at least once.
This is really impressive work and I applaud the author for building something that I'm sure lots of folks will find interesting and possibly very useful, but I want to say that I think there's a reason reference semantics (using and sharing heap allocations) are hard in Rust: It's because references are just plain hard to use safely. Rust tries to make the gnarly things awkward, but what is the easy path?

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.

[0] https://www.hylo-lang.org

[1] https://youtu.be/QthAU-t3PQ4?t=2354

[2] https://www.youtube.com/watch?v=4t1K66dMhWk

I know of a lot of people trying to do "arena" GC in Rust using integer indices. I think that's an excellent approach, but it does mean you have to pass in the arena as a resource to every single function which produces an allocation. This isn't even necessarily a bad thing - the net result is a lot like Zig's allocator API.

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.

> 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 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.

Is this essentially the "cyclic reference counting" algorithm from the Garbage Collection Handbook [1], Chapter 5, Algorithm 5.5?

[1] https://gchandbook.org

I’m not a Rust programmer, but I’ve heard Rust has compile-time AST macros. I wonder if it would be possible to implement a Rust GC by defining a macro to tag garbage collected sections of code. The macro expansion could then insert cycle-checks on moves as described early in the post. I have no intuition for how performance would compare to the trait-based approach that the author went with.
IME a lot of people want some kind of help from the compiler when writing a GC, but there is the fundamental static vs. dynamic problem.

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.

In Rust, macros work at the tokenization level. Making a macro do that would require the macro to fully parse and analyze the syntax tree - possible, but not very efficient. Additionally, there's no guardrail to enforce that the user doesn't move a `Gc` anyway.
Yeap. 2 flavors: procedural and pattern matching. Even the build can be scripted in Rust. And there is constant evaluation. https://doc.rust-lang.org/reference/const_eval.html

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.

after spending the requisite time trudging through the lifetime swamp (I couldn't couldn't imagine running my whole life on the stack), and seeing all the nice things about rust, I couldn't help but thinking at the end 'this would be a really nice language if it were garbage collected'. of course you can't say that.

this is nice

That language is called OCaml.
This post is a horrible intro to Rust. It's a fantastic work, but totally outside of what a normal everyday Rust program looks like.

> 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.

It's not taboo to say so, even within the Rust community. It's something that will happen eventually. You might be interested in reading https://without.boats/blog/revisiting-a-smaller-rust/
> of course you can't say that.

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.

> box_ref.tag.store(COLLECTING_TAG.load(Ordering::Release))

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...

This is correct, thank you. I'll fix it shortly.
I’ve recently been looking at this space for using a Gc smart pointer in a language I’ve been writing. It’s extremely useful - you can integrate builtins and external functions very cleanly
Honestly, this is a very cool project. I would suggest you publish this as a formal paper to a pl journal.
Always remember: a garbage collector is a theorem prover that proves that objects are unreachable, over and over, so that they can be destroyed.

It is in general undecidable whether an object is unreachable, but we can get good performance with heuristics.

It's not. You're describing a static analyzer that inserts free() automatically. Not GC. GC is pretty much the opposite.

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.
It's easily decidable whether a given GC object could be reached. What isn't decidable is whether a it will be used in the future.

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.
Why is it undecidable? Is it not just a graph traversal/coloring problem?
I'm not sure this is right. the classic gc approach is dynamic, not static, and it discovers the reachable set, not the unreachable one. please elaborate?
Empirically what you say can't be the case, because if it's "undecidable" then one of these must be true:

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.

I'll join the others in trying to bring up a nuance.

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.

As I understand it, Rust originally had support for garbage collected objects. It was removed because the community avoided it as a general practice to ensure that libraries could work without the optional GC runtime, due to its popularity as a systems language.

https://web.archive.org/web/20130607161259/http://pcwalton.g...

Yeap. Vec, String, Box, format!, and println! all depend on alloc for heap allocations. If an allocator is provided, alloc can be used in no_std. For format!, there are alternatives such as providing a fixed u8 buffer. It's quite amazing what can be accomplished without alloc.
I think GC is a very cool set of algorithms that have their place for collecting detached elements of a graph. But in a programming language, the baseline memory model should not be a graph in the first place.
What else should it be? Besides the memory model being an implementation detail, that is not inherent to the problem, most problems (mathematically for sure, but imo also practically) require general graphs.
That's an interesting idea. Can you clarify some more on what the baseline memory model should be, if not a graph?
Personally I think D language got it right when it makes GC as a default but still allows for no GC whenever applicable thus keeping most of the language relatively safe, easier to grok and more Pythonic compared to Rust.

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-...

D made the right tradeoffs for whom? There are many safe languages with great ecosystems which have a GC. Realistically, before rust, there wasn't really any for languages without a GC. A major problem for folks who must not use a GC is that large parts of the D ecosystem rely on the GC which makes it not useful for the segments that cannot use said GC. I've not actually used D professionally, so I could be off base, but this is at least the perception.

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.

I think D failed because it doesn't take a stand one way or the other.

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.

D was doomed from the start. It was designed as an incremental improvement over C++. Initially it provided refuge to burnt out C++ programmers like myself. But once I discovered Rust there was no coming back.

Rust takes its fundamentals from more advanced and well thought out functional languages. As a result it outclasses D and its ilk.

The big reason why this library exists is so that downstream consumers can be entirely memory safe in their allocations - it's the same way that the Rust standard library works. Library writers can use unsafe code to provide safe abstractions and APIs, so you end up with an unsafe core with a safe framework on top of it. I'm just continuing that tradition.

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.

You would think that if D "got it right," it would have seen a tenth the excitement / engagement Rust does. The proof is kind of in the pudding?
This looks very cool! Thank you for sharing. I find GC's in rust particularly fascinating.

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.