How to Track Down Memory Leaks in Ruby
Tags: ruby
Published: 2022-08-10 12:51:42

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](https://bugs.ruby-lang.org/issues/17816) 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](https://docs.ruby-lang.org/ja/latest/class/GC.html):

- 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.

```ruby
# 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`.

```bash
# 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.

```bash
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.

```ruby
# 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.

```ruby
# 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](https://blog.appsignal.com/images/blog/2022-07/healthy-app-rss-appsignal.png)

And here's an unhealthy app after deployment:
![Unhealthy App RSS](https://blog.appsignal.com/images/blog/2022-07/unhealthy-app-rss-appsignal.png)

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](https://blog.appsignal.com/images/blog/2022-07/heap-slots.png)

[Read more about AppSignal for Ruby](https://www.appsignal.com/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:

- [`watch`](https://linux.die.net/man/1/watch)
- [`ps`](https://linux.die.net/man/1/ps)
- [`pgrep`](https://linux.die.net/man/1/pgrep)

Additional further reading:

- [`GC` module documentation](https://ruby-doc.org/core-3.1.2/GC.html)
- [`ObjectSpace` module documentation](https://ruby-doc.org/core-3.1.2/ObjectSpace.html)
- [Garbage Collection Deep Dive](https://jemma.dev/blog/ruby-garbage-collection-deep-dive/)
- [Variable Width Allocation](https://rubykaigi.org/2021-takeout/presentations/peterzhu2118.html)

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](https://blog.appsignal.com/ruby-magic)!**