See all articles

Thread safety in Ruby applications

Kamil Szubrycht

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.

1 2 3 4 5 6 7 8 9 10 11 # 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.

1 2 3 4 5 6 7 # program_spec.rb require './program' describe Program do it { expect(described_class.call).to eq(1267650600228229401496703205376) } end

Time to run our test.

1 2 3 4 5 $ 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.

1 2 3 4 5 6 7 8 9 10 11 12 13 # 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.

1 2 3 4 5 $ 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.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ 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.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 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.

1 2 3 4 5 $ 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 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.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ 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.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # 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.

Similar articles