Ruby Garbage Collection: More Exciting than it Sounds

Running software uses computer memory for data structures and executable operations. How this memory is accessed and managed depends on the operating system and the programming language. Many modern programming languages manage memory for you, and Ruby is no different. Ruby manages memory usage using a garbage collector (also called gc).

In this post, we’ll examine what you, a Ruby developer, need to know about Ruby’s gc.

Use the links below to skip ahead in the tutorial:

What is Garbage Collection?

Garbage collection is a method of managing computer program memory usage. The idea behind garbage collection and other memory management solutions, such as reference counting, is that instead of the developer keeping track of which objects are in use by a program, the language does. This means that the programmer can focus on the business logic or other problem being solved rather than worrying about the nuts and bolts of allocating and freeing memory. This also helps program stability and security because incorrect memory management can lead to crashes and a large portion of security bugs are due to memory management issues. Entire books have been written about this technique.

Though there are many different garbage collection algorithms, the basic premise remains the same. At various times during a program’s execution, code in the programming languae runtime scans through the program’s memory and “figures out” which objects or data structures are no longer in use. If an object or data is no longer in use, it is garbage. Its memory can be released and reused by other parts of the program. The garbage collector is responsible for releasing that memory. How and when the object scanning and usage determination happens depends on the garbage collection algorithm and implementation. 

Who should read this?

As a Ruby developer, you might think you can ignore garbage collection and leave the details to the language implementers. After all, isn’t that the point of Ruby’s GC? To relieve you of that particular worry? As you build your application, you typically can ignore garbage collection details. However, it will be useful to you to know something about this topic, because garbage collector behavior affects your program’s operation in production.

Production environments and usage stress applications in ways that you are unlikely to see in development. In addition, every production deployment environment has memory limits. This means that sometimes your Ruby application will have memory issues in production that didn’t surface in development. 

Whether you’re deploying to Heroku, a virtual machine, or Kubernetes, your application will have a finite amount of memory. If your application sees appreciable traffic or processes a large amount of data, you might see the dreaded NoMemoryError. You might also notice performance impacts as garbage collection happens. Or you might see memory bloat, as more and more memory is required by your application. 

At this point, you can move to a system that has better performance, more memory and is more expensive, or you can debug your memory usage. The latter is easier with an understanding of Ruby GC.

However, if you are a Ruby programmer regularly writing C extensions, this post will be too high level for you. Instead, you should review the extension documentation.

Which Ruby are you talking about?

There are over ten active Ruby implementations. The most commonly used Ruby is CRuby, also known as Matz’ Ruby Interpreter (MRI). CRuby is also the original Ruby implementation. Other popular implementations include JRuby and TruffleRuby.

This post will not cover the memory management of Ruby runtimes other than CRuby. If you’re interested in learning more about how memory management is handled, please consult your runtime’s documentation.

What about older versions?

The garbage collection has undergone a number of changes since Ruby 2 was released in 2013. These include a generational garbage collector (introduced in Ruby 2.1), incremental garbage collection (in 2.2), and compaction (in 2.7).

This post focuses on the latest released version of CRuby, which is 2.7. 

How does Garbage Collection work in Ruby? 

At its most basic, when you create a Ruby object, memory is allocated for it. The object lives for a while, hopefully doing some useful work. Then, when the object is no longer in use, Ruby marks that section of the memory as available for future use by other objects.

If you want the full implementation with nothing omitted, you can read gc.c. You’ll just have to read C. About twelve thousand lines of it. Whew. Rather than doing that, let’s go over the important pieces of the Ruby garbage collector.

There are two different sets of memory in Ruby. The first is the malloc heap. Everything in your program is included in this section of memory. It’s not released back to the operating system unless the memory is known to be unused by Ruby at the end of a garbage collection. Examples of objects that live in this heap include string buffers and data structures managed by a C extension.

The second is the Ruby object heap. This is a subset of the malloc heap and is where most Ruby objects live. If they are large, they may point to the malloc heap. The Ruby heap is what the Ruby garbage collector monitors and cleans up and what this post will focus on. 

The Ruby heap is split up into pages. Pages contain slots, which are either empty or contain an object reference. Objects are 40 bytes long (there are some exceptions for values like numbers).

Mark and Sweep Algorithms

Ruby uses a tricolor mark and sweep garbage collection algorithm. Every object is marked either white, black, or gray, hence the name tricolor. 

