Thread safety in Ruby applications

Kamil Szubrycht - Full-Stack Developer
multithreading, ruby, security, web

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.


Let’s get in touch
Author's Bio
Kamil Szubrycht

Full-Stack Developer

Full-Stack Web Developer with Foreman experience. Coding mostly in Ruby and JavaScript - when nobody is looking. An open source enthusiast. Biker. An occasional chess player. Real Madrid supporter. Addicted to pizza.

Similar articles
Comments

Bulletproof your development with remote team augmentation

Read how
This page is best viewed in portrait mode
Our websites and web services use cookies. We use cookies and collected data to enhance your experience, provide additional communication channels, improve marketing materials and enhance our offer. IRONIN SP. Z O.O. SP. K. is committed to protecting all the data that we collect or process in any way, especially data of personal nature. By accepting these terms you agree to our usage of cookies and processing your data, according to our Privacy Policy, and you declare that your browser settings reflect your preferences. Read more You have the right to revoke this agreement at any time, based on the terms of our Privacy Policy. You can change cookies settings in your browser. If you do not agree with us using cookies and processing your data, please change your cookies settings in your web browser and reject these terms. You can find more information about cookies, your data privacy This site uses cookies. By continuing to browse the site, you are agreeing to our use of cookies. data processing, and your rights in our Privacy Policy.