To Free or Not to Free: A Story About a Memory Leak in Strings

While debugging some code for the new experimental feature Variable Width Allocation in Ruby, I noticed something odd in the code for Ruby strings. The code looked like it could potentially leak memory, so I tried to see if I could actually get it to reproduce. And voila, I produced a small script that caused Ruby to leak memory and become more and more bloated. In this blog post, I’ll show you what a memory leak looks like, a simplified view of how Ruby strings work internally, and how the bug was fixed.

By the way, if you’re interested in learning about Variable Width Allocation, you can learn a bit more about it in this ticket. A more approachable blog post will come soon when we progress more into this feature.

Memory leak

“What is a memory leak?”, you might ask. A memory leak is when pieces of memory that won’t ever be used again remain to be held on by the application. In languages that use manual memory management, like C that MRI Ruby is written in, you need to manually free memory to let the system know that the piece of memory is no longer used. If we don’t free memory that is no longer useful, then a memory leak will occur.

Here’s the script that reproduces the memory leak:

100.times do
  1000.times do
    # 0.to_s is a special case that creates a string from a C string literal.
    # https://github.com/ruby/ruby/blob/26153667f91f0c883f6af6b61fac2c0df5312b45/numeric.c#L3393
    # C string literals are always marked STR_NOFREE.
    str = 0.to_s
    # Call String#initialize again to create a buffer with a capacity of 10000
    # characters.
    str.send(:initialize, capacity: 10000)
  end

  # Output the Resident Set Size (memory usage, in KB) of the current Ruby process.
  puts `ps -o rss= -p #{$$}`
end

This script does some rather odd things, but I’ll explain the rationale of every line in this blog post. All you need to know for now is that we first create a string str, and then we call the constructor String#initialize on it with an initial capacity for 10,000 characters. As you might expect, we set a capacity because this 10,000 character buffer will be leaked. As a side note, although calling the #initialize method more than once on an object is legal in Ruby, please please please don’t actually do it because it’s not a good coding practice.

Then we do all this 1000 times. Why 1000 you might ask? It’s just a random number that isn’t too small which will be difficult to actually see the memory leak occurring, and not too large which will take too long to run. We then output the Resident Set Size (RSS) through the ps shell command. RSS is the amount of memory by this process used that is loaded into RAM. We then run all of this 100 times so we can graph the RSS growth.

If you’re trying to run this script yourself, note that it shells out to call the ps command, so it will only work on systems that have it (e.g. Linux and macOS). It won’t work on Windows (but you can use WSL to emulate a Linux environment).

Since the string is never held on anywhere in this script, it will get reclaimed by the garbage collector and all of its memory should be released. In other words, what should happen is that the RSS grows a little bit, then the RSS should stop growing and remain constant.

But what actually happens? This chart graphs the RSS growth for master in red (which is before the fix) and the branch in blue (which is with the fix).

Graphing the RSS of the script over iterations.

We can clearly see that the RSS of master grows linearly, which shouldn’t happen and is a sign of a memory leak. The branch displays expected behaviour.

Ruby strings

Before we can explain the fix, we have to talk about how strings work in Ruby. If you’re already familiar with how it works, you can skip this section. Note: this section is simplified and does not include information irrelevant to the bug (such as embedded strings, shared strings, etc.).

All objects in Ruby are fixed size (but our work with Variable Width Allocation aims to change this!). An object occupies one RVALUE, which is 40 bytes on 64-bit systems. All objects have the first 16 bytes reserved, for flags and klass. The remaining 24 bytes lets the object store whatever it likes. Strings are stored in the RString struct, which has five 8 byte attributes, which are the following:

  1. flags: Used to store the flags of the object that store the object’s state (e.g. the type of object, whether the object is frozen, etc.).
  2. klass: Pointer to the class of the object.
  3. len: The length of the string.
  4. ptr: A pointer to a character buffer that stores the contents of the string.
  5. capa: The capacity of the buffer in ptr.
RString struct used to represent a string object.

Creating Ruby strings

When Ruby strings are created, an RString struct is allocated from the garbage collector. A region of memory is acquired from the system using malloc for the character buffer of the string and the ptr attribute of the RString is set to this buffer.

The pseudo-code for creating Ruby strings looks a bit like this:

def new_empty_string(capa)
  # Allocate an RVALUE from the garbage collector
  str = garbage_collector_allocate_object
  str.klass = String
  str.len = 0
  # Allocate memory from the system with capa number of bytes
  str.ptr = malloc(capa)
  str.capa = capa
  str
end

Freeing Ruby strings

When Ruby strings are reclaimed by the garbage collector, the buffer at ptr is released back to the system using free and the RString is reclaimed by the garbage collector.

The pseudo-code for freeing Ruby string looks a bit like this:

def free_string(str)
  # free releases the ptr back to the system
  free(str.ptr)

  # Give the RVALUE back to the garbage collector
  garbage_collector_free(str)
end

Creating Ruby strings from C string literals

There’s a special optimization inside Ruby for creating Ruby strings from C string literals. C string literals are strings created directly using double quotes. For example, the following code snippet shows an example of creating a C string literal.

