Race conditions in Ruby what they are and how to avoid them

- 7 mins read
rails race-condition tips database-locking

A race condition occurs when two or more threads can access shared data and they try to change it at the same time. As a result, the values of variables may be unpredictable and vary depending on the timings of context switches of the process.

To understand this better, imagine that we are In a situation where there are multiple people in a room discussing something, but only the person holding the microphone is allowed to speak at any given time, this is an example of a mutual exclusion or “mutex” condition. In this situation, the ability to speak is a shared resource that is restricted to only one person at a time. This ensures that only one person can speak at a time, and prevents multiple people from talking over each other.

This image generated by AI

In Ruby, you can use a mutex (mutual exclusion) to address race conditions. A mutex allows only one thread to access a shared resource at the same time, so other threads must wait until the mutex is released before they can access the resource.

Here is an example of using a mutex to prevent a race condition in Ruby:

require 'thread'

# Create a mutex
mutex = Mutex.new

# Define a shared variable
counter = 0

# Define a function that increments the counter
# and acquires the mutex before doing so
def increment_counter(mutex, counter)
  mutex.synchronize do
    counter += 1
  end
end

# Start two threads that increment the counter
# using the shared function
threads = []
2.times do
  threads << Thread.new do
    10.times do
      increment_counter(mutex, counter)
    end
  end
end

# Wait for the threads to finish
threads.each(&:join)

# Print the final value of the counter
# (should be 20)
puts counter

In the previous example, the increment_counter function acquires the mutex using the synchronize method before incrementing the counter variable. This ensure that only one thread can execute the code inside the synchronize block at the same time, preventing race conditions.

To test this code with rspec, you can use the thread and rspec-mocks gem. Here is an example rspec test that checks that the counter variable has the expected value after both threads have finished executing:

# require the thread and rspec-mocks gems
require 'thread'
require 'rspec/mocks'

describe 'race condition prevention' do
  it 'prevents race conditions using a mutex' do
    # Create a mutex
    mutex = Mutex.new

    # Define a shared variable
    counter = 0

    # Define a function that increments the counter
    # and acquires the mutex before doing so
    def increment_counter(mutex, counter)
      mutex.synchronize do
        counter += 1
      end
    end

    # Start two threads that increment the counter
    # using the shared function
    threads = []
    2.times do
      threads << Thread.new do
        10.times do
          increment_counter(mutex, counter)
        end
      end
    end

    # Wait for the threads to finish
    threads.each(&:join)

    # Expect the final value of the counter to be 20
    expect(counter).to eq(20)
  end
end

The RSpec test creates the mutex, shared variable, and increment_counter function in the same way as the previous example. It then starts two threads that increment the counter variable using the increment_counter function. Finally, it expect the final value of the counter variable to 20. If the code is correct and the mutex is used to prevent race conditions, the test will pass.

Handling race condition from database layer

To prevent race conditions at the database level, you can use database locking to coordinate access to shared data between threads or processes. This involves acquiring a lock on the database record that you want to modify, updating the record, and then releasing the lock.

Here is an example of using database locking to prevent race condition in Ruby on Rails application:

class User < ApplicationRecord
  # lock the database record
  # before updating the quota attribute
  def purchase_quota(quota)
    User.transaction do
      lock!
      self.quota_balance = quota
      save!
    end
  end
end

The purchase_quota method uses the transaction method provided by Active Record to wrap the database operations in a transaction. This ensures that the database record for the User model is locked before the quota attribute is updated.

The lock! method is used to lock the database record for the User model, and the quota_balance attribute is updated by adding the quota argument to it. Finally, the save! method is called to save the updated record to the database.

Here is an example of how you could test for race conditions in the **purchase_quota** method:

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#purchase_quota' do
    it 'prevents race conditions when transferring funds' do
      # Create a user with a starting quota of 10
      user = create(:user, quota_balance: 10)

      # Start two threads that top up quota 10 to the user's account
      threads = []
      2.times do
        threads << Thread.new do
          user.purchase_quota(10)
        end
      end

      # Wait for the threads to finish
      threads.each(&:join)

      # Expect the user's quota balance to be 30
      expect(user.quota_balance).to eq(30)
    end
  end
end

This test creates a new **User** record with a starting quota balance of 10, then starts two threads that each purchase 10 to the user’s table. After the threads have finished, it expects the user’s quota_balance to be 30.

If the purchase_quota method is implemented correctly and uses database locking to prevent race conditions, this test will pass. However, if the method does not use locking and race conditions occur, the final value of the **quota_balance** attribute may not be predictable, and the test may fail.

Conclusion

Race condition can occur in many different situations, but a common example is when multiple threads or processes try to update the same shared data at the same time. The previous example explains if we have an application that allows users to purchase the quota, and many processes can deduct the quota_balance at the same time, this can lead to a race condition.

Without proper synchronization or locking, each user’s quota deduction may be processed independently, resulting in the quota balance of the target user being updated multiple times in an unpredictable manner. This can cause errors and inconsistencies in the data, and can lead to incorrect or unexpected results.

By using database locking, you can prevent race conditions in situations like this by ensuring that only one thread or process can update the data at same time. This can help to ensure the consistency and integrity of the data in your database, and can prevent errors and inconsistencies that may be caused by race conditions.

Source: