The issue of memory bloat in Ruby applications is a topic of frequent discussion. In this post, we will look at how Ruby memory management can go wrong, and what you can do to prevent your Ruby application from blowing up.
First, we need to understand what bloat means in the context of an application’s memory.
Let's dive in!
What Is Memory Bloat in Ruby?
Memory bloat is when your application’s memory usage increases all of a sudden without an apparent explanation. This increase may or may not be quick, but in most cases, it becomes continuous. It is different from a memory leak, which results from multiple executions taking place over some time.
Memory bloat can be one of the worst things to happen to your Ruby application in production. However, it is avoidable if you take proper measures. For example, if you notice a spike in your application’s memory usage, it is best to check for signs of memory bloat before troubleshooting other issues.
Before we explore how to diagnose and fix memory bloat, let’s take a quick walkthrough of Ruby’s memory architecture.
Ruby's Memory Structure
Ruby's memory usage revolves around specific elements that manage the judicious use of available system resources. These elements include the Ruby language, host operating system, and the system kernel.
Apart from these, the garbage collection process also plays a vital role in determining how Ruby memory is managed and reused.
Ruby Heap Pages and Memory Slots
The Ruby language organizes objects into segments called heap pages. The entire heap space (available memory) is divided into used and empty sections. These heap pages are further split into equal-sized slots, which allow one unit-sized object each.
When allocating memory to a new object, Ruby first looks in the used heap space for free slots. If none are found, it allocates a new heap page from the empty section.
Memory slots are small memory locations, each nearly 40 bytes in size. The data that overflows out of these slots is stored in a different area outside the heap page and each slot stores a pointer to the external information.
A system’s memory allocator makes all allocations in the Ruby runtime environment, including heap pages and external data pointers.
Operating System Memory Allocation in Ruby
Memory allocation calls made by the Ruby language are handled and responded to by the host operating system’s memory allocator.
Usually, the memory allocator consists of a group of C functions, namely malloc, calloc, realloc, and free. Let’s quickly take a look at each:
- Malloc: Malloc stands for memory allocation, and it is used to allocate free memory to objects. It takes in the size of the memory to be allocated and returns a pointer to the starting index of the allocated memory block.
- Calloc: Calloc stands for contiguous allocation, and it allows the Ruby language to allocate consecutive blocks of memory. It is beneficial when allocating object arrays of known length.
- Realloc: Realloc stands for re-allocation, and it allows the language to re-allocate memory with a new size.
- Free: Free is used to clear out pre-allocated sets of memory locations. It takes in a pointer to the starting index of the memory block that has to be freed.
Garbage Collection in Ruby
The garbage collection process of a language runtime dramatically affects how well it utilizes its available memory.
Ruby happens to have pretty advanced garbage collection that uses all of the above-described API methods to optimize application memory consumption at all times.
An interesting fact about the garbage collection process in Ruby is that it halts the complete application! This ensures that no new object allocation happens during garbage collection. Because of this, your garbage collection routine should be infrequent and as quick as possible.
Two Common Causes of Memory Bloat in Ruby
This section will discuss two of the most significant reasons why memory bloats occur in Ruby: fragmentation and slow release.
Memory Fragmentation
Memory fragmentation is when object allocations in memory are scattered all over, reducing the number of contiguous chunks of free memory. Memory can't be allocated to objects without contiguous blocks, even if more than enough free memory is available on the disk. This problem can occur in any programming language or environment, and each language has its methods for solving the issue.
Fragmentation can occur at two different levels: the language’s level and the memory allocator’s level. Let’s take a look at both of these in detail.
Fragmentation at the Ruby Level
Fragmentation at the language level occurs due to the design of the garbage collection process. The garbage collection process marks a Ruby heap page slot as free, allowing reuse of that slot to allocate another object in the memory. If a complete Ruby heap page consists of only free slots, then that heap page can be released to the memory allocator for reuse.
But what if a tiny number of slots are not marked free on a heap? It will not be released back to the memory allocator. Now, think about the many slots in various heap pages simultaneously allocated and freed by garbage collection. It is improbable for entire heap pages to be released at once. Even though the garbage collection process frees memory, it can't be reused by the memory allocator since memory blocks partially occupy it.
Fragmentation at the Memory Allocator Level
The memory allocator itself faces a similar problem: it has to release OS heaps once they are all entirely free. But it is improbable that an entire OS heap can be freed at once considering the random nature of the garbage collection process.
The memory allocator also provisions OS heaps from system memory for an application’s use. It will simply move to provision new OS heaps, even if the existing heaps have enough free memory to satisfy the application’s memory requirements. This is the perfect recipe for a spike in the memory metrics of an application.
Slow Release
Another important cause of memory bloat in Ruby is a slow release of freed memory back to the system. In this situation, memory is freed much more slowly than the rate at which new memory blocks are allocated to objects. While this is not a conventional or a rookie issue to solve, it intensely affects memory bloat — even more so than fragmentation!
Upon investigating the memory allocator’s source, it turns out that the allocator is designed to release OS pages just at the end of OS heaps, and even then, only very occasionally. This is probably for performance reasons, but it can backfire and be counter-productive.
How to Fix Ruby Memory Bloat
Now that we know what causes Ruby’s memory to bloat, let’s take a look at how you can fix these issues and improve your app's performance through defragmentation and trimming.
Fix Ruby Memory Bloat with Defragmentation
Fragmentation happens due to the design of garbage collection, and there isn't much you can do to fix it. However, there are a few steps that you can follow to reduce the chances of ending up with a fragmented memory disk:
- If you declare a reference to an object that uses a considerable amount of memory, make sure you free it manually when its job is done.
- Try to declare all of your static object allocations in one big block. This will put all your permanent classes, objects, and other data on the same heap pages. Later, when you play around with dynamic allocations, you won’t have to worry about the static heap pages.
- If possible, try to do large dynamic allocations at the beginning of your code. This will put them close to your bigger static allocation memory blocks and will keep the rest of your memory clean.
- If you use a small and rarely cleared cache, it is better to group it with permanent static allocation in the beginning. You can even consider removing it altogether to improve your app’s memory management.
- Use jemalloc instead of the standard glibc memory allocator. This small tweak can bring down your Ruby memory consumption by up to four times. The only caveat here is that it might not be compatible in all environments, so be sure to test your app thoroughly before rolling into production.
Trimming to Fix Ruby Memory Bloat
You need to override the garbage collection process and release memory more often to fix slow memory release. There is an API that can do this called malloc_trim. All you need to do is modify Ruby to call this function during the garbage collection process.
Here’s the modified Ruby 2.6 code that calls malloc_trim in gc.c function gc_start
:
Note: This is not recommended in production applications as it can make your app unstable. However, it comes in handy when slow memory release causes major hits to your performance, and you are ready to try out any solution.
Wrap-up and Next Steps
Memory bloats are tricky to identify and even more challenging to fix.
This article looked at two significant reasons behind memory bloats in Ruby apps — fragmentation and slow release — and two fixes: defragmentation and trimming.
You must keep a constant eye on your app’s metrics to identify an impending bloat incident and fix it before it takes your app down.
I hope that I've helped you take some steps towards fixing memory bloat in your Ruby application.
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!