The Internals of Ruby Thread
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
, callingrb_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.