The {pnk}f(eli)x Blog

The informal ramblings of an ex-pat PL enthusiast

GC and Rust Part 0: Garbage Collection Background

This post is a prequel to a series of posts discussing why garbage collection is hard, especially for Rust, and brainstorming about solutions to the problems we face.

The goal of this post is to provide the background foundational material about Garbage Collection that the other posts will then build upon.

You can skip ahead to the follow-up posts (once they are published) if you feel you are already well-versed in the low-level mechanics of garbage collection.

I may add more material to this post in the future if I discover a need to provide more detail on a particular subtopic, such as “write barriers”.

(The body of this post makes heavy use of client-side rendering, because of author idiosyncrasies. You may need to wait a moment while the supporting Javascript loads.)

Update (17 November 2015): It has come to my attention that portions of the post are not rendering properly in Google Chrome. I will try to fix this, but in the meantime, you should be able to get the proper renderings in Safari or Firefox. (I do not yet know about other browsers.) If your browser is working as original expected, then you should see a picture load immediately beneath this text.

Update (18 Novemer 2015): many thanks to othermike on reddit who pointed out my bug. The content should now render properly on Chrome (and hopefully other browsers too).

What is Garbage Collection

A garbage collector is a component in the runtime for a programming language that periodically attempts to reclaim memory (without requiring explicit calls to memory-freeing routines in the programning language). To do this soundly, the collector must identify blocks of memory that cannot possibly be used in the future by the program (i.e., “dead objects”).

Discussions of garbage collection often equate the notion of “dead object” with “unreachable object”: If no chain of references exists that could lead the program to an object, then that object cannot be reachedResearchers have explored methods to identify objects as dead even when reachable, such as using “parametricity”; but I am not aware of any such method being used outside of a research setting. (and therefore cannot be used in the future).

When one says “garbage collector”, one usually means a “tracing garbage collector”: a collector that works by identifying the reachable objects by computing the connected components that include the “roots” of the object graph. (The “roots” are the starting points from which any chain of references must originate in the source program.)

So, for example, we might have the following set of gc-managed objects (labelled “A” through “F” below), along with a register file labelled “RF”.

In the simple model above, the roots are the processor registers. Such a model is applicable to a language runtime where all memory blocks (including the stack frames) are managed by the garbage collector. (In other words, we are not talking about Rust yet.)

The reachable objects, as stated above, are the connected components of the graph that includes the roots, highlighted below.

A garbage collector would determine that the objects labelled “D”, “E”, and “F” are unreachable, and thus their storage can be reclaimed.

How Garbage Collection works

A garbage collector is often presented as a coroutine Coroutines are much like subroutines, except that instead of having a parent-child relationship (where the child subroutine “returns” to the parent caller), a call from coroutine A to coroutine B: saves the current context of where A currently is, transfers control to B, and the next time B calls A, resumes the context that was saved at the outset.

In other words, once the linkage has been established between A and B, then A’s calls to B look like returns from the viewpoint of B, (and B’s calls to A look like returns from the viewpoint of A).
that is linked in with the main program. The main program itself is often referred to as a “mutator”, since it is the entity that mutates the object graph. (The collector does not modify the abstract object graph, but rather the representation of the object graph in memory.)

The mutator requests memory from some allocation service (usually deeply integrated with the garbage collector for reasons we will see). If there is a memory block immediately available to satisfy the request, then the allocator hands that over. If there is not sufficient free memory, then the mutator’s allocation attempt invokes the garbage collector coroutine.

Garbage collectors are also often divided into two categories: Copying collectors, and Mark-Sweep collectors. Both collectors accomplish the goal of identifying the reachable objects and reclaiming the remainder, but they do it in different ways.

It is worthwhile to remember at this point that even though our object graphs above are drawn as abstract circles and arrows, the objects are represented somehow in memory.

For example, here is one potential representation for the above object graph, where - denotes some value that the GC knows is not a memory reference.

(Assume for this example that every GC allocated object is made from four consecutive words in memory.)

Regarding the “(header)” words in the diagram: Garbage collectors often require that the GC-managed memory be formatted in a way such that the collector can “parse” it when doing a scan over its address space.

As part of this “parsing”, the GC needs to be able to derive the size of each object it looks at.

The address-space could be partitioned into size classes. Or if objects in a block are to be variable-sized, the size could be recorded in object headers (which are often needed anyway to store things like mark bits or other data).