char *my_str = "Hello world!";

These C strings have a special property that they cannot be modified in any way and will exist for the entire lifespan of the application. So when we create Ruby strings from a C string literal, the ptr attribute is set to point directly to it. This saves memory since we no longer need a malloc call to allocate extra memory.

However, since this string cannot be modified, we set a special flag called STR_NOFREE in the flags attribute of the RString to remember that the ptr attribute points to an immutable C string literal rather than one on the malloc heap. When we need to modify this string, we need to first move the ptr to the malloc heap. We also need to remember to unset the STR_NOFREE flag.

The pseudo-code for creating Ruby strings from C string literals looks a bit like this:

def new_string_from_cstr_literal(c_str)
  # Allocate an RVALUE from the garbage collector
  str = garbage_collector_allocate_object
  # Set the STR_NOFREE attribute on the string
  str.flags.STR_NOFREE = true
  str.klass = String
  str.len = c_str.len
  # Directly set the point to the C string
  str.ptr = c_str
  str.capa = c_str.len
  str
end

Freeing Ruby strings

Since strings with ptr pointing to a C string literal isn’t allocated on the malloc heap, we can’t call free on it. So we only call free on strings without the STR_NOFREE flag set.

The pseudo-code for freeing Ruby string can be updated to handle the STR_NOFREE case:

def free_string(str)
  unless str.flags.STR_NOFREE?
    # free releases the ptr back to the system
    free(str.ptr)
  end

  # Give the RVALUE back to the garbage collector
  garbage_collector_free(str)
end

The bug

So how could a memory leak happen? One possibility is if we create a string from a C string literal (so STR_NOFREE is set), the string gets modified so the ptr is moved to the malloc heap but the STR_NOFREE flag is not unset, which means that when the string gets garbage collected it won’t call free on the memory held by ptr. This will leak the memory since it won’t be released back to the system.

Creating a STR_NOFREE string

We could easily create a STR_NOFREE string through the C API. But that’s too much work. It turns out, there’s a special optimization in Integer#to_s for the number 0. It’s a special case that creates a Ruby string directly from a C string literal. So an even easier way to create a STR_NOFREE string is to do 0.to_s.

Allocating a buffer without unsetting STR_NOFREE

The next step requires us to call the source of our bug, which is the String#initialize method. The String#initialize method accepts a capacity that is the capacity we want to initialize the string to. The part of the code in rb_str_init that implements String#initialize for STR_NOFREE strings looks like this. Explanations are provided as comments in the C code.

/* FL_TEST tests the flags of the string. In this case, it's
 * testing that either the string is STR_SHARED (which won't
 * be discussed) or STR_NOFREE (which is what we care about). */
else if (FL_TEST(str, STR_SHARED|STR_NOFREE)) {
    /* We calculate some values here, it's not critical that you
     * understand this code. */
    const size_t size = (size_t)capa + termlen;
    const char *const old_ptr = RSTRING_PTR(str);
    const size_t osize = RSTRING(str)->as.heap.len + TERM_LEN(str);
    /* Here, we allocate space for the new buffer. ALLOC_N will
     * ultimately call malloc. */
    char *new_ptr = ALLOC_N(char, (size_t)capa + termlen);
    memcpy(new_ptr, old_ptr, osize < size ? osize : size);
    /* FL_UNSET_RAW will unset flags for the string. But it only
     * unsets STR_SHARED, and not STR_NOFREE. */
    FL_UNSET_RAW(str, STR_SHARED);
    /* We set the ptr attribute of the string to the region we just
     * allocated through malloc above. */
    RSTRING(str)->as.heap.ptr = new_ptr;
}

The fix

The fix is simply to just unset STR_NOFREE. I think this one can compete in the “smallest ever bug fix” world record.

@@ -1734,7 +1734,7 @@ rb_str_init(int argc, VALUE *argv, VALUE str)
                 const size_t osize = RSTRING(str)->as.heap.len + TERM_LEN(str);
                 char *new_ptr = ALLOC_N(char, (size_t)capa + termlen);
                 memcpy(new_ptr, old_ptr, osize < size ? osize : size);
-                FL_UNSET_RAW(str, STR_SHARED);
+                FL_UNSET_RAW(str, STR_SHARED|STR_NOFREE);
                 RSTRING(str)->as.heap.ptr = new_ptr;
            }
            else if (STR_HEAP_SIZE(str) != (size_t)capa + termlen) {

This fix has been committed into Ruby and flagged for backporting to all maintained Ruby versions (2.6, 2.7, and 3.0). You can expect this fix to land in the next versions (2.6.9, 2.7.5, 3.0.3) of Ruby. You can follow the backport progress on the bug tracker. Meanwhile, the solution is to not call String#initialize multiple times on the same object. In fact, don’t do that even when this patch does get backported.

Conclusion

I hope you enjoyed reading about this really odd bug I found and fixed in Ruby. I also hope you learned a thing or two about how strings work in Ruby. If you enjoyed this article, check out my other blog post about “The Ruby Inplace Bug” that I found and fixed about a year ago.