Most major programming languages implement threads to run code in parallel. This is also true for Ruby. However, Ruby's threading model differs from languages like C# and Java - it is not fully multithreaded. Specifically, we are referring to MRI Ruby (also known as CRuby) in this blog post, not alternative Ruby implementations.

Global Interpreter Lock

You might have heard of the Global Interpreter Lock (GIL) for Python. Unfortunately, MRI Ruby has implemented a GIL as well. The GIL restricts Ruby to only executing one thread at a time, limiting full parallelism.

But what is the purpose of the GIL?

It was created to enable easy concurrent execution of Ruby code. In most cases, developers don't have to worry about race conditions or unexpected behavior when executing code in parallel. Additionally, the GIL makes it easy to wrap C libraries that are not thread-safe into thread-safe Ruby C extensions.

However, the GIL can give a false sense of thread-safety, since shared mutable state between threads is still unsafe due to context switching.

When does context switching happen

Let's take the following code snippet as an example.

@shared_value = 0
1000.times.map do
 Thread.new do
   10000.times do
     value = @shared_value
     value = value + 1
     @shared_value = value
   end
 end
end.each(&:join)

If you run the above code multiple times, you will always get 10000000 as the output

However, the story will be different if we refactor the code a little bit

@shared_value = 0

def get_shared_value
 @shared_value
end

def set_shared_value(value)
 @shared_value = value
end

1000.times.map do
 Thread.new do
   10000.times do
     value = get_shared_value
     value = value + 1
     set_shared_value(value)
   end
 end
end.each(&:join)

puts @shared_value

This time, running the code will get you different results each time

The second code snippet is logically identical to the first one, except it creates two functions for getting and setting the shared value. Why will it behave differently in threads?

One of the rules of MRI explains this:

Ruby’s MRI does context switching every time the running thread calls a function or returns from a function

If Ruby switches context after calling get_shared_value. The returned value becomes dirty and corrupts the shared value when it's set back.

This also means that if there's no internal function call inside a function, no context switch will happen.

Other cases that trigger context switching or scheduling include

  • Blocking I/O operations: When a thread performs blocking I/O like reading from a socket, Ruby will release the GIL and allow other threads to run while waiting.
  • GC: The garbage collector releases the GIL so other threads can run during collection.

It's worth noting that the context switching behavior only applies to Ruby functions, not C extension functions. C extensions do not switch context when calling internal functions, since Ruby has no visibility into this. As a result, C libraries that are not inherently thread-safe can easily be wrapped into thread-safe Ruby extensions.

Manual context switching

While the GIL is intended to simplify concurrent programming, it has its limitations. Sometimes developers need more control over context switching between threads. Fortunately, Ruby provides ways to manually trigger context switches at specific points:

In Ruby code:

  • Thread.pass: Calling Thread.pass voluntarily gives up control so another thread can run. The thread is paused and Ruby switches contexts.
  • Thread.stop: Stopping a thread with Thread.stop pauses it and allows rescheduling.
  • sleep: Calling sleep releases the GIL while sleeping. Other threads can run.

In C extension:

  • rb_thread_schedule: Like Thread.pass, calling rb_thread_schedule manually release the GIL and allow parallel execution.

Conclusion

The Global Interpreter Lock in Ruby provides simplicity for concurrent programming. Understanding how it works and when context switching happens allows developers to write optimal thread-safe code. Though not perfect, the GIL is a reasonable tradeoff for an interpreted dynamic language like Ruby. Manual context switching gives more control when needed.