How does concurrency increase speed in Ruby MRI?

In Ruby 2.3 MRI parrallelism is prevented by the Global Interpreter Lock (GIL). That means that you cannot run two processes at the same time, the CPU must switch between tasks. In that case, what are the benefits of using threads?

Threads don’t help with CPU intensive tasks

Because Ruby MRI can only run one process at a time, using threads to split CPU intensive tasks will not improve performance. Below is some code to demonstrate this:

def intensive_process
  10_000.times {|n| n ** n}
end

## Without multiple threads
time = Time.now
intensive_process; print "request_a"
print "request_b"; intensive_process; print "request_b2"
intensive_process; print "request_c"
puts "seconds: #{Time.now - time}" #=> seconds: 15.081583

## With multiple threads
time = Time.now
a = Thread.new { intensive_process; print "request_a" }
b = Thread.new { print "request_b"; intensive_process; print "request_b2" }
c = Thread.new { intensive_process; print "request_c" }
a.join; b.join; c.join
puts "seconds: #{Time.now - time}" #=> seconds: 15.223715

Here are two chunks of code that each perform 3 intensive operations. Running in a singe thread takes ~15 seconds and running in 3 separate threads takes ~15 seconds.

To increase the performance of CPU intensive tasks you could use Ruby’s fork method to fork an OS process. Or you could use another Ruby implementation (Jruby or Rubinious).

I/O intensive tasks

In a typical web application there are many processes that are restricted by I/O. For example, making a web request. When making a web request a lot of time is taken up waiting for a response. Ruby could spend this time running other processes.

MRI’s GIL will automatically switch processes if it is blocked by an I/O operation. Here is an example of this in action:

## Without multiple threads
time = Time.now
sleep 10; print "request_a"
print "request_b"; sleep 20; print "request_b2"
sleep 20; print "request_c"
puts "seconds: #{Time.now - time}" #=> seconds: 50.005372

## With multiple threads
time = Time.now
a = Thread.new { sleep 10; print "request_a" }
b = Thread.new { print "request_b"; sleep 20; print "request_b2" }
c = Thread.new { sleep 20; print "request_c" }
a.join; b.join; c.join
puts "seconds: #{Time.now - time}" #=> seconds: 20.000947

Here are two chunks of code that each perform 3 requests (I am using sleep to simulate slow web requests). The first example performs three requests sequentially in the same thread and the result is 50 seconds. This is expected because sleep 10 + sleep 20 + sleep 20 should equal 50.

The second chunk of code performs each of the 3 requests in a separate thread and the result is 20 seconds. This is because, while the first thread is sleeping, Ruby’s scheduler will switch to the second thread and start processing that thread. Once the second thread is sleeping the scheduler can move on to the third thread.

Summary

In short, Ruby MRI threads are useful for tasks that require I/O but they do not improve performance for processor intensive processes because MRI does not parallelize across multiple cores.