If done exceptionally well, the only visible evidence of the leak will be substantial performance disparities between seemingly symmetric features (well done! this is unusual).
If done with the normal level of software design quality, the evidence will show up as quirky behavior, imperfect symmetry, "well this one always works but the other one is not reentrant", etc.
As with basically everything, there are tradeoffs involved. Sometimes restrictions can be helpful for keeping things understandable, which can in turn make optimizations easier to implement. As a rather hamfisted example: completely unrestricted goto. Very general, debatably powerful, but relatively easy to use in a way that makes comprehension difficult. That same generality can also make it difficult to verify that optimizations don't change observable program semantics compared to something more restricted.
Price paid: a) you need a GC (ok, whatever, sure, you have one), b) well, the performance hit of having to GC activation records (you don't care; others do), c) whoops, thread-safety got much harder and maybe you can't even have threads (imagine two threads executing in the same activation record at the same time!).
That's a very very steep price.
When a reference count hits zero, that's when refcounting begins to be concerned with a dead object; and that part of its operation corresponds to the sweep activity in garbage collection, not to the tracing of live objects.
It is not a dual to garbage collection concerned with its negative spaces; it's simply a less general (we could legitimately say lesser) garbage collection that doesn't deal with cycles on its own.
There are different implementation and performance trade-offs associated with both. I’ll focus on the two that are most meaningful to me.
Reference counting can be added as a library to languages that don’t want or can’t have a precise garbage collector. If you work in C++ (or Rust), it’s a very viable way to assure that you have some measure of non-manual clean up while maintaining precise resource control.
Similarly, when performance matters reference counting is essentially deterministic much easier to understand and model.
In a lot of situations, garbage collection is an overall better strategy, but it’s not a strict superset, and not always the right choice.
Is it? What happens if you remove that one last reference to a long chain of objects? You might unexpectedly be doing a ton of freeing and have a long pause. And free itself can be expensive.
And it pops up in the profiler immediately with a nice stack trace showing where it rooted from. Then you fix it by e.g. moving cleanup to background to unlock this thread, not cleaning it at all (e.g. if the process dies anyway soon), or just remodel the data structure to not have so many tiny objects, etc.
Essentially this is exactly "way more deterministic and easier to understand and model". No-one said it is free from performance traps.
> And free itself can be expensive.
The total amortized cost of malloc/free is usually much lower than the total cost of tracing; unless you give tracing GC a horrendous amount of additional memory (> 10x of resident live set).
malloc/free are especially efficient when they are used for managing bigger objects. But even with tiny allocations like 8 bytes size (which are rarely kept on heap) I found modern allocators like mimalloc or jemalloc easily outperformed modern GCs of Java (in terms of CPU cycles spent, not wall clock).
Yes.
> What happens if you remove that one last reference to a long chain of objects?
A mass free sometime vaguely in future based on the GC's whims and knobs and tuning, when doing non-refcounting garbage collection.
A mass free there and then, when refcounting. Which might still cause problems - but they are at least deterministic problems. Problems that will show up in ≈any profiler exactly where the last reference was lost, which you can then choose to e.g. ameliorate (at least when you have source access) by choosing a more appropriate allocator. Or deferring cleanup over several frames, if that's what you're into. Or eating the pause for less cache thrashing and higher throughput. Or mixing strategies depending on application context (game (un)loading screen probably prioritizes throughput, streaming mid-gameplay probably prioritizes framerate...)
> You might unexpectedly be doing a ton of freeing and have a long pause. And free itself can be expensive.
Much more rarely than GC pauses cause problems IME.
They are not the same because there are "semantic tradeoffs".
Garbage collection also traces dead objects. Or at least some kinds of GC implementations that are not copying. when the marking is done, the heaps are traversed again to identify dead objects, which are put onto a free list. That's a trace of dead objects. (Under copying collection, that is implicit; live objects are moved to a new heap and the vacated space is entirely made available for bump allocation.)
In a mark & sweep GC, the mark phase traverses the object graph, visiting only live objects. You need to recursively visit any objects that are not already marked — this is the process known as tracing. The marking time is proportional to the number of live objects.
In the sweep phase, you typically do a linear traversal of memory and reclaim any objects that are not marked. You do not examine pointers inside any objects, so the graph structure is irrelevant. The time is proportional to the total size of memory.
In reference counting, when a refcount hits 0, you need to decrement the refcount of pointed-to objects, and recursively visit any objects whose refcount is now 0. The time is proportional to the number of objects that have just died.
Structurally, decrementing refcounts is *very* similar to tracing. You're right that it's purpose is similar to the sweep phase of a mark & sweep GC, but they aren't algorithmically similar.
Garbage collection culd also trace both spaces. After we have performed the mark phase of basic mark-and-sweep, we could again go back to the root pointers and traverse the object graph again in the asme way, this time looking for objects which are not marked reachable. Similarly to refcounting, we could do the finalization for each such object and then recursively look for others that are unreachable.
We don't do that for various reasons:
- the belief that it's faster to traverse the heaps, even though in doing so, we it may be necessary to skip numerous free objects.
- the use of a copying algorithm, whereby we moved the live objects to a new heap, so there is no need to trace or otherwise traverse the dead space in detail.
The belief is justified because there usually aren't any free objects when GC is spontaneously triggered. A recursive trace vists the entire graph of objects that were previously live, some of which are now dead. A sweep through the heaps visits all the same ones, plus also some entries marked free (of which there are none in a spontaneous GC pass).
But the recursive traversal involves inefficient dependent pointer loads, poor caching, and the visitation of of the same objecct multiple times. While in the case of dead objects, we can easily tell that we are visiting a dead object a second time (the first time we finalized it and marked it free), we have to account for multiple visits to the reachable ones; we have to paint each one a new color to indicate that it was visited in the second pass.
Also, the closest thing to a graph search in reference counting, as most of us understand it, occurs at a totally different level in the stack.
In a GC: the graph search is hidden from view and implemented in any number of clever ways by the language runtime. Or, if you’re implementing the GC yourself, it sits out of the way of normal operations on your “heap”.
In ref counting: down ref to zero triggers a destructor. That destructor can do anything. It so happens that if the type has reference counted references, then the destructor will, either automatically or manually, invoke downref operations. All of this is part of the language’s normal execution semantics.
To say that these things are alike is a stretch. You could use similar logic to say that anyone doing a graph search is collecting garbage
That can be true of garbage collection also; it can be bolted on in a way that the application is responsible for providing a handler for traversing objects during marking.
(Of course, it is vastly less likely that a marking traverser would be doing anything other than its job of recursing or otherwise indicating pointers to be traversed, whereas finalization routines will mix the downing of references with other resource clean up.)
> […]
> and that part of its operation corresponds to the sweep activity in garbage collection, not to the tracing of live objects.
The sweep activity in garbage collection traces live objects. https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...:
“The overall strategy consists of determining which objects should be garbage collected by tracing which objects are reachable by a chain of references from certain root objects, and considering the rest as garbage and collecting them.”
Sweeping is just
> considering the rest as garbage and collecting them
Perhaps general purpose systems of these sorts aren’t suitable for specialized applications… but I don’t get the “hate” (if you can call it that) which some programmers have for GC.
GCs have a lot of tradeoffs involved. It's impossible to check all boxes and that means that there's going to be something to gripe about.
If you want your GC to be memory efficient you are likely trading off throughput.
If you want your GC to allocate fast and avoid memory fragmentation, you are likely over-provisioning the heap.
If you want to minimize CPU time in GC, you'll likely increase pause time.
If you want to minimize pause time, you'll likely increase CPU time doing a GC.
All these things can make someone ultimately hate a GC.
However, if you want a programming language which deals with complicated memory lifetime (think concurrent datastructures) then a GC is practically paramount. It's a lot harder to correctly implement something like Java's "ConcurrentHashMap" in C++ or Rust.
I will put forward some arguments. I do not believe all of them:
GC is for lazy programmers who do not know how to manage memory.
GC takes aspects of memory allocation out of my control. If I control all the things I can get the best performance.
If you care about performance: when your program does not need GC, GC is is pure overhead.
If you care about performance: if you must use GC then you must have a high-performance GC available. So the question is not just GC/no-GC but you have to worry about details of the GC -- it's a leaky abstraction.
If you care about average latency: if you must use GC then you must have a low-latency GC available (i.e. a GC with low and bounded pause times)
If you care about meeting real-time deadlines: if you must use GC then you must have a GC that is guaranteed to meet your timing constraints.
Corollary of the previous three points: GC is not just GC. Someone else's GC is often not the GC you want. Your program requirements can impose strong requirements on the GC algorithm, and you don't always have the necessary control over the GC.
For a particular language, GC algorithm and/or implementation can be a moving target. If the GC developer's goals don't stay aligned with your requirements then you are hosed.
GC results in unpredictable memory allocation performance. Ironing out these performance issues (e.g. by avoiding allocations, pooling objects, etc.) is just as much work as using manual memory allocation, so why bother with GC.
Unless you have allocation patterns or other requirements that really need GC[0], it's easier to just avoid GC.
[0] e.g. some lock-free algorithms depend on the presence of GC
If this is what I'm thinking of, it does not require GC, only "defer deallocation until all running threads check in", which is ... not trivial, but certainly feasible to do in RC and much easier than making a decent GC.
In any case, if one is doing GC in such a language, a full tracing collector (whether copying or mark & sweep) is madness, as to find live references means walking nearly the entire heap including the portions living in secondary storage, and now you're in a world of pain.
In this case, an intelligent cycle collecting garbage collector in the Bacon style was the answer. You keep in in-memory table of reference counts, and you only trace when you hit cycles. [and hopefully design your language semantics to discourage cycles]
How do you tell when you've hit a cycle?
> hopefully design your language semantics to discourage cycles
Why? Cyclical structures can be very useful. For example, it can be very handy in many situations for a contained object to have a back-pointer to its container.
[UPDATE] Two responses have now pointed out that this particular case can be handled with weak pointers. But then I can just point to a general graph as an example where that won't work.
That's not a true cycle, it's just a back link for which "weak" reference counts suffice. The containment relation implies that the container "owns" the object, so we don't need to worry about the case where the container might just go away without dropping its contents first.
(Edit: I agree that when dealing with a truly general graph some form of tracing is the best approach. These problem domains are where tracing GC really helps.)
> hopefully design your language semantics to discourage cycles
thus expanding the scope of their comment beyond that specific use case.
So the "increased cognitive overhead" is intrinsic to the problem domain, not an unforced defect of the language design. Overgeneralization in such a case would induce even worse overhead as there'd be no user-level way to fix perf.
Does it frequently need an owning reference though or would a weak reference suffice? Usually the latter situation suffices.
But then I'll just choose a different example, like a general graph.
https://pages.cs.wisc.edu/~cymen/misc/interests/Bacon01Concu...
TLDR there are heuristics which can give you a hint. And then you trigger a local trace to see.
> Why?
Because then you incur the cost of a trace -- and potentially paging in from slow-slow disk -- vs a simple atomic refcount.
Even just a localized trace on live objects is a pointer-chasing cache & branch prediction killer.
I can see a periodic compacting phase could be useful in a system like that.
In the DB world there's good research around similar topics. e.g. LeanStore and Umbra -- Umbra in particular does some nice things with variable sized buffers that I believe are expected to help with fragmentation https://db.in.tum.de/~freitag/papers/p29-neumann-cidr20.pdf
mark-sweep obviously not, as it does not defragment and also stops the world.
Instead, he could just use the references he needs in the new tree, delete/override the old tree's root node, and expect the Javascript GC to discard all the nodes that are now referenced.
> Then, my plan was to construct a ProseMirror transaction that would turn the old tree into the new one. To do that, it’s helpful to know which nodes appeared in the old document, but not the new one.
So, it's not actually about reclaiming the memory. It's about taking some action on the nodes that will be reclaimed. It's akin to a destructor/finalizer, but I need that to happen synchronously at a time that I control. JavaScript does now support finalization (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...) but it can't be relied on to actually happen, which makes it useless in this scenario.
"This was the answer I needed! Rather than visiting all the live objects, I wanted to only visit the dead ones, and reference counting would let me do that.
So I added a way of maintaining a reference count for all the nodes in the doc. When we produce a new document, we decrement the reference count of the old root node (it will always be 0 afterwards). So we recursively decrement the ref count of its children, and so on. This gives me exactly what I wanted — a way to find all the nodes that were not reused, without having to visit most of the nodes in the doc."
I think there's a bit missing from the description here in the and so on, you would only recurse on a node when it's new refcount is zero, right (and the set of zero refcount nodes produced is exactly the set of dead nodes)?
Isn't this sort of just like having a dirty flag on nodes, and then replacing dirty nodes?
Except, if someone want to pay Grug to work on garbage collector for javascript framework, Grug put in position where Grug learns what Grug don't already know about it because it now Grug's job. So Grug understand why Sun solve problem, why problem hard, tell other Grug about isomorphisms between spanning trees. Now other Grug know more about what other Grug don't know, why other Grug not make same mistake of knowing better than Sun Microsystems either.