I recently created a new Ruby gem: the gvl-tracing gem. This gem can be used to generate a visualization of what your Ruby threads are up to:

Click Open Example 1 to explore this example. Alternatively, download example1.json.gz and open it with the Perfetto UI. Example code is from example1.rb.

Interested? Read on for details and how to generate your own traces.

what is the global vm lock?

The Ruby VM is a big program mainly written in the C programming language (we’ll leave JRuby and TruffleRuby for another day 😁).

When you create threads in your Ruby application, the Ruby VM actually matches them, one to one, to operating system threads. (There’s been some discussion on changing this at some point, which would be really interesting!)

Thus, the Ruby VM a big multi-threaded C program. In such programs, to avoid concurrency bugs, you need to adopt a strategy to ensure correctness when multiple threads are working at the same time. The Ruby developers adopted a strategy that they chose to call the Global VM Lock, or GVL for short.

(As an aside, you may have heard the GVL instead being called the Global Interpreter Lock, or GIL. This term comes from the Python community; the Python interpreter uses something very similar to the GVL, for similar reasons. The Python developers just named it differently.)

The concept of the GVL is that whenever any thread is running Ruby code or otherwise needs to interact with Ruby VM structures, that thread needs to be holding the GVL, and no other thread can be doing the same. This ensures the correctness of the Ruby VM by making sure threads are not modifying objects and other critical VM structures at the same time.

As a consequence of the GVL, at the level of our own Ruby code in our Ruby applications, we can observe concurrency (multiple Ruby threads can be working on multiple things at a time), but not parallelism (at any one time, only one Ruby thread is making progress).

actually, the global vm lock is no more!

One of the big features of the Ruby 3.0 release is the introduction of Ractors. Ractors are a new concurrency primitive that was introduced to both the language and VM (and that I experimented with in other posts, see 1 and 2).

A key refactoring that was done when Ractors were introduced was making the Ruby VM go from having a single Global VM Lock to having multiple Global VM Locks, one per Ractor.

Now you may have spotted that naming something Global but then having multiple of them is probably slightly confusing, and the Ruby core developers agree with you: They actually don’t call it the Global VM Lock anymore. Since https://github.com/ruby/ruby/pull/5814, they just call it thread_sched.

But, if your Ruby application is not using Ractors — which I would bet is still the case for most applications — then, for all intents and purposes, you still are at the mercy of a single thread_sched, which acts exactly as the GVL did prior to Ruby 3.

Having gone through all that introduction, let’s move on to the fun parts!

let’s talk gvl tracing

Recently, @_byroot (Jean Boussier) introduced an amazing new Ruby VM feature that gives users access to the state of the GVL. He called it the GVL Instrumentation API, and with it we can observe in detail what is happening with the GVL: when threads grab it, when they release it, how long they hold on to it, and how long other threads spend waiting for it.

Ever since I learned of this new VM API I knew what I wanted to use it for: I wanted to build a visual timeline showing all this information.

Thus the gvl-tracing gem was born.

And here’s how it looks for a very simple Ruby application:

You can play with this example yourself by clicking Open Example 2. Alternatively, download example2.json.gz and open it with the Perfetto UI. Example code is from example2.rb.

So, what are we looking at here? We’re looking at this Ruby code:

require "gvl-tracing"

def fib(n)
  return n if n <= 1
  fib(n - 1) + fib(n - 2)
end

GvlTracing.start("example2.json")

other_thread = Thread.new { fib(37) } # runs in other thread
fib(37) # runs in main thread

other_thread.join
GvlTracing.stop

In this app, two threads are competing for the GVL, and you can see how Ruby gives each of them 100ms to run at a time, before pausing and switching to the next one. The thread executing at any point is the one in the resumed state; the other thread remains in the ready state, meaning it has work to do, it’s just waiting for its next turn holding the GVL.

You can observe how in Ruby you usually get concurrency, but not parallelism: both threads have plenty of work to do, but they take turns executing, which makes them both take longer.

If you add more threads you can start to see longer and longer gaps between when any given thread actually gets its turn. Here’s the same example I presented at the beginning of this post reproduced again: with 3 threads competing for the GVL, each thread needs to wait for 200ms for each 100ms spent working.

How would this application look if it was changed to use two different Ractors? As you may have suspected, you get real parallelism, with threads inside different Ractors acting independently:

You can play with this example yourself by clicking Open Example 3. Alternatively, download example3.json.gz and open it with the Perfetto UI. Example code is from example3.rb.

Yay, we established that ractors work! Both threads, each belonging to a different Ractor, run in parallel, unimpeded by the other.

using gvl-tracing: you need ruby 3.2

As I’m writing these words in July 2022, the biggest challenge in using the gvl-tracing gem is that you need an up-to-date development build of Ruby 3.2 to try it out (tip: preview 1 is too old).

Ruby 3.2 should be released in December 2022, so if you’re reading this after that date, you can just use the stable release as usual :)

But until then, here’s how you can install a development build of Ruby 3.2 using the usual Ruby version managers:

  • rvm: rvm install ruby-head

  • rbenv: rbenv install 3.2.0-dev

Or…​ if you have docker installed on your machine, you can use the ruby-lang development images.

Here’s how you can use them:

$ cd my_ruby_app/
$ docker run -v $(pwd):/app -it rubylang/ruby:master-focal
root@0e0b07edf906:/# cd app/
root@0e0b07edf906:/app# ruby -v
ruby 3.2.0dev (2022-07-23T12:42:05Z master 721d154e2f) [x86_64-linux]
root@0e0b07edf906:/app# gem install gvl-tracing
Building native extensions. This could take a while...
Successfully installed gvl-tracing-0.1.1
1 gem installed
root@0e0b07edf906:/app# ruby <your app>

The above command shares the current folder with the docker container, so you can run your app and any files in that folder as usual. (You can also use bundler!)

using gvl-tracing: how to try it on your own applications

The gvl-tracing gem provides only provides two apis: start and stop.

  1. Use start with a file name to start recording data: GvlTracing.start('my-test-trace.json').

  2. Use stop to finish the recording: GvlTracing.stop().

You can browse the resulting tracing file by loading it into the Perfetto UI.

share your traces and learnings!

We’re still at the very beginning of exploring gvl tracing and what we can learn about the Ruby internals with it.

Find something interesting? Consider dropping it on a GitHub gist and sharing the link! I’m @KnuX on twitter, and feel free to reach out via e-mail as well.

Finally, I’d like to thank @_byroot (Jean Boussier) for the awesome new API, and I also recommend you check out Shopify’s gvltools gem, which provides other ways of measuring the impact of the GVL on your own applications.