Multithreading is the ability to execute code on multiple concurrent threads. Each thread exists within a process, and each process can have at least one thread. Multithreading allows you to speed up your program, but also creates additional problems that do not occur in single-threaded programs. If you decide to use multiple threads in your app, you need to ensure that your code is thread-safe.
Let’s start with this simple program.
# program.rb
class Program
def self.call; new.call; end
def call
number = 1
100.times { number += number }
number
end
end
And a test for it, to make sure everything works properly.
# program_spec.rb
require './program'
describe Program do
it { expect(described_class.call).to eq(1267650600228229401496703205376) }
end
Time to run our test.
$ rspec program_spec.rb
.
Finished in 0.02662 seconds (files took 1.12 seconds to load)
1 example, 0 failures
Success! Our program passed test passed. Now, let’s see what the result will be with two threads. To create a new thread, we will use the Thread class, which is an abstraction for an operating system thread. When you create a Thread, you need to pass the code block that is executed inside the thread. To ensure that the main thread will wait for our threads to finish, we have to call join method on each thread.
# program.rb
class Program
def self.call; new.call; end
def call
number = 1
2.times.map do
Thread.new { 500.times { number += number }}
end.each(&:join)
number
end
end
Let’s run our test.
$ rspec program_spec.rb
.
Finished in 0.02662 seconds (files took 1.12 seconds to load)
1 example, 0 failures
Alright, it looks like everything works well. But let’s run the same example with JRuby.
$ rspec program_spec.rb
F
Failures:
1) Program should eq 1267650600228229401496703205376
Failure/Error: it { expect(described_class.call).to eq(1267650600228229401496703205376) }
expected: 1267650600228229401496703205376
got: 14855280471424563298789490688
(compared using ==)
# ./program_spec.rb:4:in `block in (root)'
Finished in 0.04002 seconds (files took 1.12 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./program_spec.rb:4 # Program should eq 1267650600228229401496703205376
Oops, that’s not what we expected! What’s going on? What you see above happened because, unlike with MRI (Matz's Ruby Interpreter), there is no GIL (Global Interpreter Lock) mechanism in JRuby. The GIL ensures that two threads cannot be executed at the same time. That means threads on MRI won't run in parallel - MRI just switches between threads, giving each some CPU time. To fix our code to work with JRuby, we can use the Mutex locking system, which will allow to synchronize access to certain sections of code.
# program.rb
class Program
def self.call; new.call; end
def call
number = 1
mutex = Mutex.new
2.times.map do
Thread.new do
500.times { mutex.synchronize { number += number }}
end
end.each(&:join)
number
end
end
Let’s check if it works.
$ rspec program_spec.rb
.
Finished in 0.02662 seconds (files took 1.12 seconds to load)
1 example, 0 failures
Ok, fixed. Does that mean you don’t have to care about thread safety when you have GIL? Well, not really. Imagine a situation in which you read some global resource and update its value after a while. We can simulate this with the following example:
# program.rb
class Program
def self.call; new.call; end
def call
number = 1
2.times.map do
Thread.new do
50.times do
number_value = number
sleep(0.001)
number += number_value
end
end
end.each(&:join)
number
end
end
Now, let’s run that with MRI.
$ rspec program_spec.rb
F
Failures:
1) Program should eq 1267650600228229401496703205376
Failure/Error: it { expect(described_class.call).to eq(1267650600228229401496703205376) }
expected: 1267650600228229401496703205376
got: 1525719740468429782208
(compared using ==)
# ./program_spec.rb:4:in `block (2 levels) in <top (required)>'
Finished in 0.07984 seconds (files took 0.28282 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./program_spec.rb:4 # Program should eq 1267650600228229401496703205376
As we can see, GIL does not ensure that our code is always thread safe. We need to make sure that all operations are atomic. We do that by using the same mechanism as before - Mutex.
# program.rb
class Program
def self.call; new.call; end
def call
number = 1
mutex = Mutex.new
2.times.map do |i|
Thread.new do
50.times do
mutex.synchronize do
number_value = number
sleep(0.001)
number += number_value
end
end
end
end.each(&:join)
number
end
end
If you use threads, you need to make sure that your code and any gem you use is thread safe. Special care should be taken if you use a background processing system, like Sidekiq. By default, one Sidekiq process creates 10 threads. You can change that by changing the concurrency value in the Sidekiq config file.