Clever representation techniques exist that avoid using header words for small objects (like pairs) without requiring size-class partitioning; but I digress.

In these pictures, there is no difference between an arrow pointing to the left- verus right-side of a memory cell; so the occurrence of the pointer to A (0x10010) in r0 is no different than the occurrence of that same value in memory cell 0x10004 (the first non-header word of D), even though the arc for the former is pointing at the left side of the first memory cell of A, and the arc for the latter is pointing at the right side of that memory cell.

Mark-Sweep Collection

A Mark-Sweep collector works by first doing a traversal of the reachable memory, marking each object it finds (e.g. by setting a bit reserved in the object header, or in separate mark bitmap if there is no such bit reserved). This traversal requires some amount of extra memory in reserve to track remaining work for the trace (e.g. a “mark stack” of objects we are in the midst of traversing, and/or a queue of objects scheduled for future traversal).

The “Mark” phase

Here is a sketch of the traversals that the garbage collector makes in order to mark each reachable object.

As reflected in the diagram above, each object that the GC reaches has its mark bit set to “marked” in its header word.

The numbers on the arcs above are meant to correspond to a hypothetical traversal order as the GC marks the memory; particular tracing strategies may yield different orders. (No matter what, we will not trace object “G” until after we have seen “C” via some route.)

Also, I have left the memory for the mark-stack out of the picture; in this case the mark-stack would not grow very large, but in general one must anticipate the mark-stack growing as large as the longest path through the reachable object graph. (The longest path in this case is three objects long.)

The “Sweep” phase

As previously mentioned, the GC much be able to “parse” the memory when scanning the address space.

In the case of the Mark-Sweep collector, in addition to having to be able to derive the size of each object, we also need the mark bit for each object to be located at predictable location.
A Mark-Sweep collector does not move objects, so it must resort to metadata such as a free-list to track reclaimed memory. So, after the marking is finished, the GC then sweeps over the memory: it walks over the GC-managed address space and builds up a free-list of blocks that were not marked during the traversal.

(The arcs that make up the free-list above are dashed, to distinguish them from the “real” references that make up the object graph. In the above scheme, the pointer to the next element in the free list is held in the second word of each free block.Putting the next-pointers for the free-list into the second word of each four-word block is one way of satisfying the aforementioned requirement that the GC-managed memory be parseable. )

With that, the GC is done; the mutator (i.e. main program) is now free to take blocks off of the free-list to satisfy memory requests.

Conservative Collection

A conservative collector is a particular kind of Mark-Sweep collector where it is not provided enough information to know whether one or more words of reachable data that it encounters should be interpreted as a reference or not. Such a collector is forced to assume (conservatively) that if the encountered datum is an allocated address on the GC-managed heap, then that could be its purpose from the perspective of the mutator, and therefore that datum must be treated as a reference to memory.

In the diagrams above, I said that - denotes some value that the GC knows is not a memory reference. In a conservative collector without any type information, the only kind of value that can be interpreted that way is one that lies outside the addreses space of the GC-heap.

So for example, if we had the same picture as above, but the register r2 happen to hold an integer with value 0x10000, and the conservative collector is not told “r2 holds a non-reference at this point in the execution”, then this diagram would result:

That is, even though in the program itself, the value 0x10000 is not meant to be interpreted as a memory address, D (and E and F) are all conservatively classified as live objects.

Copying Collection

A Copying collector moves objects from one location to another as part of its tracing process, and updates the references in reachable objects as it goes.

I will first illustrate this using our low-level memory graph, but I will not draw the edges for the dead objects anymore, as they add significant clutter to the picture.

The Reserved “To-space”

First, we need to have some reserved memory to target as we copy objects. I have put this target memory (the so-called “to-space”) on the left-hand side of the picture; the nearby “avail” circle is a local variable in the GC that indicates the starting address that we can use to copy objects into; so it starts off at the first address, 0x20000.

Copying from the Roots

First we walk over the roots (in our simplified model, the registers), and copy over all of the objects we see. So the below results after we scan just the first two registers, copying the objects A and B into new locations, respectively labelled A' and B', and updating avail accordingly.

Note that as we copy objects from the source memory (the so-called “from-space”), we must maintain a map from the original object to its newly allocated copy. In this model, this is accomplished by imperatively overwriting the original object with fwd header marking it as “forwarded” as well as a “forwarding pointer” (the dashed arcs) that points to the new location.

