ruby

How to Track Down Memory Leaks in Ruby

Tony Rowan

Tony Rowan on

How to Track Down Memory Leaks in Ruby

A memory leak is an unintentional, uncontrolled, and unending increase in memory usage. No matter how small, eventually, a leak will cause your process to run out of memory and crash. Even if you periodically restart your app to avoid this crash (no judgment, I've done that!), you still suffer the performance implications of a memory leak.

In this post, the first of a two-part series on memory leaks, we'll start by looking at how Ruby manages memory, how Garbage Collection (GC) works, and how to find a leak.

In the second part, we'll take a deeper dive into tracking down leaks.

Let's get started!

Ruby Memory Management

Ruby objects are stored on the heap, and each object fills one slot on the heap.

Prior to Ruby 3.1, all slots on the heap were the same size — 40 bytes, to be exact. Objects too large to fit in a slot were stored outside the heap. Each slot included a reference to where objects were moved.

In Ruby 3.1, variable width allocation for String objects was merged. Soon, variable width allocation will be the norm for all object types.

Variable width allocation aims to improve performance by improving cache locality — all the information of an object will be stored in one place rather than across two memory locations.

It should also simplify (some parts) of memory management. At the moment, there are two 'heaps':

  • The Ruby heap (or GC heap) that stores smaller Ruby objects.
  • The C heap (or malloc/transient heap) that stores larger objects.

Once variable width allocation is the norm, there should be no need for the latter heap.

The heap starts at a given size (10,000 slots by default) and objects are assigned to free slots as they are created. When Ruby tries to create an object and there are no free slots available, Garbage Collection (GC) occurs to make some free slots available.

If there are too few free slots after GC, the heap will be expanded (more on this a little later).

Here are the factors you can control, alongside their environment variables:

  • Initial size of the heap - RUBY_GC_HEAP_INIT_SLOTS
  • Number of free slots that should be available after GC occurs - RUBY_GC_HEAP_FREE_SLOTS
  • Amount the heap is expanded by - RUBY_GC_HEAP_GROWTH_FACTOR

Garbage Collection in Ruby

Garbage Collection in Ruby 'stops the world' — no other process occurs when GC occurs. Garbage Collection in Ruby (since 2.1) is also generational, meaning that the garbage collector has two modes:

  • Minor GC - inspects 'young' objects (objects created recently)
  • Major GC - inspects 'old' objects as well as 'young' objects (all the objects)

Note: An 'old' object has survived 3 GC runs, major or minor.

When the heap is full, minor GC is invoked first. If it can't free up enough slots to be below the limit, major GC will be invoked. Only then, if there are still not enough free slots, will the heap be expanded.

Major GC is more expensive than minor GC because it looks at more objects.

The theory behind why generational GC is more performant is that objects usually fall into two categories:

  1. Objects that are allocated and then quickly go out of scope. In a Rails app, models fetched from the DB to render a page will go out of scope when the request ends.
  2. Objects that are allocated and kept around for a long time. Classes and caches are likely to still be in use throughout the lifetime of an app.

Major GC will also run after minor GC if the number of old objects is above a certain threshold, even if there are sufficient free slots. This limit increases as the size of the heap grows and can be controlled by the RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR environment variable.

When you have a leak, you create objects that can't be cleaned up — more and more old objects. This means that major (expensive) GC will run much more often than it should. Since nothing else runs when GC is running, this is time that you waste.

I've left some links at the end of this article for further reading on memory layout and the garbage collector in Ruby.

What Does A Memory Leak Look Like in Ruby?

You can see a memory leak using simple tools available on any Unix system. Take the following code as an example.

# leaky.rb
 
an_array = []
 
loop do
  1000.times { an_array << "A" + "B" + "C" }
  puts an_array.size
  sleep 1
end

To say this code 'leaks' is a little unfair — all it does is leak! — but it serves our purposes.

We can observe the leak quite simply from the command line by running this program in one terminal and watch-ing the memory increase over time with ps.

# In terminal one
$ ruby ./leaky.rb
 
# In terminal two
$ watch ps -p `pgrep -f "ruby ./leaky.rb"` -o pmem,pcpu,rss,args

The pgrep -f "ruby ./leaky.rb" finds the process ID for us, so that we can restrict the ps output to only the process we're interested in. As you may be able to guess, it's like grep for processes.

The watch tool allows us to poll the output of a given command and update it in place, giving us a live dashboard within our terminal.

You'll get output like this, which updates every couple of seconds.

Every 2.0s: ps -p 50866 -o pmem,pcpu,rss,args
 
%MEM  %CPU    RSS ARGS
 0.2   4.1 163408 /Users/tonyrowan/.asdf/installs/ruby/3.1.1/bin/ruby ./leaky.rb

You should see the %MEM and RSS increasing. They are:

  • %MEM - The amount of memory the process uses as a percentage of memory on the host machine.
  • RSS (resident set size) - The amount of RAM the process uses in bytes.

This basic OS-only information is enough to spot if you have a leak — if the memory keeps going up, it means you do!

Find Ruby Leaks with the Garbage Collector Module

We can also detect leaks within Ruby code itself with the GC module.

# leaky.rb
 
GC.disable # Only run GC when manually called
 
an_array = []
 
loop do
  1000.times { an_array << "A" + "B" + "C" }
  puts "Array is #{an_array.size} items long"
 
  GC.start # Run a major GC - use full_mark: false for minor GC
  puts "There are #{GC.stat(:heap_live_slots)} live objects"
 
  sleep 1
end

The GC.stat method will return a hash with a lot of useful information. Here, we're interested in :heap_live_slots, which is the number of slots on the heap that are in use. That's the opposite of :heap_free_slots. At the end of the loop, we force a major GC and print out the number of used slots, i.e., the number of objects that remain after GC.

When we run our little program, we see this increase ad infinitum. We have a leak! We could also have used GC.stat(:old_objects) to the same effect.

While the GC module can be used to see if we have a leak and (if you're smart with your puts statements) where the leak might be occurring, we can see the type of objects that might be leaking with the ObjectSpace module.

# leaky.rb
 
GC.disable # Only run GC when manually called
 
an_array = []
 
loop do
  1000.times { an_array << "A" + "B" + "C" }
  puts "Array is #{an_array.size} items long"
 
  GC.start # Run a major GC - use full_mark: false for minor GC
  pp ObjectSpace.count_objects
 
  sleep 1
end

The ObjectSpace.count_objects method returns a hash with the counts of live objects. T_STRING, for instance, is the number of strings live in memory. For our rather leaky program, this value increases with each loop, even after GC. We can see that we are leaking string objects.

Application Performance Monitoring in Production with AppSignal

While playing with ps and GC can be a sensible route for toy projects — they're also fun and informative to use! — I would not recommend them as your memory leak detection solution in production apps.

This is where you would use an Application Performance Monitoring (APM) tool. If you're a very large company, you can build these yourself. For smaller outfits, though, picking an APM off-the-shelf is the way to go. You do need to pay a monthly subscription, but the information they provide more than makes up for it.

For detecting memory leaks, you want to find server or process memory use (sometimes called RSS) graphs over time. Here's an example screenshot from AppSignal's 'process memory usage' dashboard of a healthy app shortly after being deployed:

Healthy App RSS

And here's an unhealthy app after deployment:

Unhealthy App RSS

AppSignal will even surface Ruby VM stats like GC and heap slots, which can give you an even clearer signal for a memory leak. If the number of live slots keeps growing, you have a leak!

Heap Slots showing leak

Read more about AppSignal for Ruby.

Wrap Up and Further Reading

In this post, we took a quick tour of Ruby's memory management and garbage collector. We then diagnosed how to discover a memory leak using Unix tools and Ruby's GC module.

Next time, we'll see how to use memory_profiler and derailed_benchmarks to find and fix leaks.

In the meantime, you can read more about the tools we used:

Additional further reading:

Happy coding, and see you next time!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Share this article

RSS
Tony Rowan

Tony Rowan

Our guest author Tony is a Full-Stack Engineer tinkering with projects big and small. He's built apps, games, and web apps, is a Ruby lover 💎 and dog dad 🐶.

-> All articles by Tony Rowan-> Become an AppSignal author

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps