That's a strange thing to assert, having acknowledged the existence of generational GC.
> Reference counting also has its own running cost, paid on every copy of a pointer you make and every time you drop one.
isn’t 100% true. It’s not necessarily on every copy or drop. Compilers can (and do) elide reference count updates if they can proof they aren’t needed, and can even skip allocating room for reference counts if they can proof it isn’t needed (example: a local object that doesn’t escape its scope)
I also find it a miss that the article doesn’t discuss memory usage. A garbage-collected program needs more memory to match the performance of the equivalent manually managed language.
https://dl.acm.org/doi/10.1145/1094811.1094836 says you need to give it 5 times the memory, but that’s from 2005 and likely outdated.
What people forget too is that new and destroy in C++ aren’t exactly cheap either. As far back as Java 5 the hotspot people were bragging on how new in Java was cheaper than new in C++, closer to stack allocation speed.
Of course if you look at any Java library other than ones intended for embedded or HFT systems, you will see Jevons’ Paradox at play. Made allocation cheap, and everyone allocates the hell out of everything, eating up the gains and then some.
But at the same time, the architectural simplicity of not having to worry about object lifetimes makes APIs simpler, which allows people to invest that brain power into making them more sophisticated in other dimensions. Watching Rust try to get itself doubly linked lists was strange to watch from the bleachers.
void foo::method(const &smart_ptr<thing> x) { ... }
The smart pointer passed reference isn't copy-constructed and so no refcount is bumped. It has a scoped lifetime. The calling function owns a reference (baseline correctness assumption or else we are screwed). That caller is suspended while the callee executes. fn foo(x: &Arc<T>)
…quite literally borrows the count in the refcount-ed thing.I think you can find similar things in CPython, on the C side of things.
If we have two generations, young and old, we obviously can't simply "just collect in the young generation" — which I feel like is exactly what most generational GC descriptions say. If we only traced young objects, we could very well miss an old object pointing at a young one, collect the younger object, and now we've got a dangling pointer.
So there must be some book-keeping to avoid tracing everything (or what'd be the point of generations), but I have no idea of what that book-keeping is, or its cost.
> Show me your code and conceal your data structures, and I shall continue to be mystified. Show me your data structures, and I won't usually need your code; it'll be obvious.
Careful; just because you only collect in the new generation doesn't mean that you only trace the new generation. From my understanding a rudimentary generational GC design is to include the old generation in the GC roots so you don't need to examine everything in the new generation - if it's not reachable from the roots it's implicitly dead, and the latter category is assumed to apply to most objects in the new generation under the generational hypothesis.
Well, then I suppose you're saying the up-thread comment is wrong?
TFA:
> Every one of those pointers has to be followed on every cycle
The comment:
> That's a strange thing to assert, having acknowledged the existence of generational GC.
Which would imply that's not the case, i.e., that we're not considering every pointer in every GC sweep. (Which, again, I thought was largely the point of generational GCs: to make sweeps cheap by not considering every pointer.)
I guess if it's really the sweep that's the expensive part, then perhaps doing a full walk is fine.
> generational GC design is to include the old generation in the GC roots so you don't need to examine everything in the new generation
I'm assuming roots (stack references to objects) are separate from generations (which heap objects belong to).
I suppose if you added old objects to the set of roots, that'd also solve it, but that's the same as "every one of those pointers has to be followed on every cycle".
The sibling post thinks writes are made more expensive by tainting/young-ifying objects that get written to. In that way, we prevent an old object from ever pointing at a new one — at the cost of writes now being more than a write.
Edit: Yeah, here's [a note](https://chromium.googlesource.com/v8/v8/+/refs/heads/13.3.25...) about how Chrome implements it. There is additional book-keeping and cost to writes.
I wrote one for TXR Lisp which there are no generation heaps; but objects have a generation. In the same heap, you can have adjacent (unrelated) objects that are in different generations. Objects never move from their heap; they keep their position in the same heap over their lifetime.
The allocator therefore records the new generations in a fresh log, which serves as the nursery. When that log gets full, a pass is triggered.
Implementations with separate heaps (typically copying collectors) allocate in a nursery heap and promote objects from there, but it is conceptually similar.
In the marking phase, we do not process this nursery. We start at the usual root pointers: stack, globals and proceed with normal marking, like in a pure mark-sweep collector (or a full pass), with a small modification: whenever we hit an old gen object, we skip it. We assume that the old gen object is reachable, and every object reachable through it is similarly an old-gen object. This is where the generational algorithm wins, chopping down the graph of objects to be marked, possibly drastically so.
Where the nursery/fresh log comes in is the sweep phase. Because we know that we did not traverse any old generation objects, it would be wasteful to do a full sweep. In the case of the fresh log, we sweep just through the fresh log. Anything in the fresh log that is reachable is promoted to the old generation. Anything not reachable is reclaimed: either immediately or through the finalization treadmill, if applicable. The fresh log is then reset to empty. In the case of a nursery, we similarly just sweep through the nursery heap and promote (by copying) reachable objects to the old generation, reclaiming the rest. The nursery is empty.
There may be additional structures. Note that we have the assumption that old objects only point to old. But what if (it is allowed that) the program mutates an old object to point to a newly allocated new one? That would break the assumption. We can make the program (code generated by compiler or whatever) report whenever that happens.
In the TXR Lisp implementation of generational GC, mutation of old objects is handled via two strategies. A single value assignment of a young object to a field in an old one will cause the young object to be appended to a "check" log. In some cases, this is not practical for various reasons. For instance, an operation mutates a large number of fields of the same object (e.g. array). In such cases we add the old objects to a "mutated" log. The objects added to both arrays have their generation field reset to -1: neither young (0) nor old (1).
Both the check log and mutated log are marked during marking. The check log contains only young objects and so sweeping those is already taken care of by the freshlog, it needs not be visited during the sweep phase.
The mutated log is processed during sweep in order to reset the generations from -1 back to 1.
When the check and mutated logs fill up, GC is not triggered immediately then, but the flag is set for the next GC to be a full one. What that does is turn off the mechanism: since we know a full GC is coming, we don't have to record mutations of old objects pointing to new.
It's best to think about lifetimes and lifecycles where possible. Immutability where sensible and things like pool allocation are examples of this.
GC languages can result in quite pessimistic code because they encourage people to NOT think about what is going on. But people have also built functional HFT engines on things like the JVM by thinking about lifetimes and lifecycles.
The wide variety of options and tradeoffs, with fewer clear lines in the sand than most people seem to think, is already enough to call it a "continuum" but what really finishes the job is that they're all mixable and matchable. Something like Zig makes that really obvious, but most static languages have at least some sort of ability to mix in multiple strategies. There's nothing wrong with a C++ program that uses new & delete, and also uses arenas for some things, and also uses garbage collection for some things, and also has an integrated scripting language like Lua with its own memory strategies. Such programs are not that uncommon... that describes modern games nowadays, the supposed canonical case where you "can't afford GC". But it can... it just fences it in to a particular domain where it fits.
> The leaks that come from forgetting to free something go away entirely.
Not quite: In manually-managed and referenced counted languages with destructors, releasing resources, (open file handle, open socket, open connection to a database, ect,) happens when objects are cleaned up.
In a (tracing) garbage collected language, releasing resources is a very manual process. You might not have a memory leak, but leaking file handles or similar resources is a real problem with real consequence.
Also every single graphics application that uses Metal or DirectX, relies on reference counting as GC algorithm.
Oh shit…
How do you "optimize" the GC away after you wrote your entire database server in a language that uses it?
But the most naive example any language supports is simple object pooling.
Then more fancy, zero allocations tasks in C# https://github.com/cysharp/unitask
Where GC means any kind of GC algorithm from CS point of view.
I disagree with this. The sentence implies that this work is done in order to make the compiler happy, where my experience is that it forces the programmer to actually get it right.
I had an "aha moment" when I was frustrated at failing to express my intent to the compiler, and suddenly realised that the reason I couldn't "just say the magic words" was that my object ownership design was inherently flawed. I had to make large changes not to make the compiler happy, but to actually have a coherent design.
So no, it's not about what "the compiler can verify". That's like saying "my lawyer won't let me do this". No, your lawyer is your employee, not your boss. They're just saying that if you do this, then you may go to prison. It's not the same thing.
("unsafe" is the Rust way to go "thank you, legal department, but I'm making a business decision to take this risk. Your concern has been noted")
Let’s say you have two ways of doing the same thing: both work, both are legit and neither introduce GC bugs. The only difference between the two is that one can be verified by the compiler while the other can’t, so you are stuck with solution no. 1 although both would work.
To phrase it differently: the code that gets verified by the compiler is safe, but is all safe code verifiable by the compiler?
I’m not implying that’s the case, but that’s what I feel the author is saying.
Right. And this reduces to the halting problem, so in theory the compiler cannot know that all safe code is safe.
In practice, I'm saying that not just syntactically, but in your code's design, the compiler is more likely to be right. It's a bit like Chesterton's fence. You can bypass the lifetime checks if you just have the confidence to say "yes, I'll use `unsafe` here and it's fine because these reasons". As you're writing your "SAFETY" comment, you may very well find yourself not so confident anymore. And indeed, often this compiler-induced "stop and think" prevented you steaming ahead with a bug.
Now, the borrow checker is not perfect. I don't know how far away from "all but NP-complete cases" it is. My experience is that it's almost always right, and I've only had to put a seemingly needless "drop" statement to placate it. But they're working on it. A new one is coming: https://daily.dev/posts/rust-s-new-borrow-checker-is-coming-...
And once again this old blog post of mine comes to mind: https://blog.habets.se/2020/12/Bypassing-safety-check-for-ob...
In any case "by arranging your program in a way the compiler can verify" I think is not accurate, because the overlap between "correct" and "compiler can verify" is nearly complete, though yes the latter is a strict subset of the former. In other words I don't write Rust to make the compiler be able to verify it, but to make it correct. And nearly always that means the compiler can verify it too.
I've also had the converse experience, where I know full well that the structure I'm trying to impose is correct and quite efficient, but its part of the space that rust doesn't cover.
Rust is great. It's a noble attempt to bring a degree of correctness to a problem space that suffers from a great deal of slop. But to pretend that the model is complete, or that the design decisions that were made are perfect in every way, is just wrong. That the rust compiler and runtime can't support my construct isn't really an absolute value judgement on that idea in the first place. The rust compiler isn't really an oracle that tells you whether something is right or not in an arbitrary value system.
Still, I don't write Rust code the way I do "to make the compiler happy", but to make it correct. And sometimes it's correct to drop some "unsafe" because gosh darn it, you know it's fine this time.
And you're probably right for the cases you're thinking of, where Rust wouldn't let you (at least without unsafe). And maybe you're 99% sure about that.
But for every 100 changes we’re 99% sure won’t cause an outage, one will…
(also future changes may invalidate assumptions you relied on, of course, making it no longer true)
Do you have some examples you can share where you think Rust prevents you doing the right thing? The ones I run into tend to force me to think of the edge cases, and usually those edge cases don't even have a right answer.
Haskellers say the exact same thing. :P Personally, I'd much rather get shit done and don't appreciate tools "forcing" me.
But I agree that it's must faster to get the wrong answer than the right one.
I'd say GC is always the fastest to free objects within the main code path. Literally zero instructions.
Java pioneered this garbage collection stuff because you had cycles of references. You don't need to have cycles. WeakRef is a much better thing now. All you need is reference counting, and you don't need any garbage collection at all. When the reference count reaches 0, you destroy the object and free up its memory. It's far more predictable than GC, too.
And GC isn't "the fastest" to free objects, it has to walk a graph. The fastest is actually arena allocation and then just dropping the whole thing. But that's exactly what owning an entire container of objects can do. If you have a doubly linked list, for example, A[n] -> A[n+1] but also A[n+1] -> A[n] but neither of those should be a strong reference to prevent reclaiming. Instead, the container of that doubly linked list should be the one having a strong reference to its items.
True, but reference counting or free need not be far behind. They can append the pointer being freed to a per-thread list (⇒ no locking needed) that a separate thread that does the actual freeing periodically claims and then iterates over to actually free the objects.
Disadvantage is that memory usage goes up a bit because the actual freeing is delayed, but that (likely) is less so than with a garbage collector.