The copies themselves just get the original memory contents, so they may have pointers to the old objects in the source memory (such as the B' -> C arc in the picture). Those will get fixed up later.

We still need to scan the rest of the registers, which copies C as shown below.

Scan the “To-space”

Now that we have finished scanning the roots, we start the fixup process of scanning over the “to-space.” Every time we encounter a pointer into the “from-space”, there are two cases to consider: Either it is an already copied object (in which case there will be a forwarding pointer installed), or it is an object that is not yet copied.

If its a forwarded object, then we fixup our reference so that it points to the new copy in the “to-space”. We see this when the fixup scan is scanning over B' and sees the B' -> C reference, which it then rewrites to a B' -> C' reference, highlighted below.

The fixup scan is not yet complete; the next object it encounters, C', illustrates the other case of a reference to an object (G here) that has not yet been copied. In this case, it just copies it, in the same manner that we did when we were scanning the roots. (This adds the forwarded object to the set of objects enqueued for fixup scanning.)

Reclaim the “From-space”

Eventually the fixup scan will finish processing all of the “to-space” (since there are only a finite number of objects that could be enqueued). At this point, there will be no more reachable objects in any part of the from-space, and thus those memory blocks can be reclaimed in their entirety.

Woo, done!

A Spectrum of Collectors

The mark-sweep and copying collection methods illustrated above actually form two extreme points on a spectrum of implementation techhniques.

In practice, many collectors are neither entirely mark-sweep nor copying, but rather employ a hybrid strategy, where some memory regions are reclaimed via a copying-collection, while others are managed via a mark-sweep method. For example, some generational collectors work by promoting objects from a young space into an older space via copying collection, but then the last space (with the eldest objects) can be managed via mark-sweep.

As another example of a hybrid strategy, there exist conservative collectors (such as “mostly copying” collectors) where exact type information is known for the heap-allocated objects, but the types are not known for the roots (i.e. the registers and values embedded in the stack). In such systems, it is not safe to move objects that are referenced via conservatively-scanned words. Such objects are “pinned” in place (which means that it cannot be moved by the collector) for the duration of this collection, and thus space in their memory blocks can only be reclaimed with via a mark-sweep collection. However, objects reachable solely via precisely-scanned words can be moved, and memory blocks made up solely of such objects can be reclaimed via a copying-collection strategy.

Pinning Support

In our discussion to follow, rather than attempt to characterize a collector as “mark-sweep” or “copying”, it will be more useful to distinguish collectors in terms of whether or not they support “pinning”. In a language runtime that supports pinning, a mutator (i.e. the main program linked with the collector coroutine) can tag any live object as “pinned”.

(In the example of “mostly copying” above, such pinning is accomplished by putting a reference into a conservatively-scanned root. However, some language runtimes provide first class support for pinning; for example, the Microsoft CLR once offerred a pin_ptr smart-pointer for Managed C++ that would prevent a referenced object from moving.)

In other words, in a runtime with pinning, the mutator dictates which objects can be managed via copying collection. If the runtime does not support pinning, then it is the collector that dictates which objects are managed via copying; the mutator cannot rely on the collector allowing arbitrary objects to be pinned.

Simplifying our diagrams

While I am sure it was fun to decode the above renderings of memory banks, now that we have seen how collectors work at a low-level, I am going to revert to the earlier high-level object notation. It should be easier for you to read, and (just as important) for me to write.

To show a copying collector’s intermediate state in a high-level picture, I will show newly copied objects with prime marks (and, if possible, in a separately delineated to-space), and a dashed-line for forwarding pointers.

Here is an example of this style of rendering, using the earlier example at the point where a copying collector had scanned just registers r0 and r1 (but had not yet copied C from register r3), highlighting the copied objects and the newly written references (including the dashed forwarding pointers).

(This rendering is arguably just as clear (or unclear) as our earlier memory diagram was, apart from some annoying edge-crossings due to the graphviz layout engine.)

Conclusion

Well, I don’t know if you learned anything about GC from this post. I certainly learned a lot about techniques for wrestling with graphviz. :)

There will be a followup post soon-ish that will bring Rust into the picture, discussing what GC and Rust integration even means, and the host of problems that crop up.

Comments