Whenever garbage collection is called, it starts with a ‘mark’ phase. The garbage collector examines objects on the Ruby heap. It tags everything with a white flag. At this point in time, a white flag means that the object hasn’t been reviewed.

The collector then looks at all objects that are accessible. These may be constants or variables in the current scope. These are marked with a gray flag. This means that the object should not be garbage collected, but hasn’t been fully examined. 

For each object marked with a gray flag, all its connections are examined. For each reviewed object, if it has a white flag, the flag is changed to gray. At this time, this object is also added to the list of objects to be reviewed.

Finally, after all the connections of a given object have been examined, the initial root object with the gray flag is marked with a black flag. A black flag means “do not garbage collect, this object is in use”. The collector then starts examining another object with a gray flag.

After all the objects with gray flags have been examined, all objects in the Ruby heap should have either a black flag or a white flag. Black flagged objects should not be garbage collected. White flagged objects are not connected to any black-flagged objects and can be deleted. Simple enough, eh? The ‘mark’ phase is complete.

The memory that the white flagged objects are allocated in is then returned to the Ruby heap. This is the ‘sweep’ phase. Most of the complexity of the mark and sweep algorithm is in the ‘mark’ phase.

Garbage collection is either called manually or when the Ruby heap becomes full. We’ll talk more about what ‘full’ means later.

Generational Garbage Collection

Many objects are used briefly and then discarded. The Ruby heap is large. It’s silly to look at every object every time when some live only for a function call and others live for the duration of a running program. Ruby addresses this by making the mark phase smarter using a generational garbage collector.

The generational garbage collector, introduced in Ruby 2.1, takes advantage of the differences in object lifespan by keeping track of how many times it has seen an object. If an object has been seen 3 times, it is marked as being an ‘old’ object and is treated differently from the younger objects.

When you are using generational garbage collection, you end up with multiple sections of the Ruby heap. There’s a ‘new object’ section, which is where all new object allocations are stored. This section is smaller than the entire heap and is garbage collected often--such a garbage collection is called a minor garbage collection. Objects in the ‘old object’ section are garbage collected less often, only during what is called a full garbage collection. 

Minor garbage collection also handles new objects that are referenced by an older object as well as other edge cases. This is why the Ruby algorithm is sometimes called RGenGC, Restricted Generational Garbage collection.  There is no rule that both garbage collections must use the same algorithm, but at this time, CRuby uses the mark and sweep algorithm for both minor and major garbage collections.

Incremental Garbage Collection

Generational garbage collection has a flaw. The collecting of objects from the ‘new object’ area is fast. But any objects outside of that (in the ‘old area’ or other areas to handle C extension edge cases) are only collected when there is a full garbage collection. There can be many objects outside of the ‘new object’ area, and that means that a full garbage collection impacts performance.

As of Ruby 2.2, incremental garbage collection allows for the interweaving of full garbage collection and the execution of the program. This means that a full garbage collection no longer has as large a performance impact because the program can execute at the same time. The mark phase becomes even more complicated, however.

Of course, the incremental garbage collection needs to be careful not to garbage collect objects that are referenced. References can change while it is running incrementally. This might happen if a hash that was marked as black, which,  remember, means that it has been fully examined and points to no white objects, has a new object added to it. That new object will be marked with a white flag and maybe inadvertently collected by the garbage collector. 

This algorithm solves this issue by using what is called a ‘write barrier’. The ‘write barrier’ detects any time a black object references a new, or white, object, and informs the garbage collector that what it thought was so (a black object will never reference a white object) isn’t true. Anyone writing C extensions needs to be especially careful of the ‘write barrier’.

Compaction

Heap compaction landed in CRuby 2.7 and is the latest change to the garbage collection system. It uses the “two fingers” algorithm to compact the Ruby heap. Heap compaction has a number of benefits. If memory is fragmented, a Ruby object can’t use a large contiguous chunk, which negatively affects access performance. If objects related to each other are close in the heap, CPU caches will load them together; CPU caches are much faster than main memory access. If you are using a web server that forks, compacting memory before forking can also improve write-on-copy performance. Here’s a great video about Ruby’s heap compaction.

Compaction is an optional feature that you currently can manually call via GC.compact. Using compaction may cause issues with C extensions that are not programmed to handle it, since it moves objects around in the Ruby heap. Make sure you test your application thoroughly if you use this feature.

How to be a Garbage Collector friendly developer

Remember, the whole point of Ruby garbage collection is that you, as a developer, don’t have to think about memory usage. Unfortunately, as mentioned above, sometimes you do.

