Ruby's GIL in a nutshell
Stanislav Kozlovski Sep 11 '17
Note: Even though they are technically not the same, I use interpreter and runtime interchangeably. Know I am referencing MRI.
Maybe you've heard that there is this one thing that is stopping our beloved language from being faster. The GIL (Global Interpreter Lock) is a feature of the Ruby interpreter that prevents data from being accessed by multiple threads at once; in other words - it forbids parallelism with shared memory, a popular strategy for speeding up software.
But wait, what are threads?
A thread is the context of a program's execution, keeping track of what state it's currently in. This allows you to assign a task to a thread, switch to another task (on another thread), finish that task and resume the original one. Your operating system can put threads on different CPU cores, allowing them to run simultaneously and significantly improving the performance of your program.
By default, Ruby runs in one thread. No switching of tasks takes place.
So, say we have the following code
number = 0 (1..5000).each do |num| # loop #1 number = number + 1 end puts 'Loop #1 is finished!' (1..5000).each do |num| # loop #2 number = number + 1 end puts 'Loop #2 is finished!' puts number
We know that the first loop will finish before the second for sure. Nothing gets run at the same time and nothing happens in between the loops.
> enether$ ruby loops.rb Loop #1 is finished! Loop #2 is finished! 10000
Okay, how could we make use of that idle core? As one thread cannot be run on two cores at the same time, we'll need to create another thread! Ruby's
Thread class is an abstraction of a thread that gets mapped to a native thread ran by your operating system.
number = 0 (1..5000).each do |num| # loop #1 number = number + 1 end puts 'Loop #1 is finished!' another_thread = Thread.new do (1..5000).each do |num| # loop #2 number = number + 1 end puts 'Loop #2 is finished!' end puts number
Now we have moved our second loop into
another_thread and everything else is left in the main thread. Loop #2 retains access to the number variable and is left to increment it alongside loop #1.
What we want to happen
Cool, let's run this and make sure it works!
> enether$ ruby fast_loops.rb Loop #1 is finished! 5000
What the hell?
Okay, I did not mention one other fact.
As soon as the main thread finishes, so does our program; all other threads are forcefully terminated without waiting for them to finish. To make sure our program waits for
another_thread to do its thing, we introduce the
ThreadsWait class by adding
require 'thwait' and
ThreadsWait.all_waits(another_thread) to our file. This tells Ruby to not end our program until all the threads given to the
all_waits method finish execution.
> enether$ ruby fast_loops.rb Loop #1 is finished! Loop #2 is finished! 10000
Much better. Our code should now be running on two CPU cores at the same time, hence it should be twice as fast!
But actually it is not doing that.
This is where the dreaded GIL steps in. The GIL limits the amount of threads that can access the runtime to 1! That's right, only one thread can access the Ruby interpreter at any time. This essentially means that parallelism with shared memory (e.g both code having access to the
number variable) is forbidden.
At this point you may be asking yourself - "How does
another_thread get run if the GIL limits access to one thread at a time?"
Easy, the threads take turns in accessing the runtime. The currently running thread holds the GIL and releases it once it's appropriate.
This is called (voluntary) context switching.
As we said, threads store at what point of the program they're at and they can easily pause/resume their work. It would be unfair (and defeat the purpose of threads) if one thread hogged the CPU until it finished all of its work.
As you can see, our code is ran consecutively, no two lines are actually ever ran at the very same time. These is no way around this except for removing the lock.
In this scenario, our program is actually slower than it would have been without the threads, as switching from thread to thread is unnecessary overhead which does not serve us any purpose.
We couldn't care less if we ran 200 iterations in loop #1, ran 5000 iterations in loop #2 and came back to finish loop #1. Regardless, there are scenarios where it makes sense to use threads but we will not touch them here.
A GIL-less world
We know our standard Ruby interpreted (MRI) has the GIL but some other implementations do not! JRuby, implemented in Java, does not have such a thing. Let's run our code in there and see what happens.
> enether$ jruby faster_java_loops.rb Loop #1 is finished! Loop #2 is finished! 5454
What the hell?
> enether$ jruby faster_java_loops.rb Loop #1 is finished! Loop #2 is finished! 8580 > enether$ jruby faster_java_loops.rb Loop #1 is finished! Loop #2 is finished! 9162
It produces a different result each time
This code is not thread-safe. But what does that mean? What is actually happening here?
And we start running them, follow the diagram carefully
As you can see, our thread can get paused right before an assignment. This is an OS context switch (involuntary context switch) scheduled by the kernel's scheduler and is unpredictable as well as unavoidable. The other thread can do as much work as it likes but it can get overwritten if a context switch happens at the wrong time.
This is what can happen with a lack of a GIL as JRuby shows.
With a GIL, our main thread would have taken the lock and even if the OS switched to the other thread, it would have been denied access to the interpreter, essentially forcing it to give up the CPU to another thread.
This is what the GIL is, an implementation detail of the Ruby runtime that guards it against problems introduced by multithreaded code. By having threads hold a lock on who can access their data, involuntary context switches do not do anything but slow our code down (which is better than breaking it).
It is extremely important to note that this does not make all Ruby code you write thread-safe. The sole purpose of the GIL is to protect the interpreter's internals from situations like the one depicted above.
Other benefits of the GIL include:
- Faster single-threaded programs
- It makes wrapping C libraries easier. You don't have to worry about thread-safety. If the library is not thread-safe, you simply keep the GIL locked while you call it.
- Simple garbage collection
We saw that the GIL is a lock that prevents other threads from accessing the interpreter while another thread has it and got a vague idea of how it works with multiple threads - namely by sequential acquire/release operations.
We showed a simple example of why it is needed to protect MRI against OS context switches and how things might go wrong if it did not exist.
Hopefully this leaves you with a vague idea on why we need the GIL and a little bit less resentment, if not some respect, for it.