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).
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:
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.).klass
: Pointer to the class of the object.len
: The length of the string.ptr
: A pointer to a character buffer that stores the contents of the string.capa
: The capacity of the buffer inptr
.
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.