If you are kind to your garbage collector, it will be kind to you by performing its duties quickly and efficiently. Here are three ways to be a gc friendly Ruby developer.

Avoid circular references

When you create objects, watch out for circular references. Since CRuby uses the mark and sweep algorithm, if two objects reference each other, they will never get collected. Here’s an example using an object finalizer (also called a destructor) based on a 2018 RubyConf talk.

class memory leak
  def initialize(name)
    ObjectSpace.define_finalizer(self, proc { puts name + " is gone" }
  end
end

Here the proc and selfreference each other because procs have access to variables defined when they are created. Because of this they will never be garbage collected. If you want to learn more about procs and lambdas, check out this post.

Here’s the same class rewritten to avoid the circular reference:

class NotALeak
  def initialize(name)
    ObjectSpace.define_finalizer(self, NotALeak.finalizer_proc(name)
  end

  def self.finalizer_proc(name)
    proc { puts name + " is gone" }
  end
end

In this case, the proc is defined without access to the object that it is going to be associated with, avoiding the creation of a circular reference. That allows the object to be marked in the ‘mark’ phase.

 Be careful with long-lived objects

Globals and top-level variables are never garbage collected during the life of a Ruby program, so minimize their use. As you might expect, any object you associate with a global variable will also not be automatically garbage collected, so you’ll have to manually sever any references to have such objects be collected. 

The same is true for constants. For example, if you create a constant array:

MY_ARRAY = []

and add any object to it, that object will live forever (well, at least as long as the Ruby program is running). That’s okay if that is intended, but if you didn’t intend that, you can end up using more memory than you planned.


Monitor memory usage

In addition to being careful in the way you develop, monitoring memory usage will give you further insight into how your program uses or abuses memory.

Running GC.stat gives you useful information about the Ruby heap. This hash looks like:

{:count=>24,
 :heap_allocated_pages=>126,
 :heap_sorted_length=>126,
 :heap_allocatable_pages=>0,
 :heap_available_slots=>51365,
 :heap_live_slots=>50915,
 :heap_free_slots=>450,
 :heap_final_slots=>0,
 :heap_marked_slots=>31710,
 :heap_eden_pages=>126,
 :heap_tomb_pages=>0,
 :total_allocated_pages=>126,
 :total_freed_pages=>0,
 :total_allocated_objects=>207782,
 :total_freed_objects=>156867,
 :malloc_increase_bytes=>18384,
 :malloc_increase_bytes_limit=>16777216,
 :minor_gc_count=>20,
 :major_gc_count=>4,
 :compact_count=>0,
 :remembered_wb_unprotected_objects=>286,
 :remembered_wb_unprotected_objects_limit=>554,
 :old_objects=>31278,
 :old_objects_limit=>58784,
 :oldmalloc_increase_bytes=>125392,
 :oldmalloc_increase_bytes_limit=>16777216}

There are a few important keys to monitor. Two are the count of a major (aka full) and minor garbage collections: major_gc_count and minor_gc_count. Minor GCs should have minimal performance issues, but major GCs can have larger impacts, so knowing how many of those happen can let you know if memory usage might be causing performance impacts. 

If you want to see how the major garbage collections are being triggered, monitor the following stats:

For each of these, there is a corresponding _limit key. The _limit values indicate when a full garbage collection will be triggered. You can monitor how these change over time or when certain code paths are executed to gain more insight into when major garbage collections occur. 

You’ll also want to track heap_free_slots since a high number here indicates the Ruby heap expanded, then the garbage collector ran and freed up many slots. That means that something is allocating a large number of short-lived objects. This behavior can lead to memory bloat over time. Here’s a great, if slightly dated, post with information about each of these keys.

Finally, monitor the count of NoMemoryErrors, which indicates that there’s a serious memory issue and that Ruby can’t get the memory it needs.

Alternative memory allocators

There are a number of alternative memory allocators available. The default for CRuby is the system malloc. GitHub uses jemalloc for their Ruby on Rails application. There are others with different performance characteristics, compaction algorithms and tradeoffs. Other memory allocators which work with CRuby include tcmalloc and ptmalloc. These allocators manage the larger help, not the Ruby heap.

Before you switch the memory allocators in production, make sure to test your application under a variety of real-world traffic and usage to see how your memory usage changes.

Final thoughts

Like all memory managed languages, Ruby takes care of many of the memory needs of Ruby programs without a developer worrying about it. However, if your program runs out of memory or has performance issues due to garbage collection, it helps to know more about how Ruby handles memory management under the hood.