Introducing Threads, Parallelism, and Mutex in plain Ruby

In our modern, computerized world, managing multiple actions at once (known as concurrency) has become integral to creating efficient and responsive software. In Ruby, one way we achieve this is through the use of threads.

Explaining threads

A thread in Ruby is an execution flow, similar to a process, but requires less memory and CPU time to create and manage. Threads can run in parallel on multi-core systems, greatly improving the speed of programs that require a lot of waiting (such as making requests to a server or reading data from a database). However, using threads is not without its pitfalls. The dangers of concurrent access to shared resources. Imagine we have two threads that need to read and modify the same shared resource at the same time. This could lead to what's known as a race condition, where the output depends on the unpredictable timing of the threads. As a result, you could end up with unreliable and unexpected results.

Using mutexes to manage shared resources

A common way Ruby developers solve this problem is to use a mutex. A mutex (short for "mutual exclusion") is a way of synchronizing access to shared resources to prevent race conditions. Let's look at how we can use a mutex to ensure safe withdrawals from a BankAccount class in a multithreaded environment.

class BankAccount
  attr_reader :balance

  def initialize(balance)
    @balance = balance
    @lock = Mutex.new
  end

  def withdraw(amount, job_id)
    puts "JOB #{job_id}:: Withdrawing money..."
    sleep(rand(1..4)) # simulate long task which is not critical for the account here
    # lock the account to make sure only one thread can access it at a time(race conditions)
    @lock.synchronize do
      if @balance >= amount
        @balance -= amount
        puts "JOB #{job_id}:: Withdrawn #{amount}. New balance: #{balance}."
        return { id: job_id, amount: amount }
      else
        puts "JOB #{job_id}:: Cannot withdraw #{amount}. Only #{balance} left in account."
        return { id: job_id, amount: 0 }
      end
    end
  end
end

account = BankAccount.new(1000)
threads = []

# Create a new thread for each withdrawal
12.times do |job_id|
  threads << Thread.new do
    account.withdraw(100, job_id)
  end
end

puts 'Running threads...'
# Wait for all threads to complete
output = threads.each(&:join)

puts "All withdrawals complete. Total amount: #{output.map(&:value).reduce(0) { |sum, hash| sum + hash[:amount] }}"

The first part of the code contains a definition of the BankAccount class. It contains two methods: the initialize method, that sets the balance and initializes a new mutex instance, and the withdraw method, which demonstrates the use of the mutex.
Within the withdraw method, we use the synchronize method of the mutex @lock. This method takes a block in which to execute our 'critical section' of code, i.e. the section that should only be accessed by one thread at a time.
The rest of the withdraw method involves some display of messages and actual withdrawal calculations. The whole withdrawal process runs within the synchronized block to prevent race conditions.
Next, we initiate a BankAccount instance and an array of threads. Then we spawn 12 threads using Ruby's in-built Thread class. Each thread will independently withdraw from the account. Finally, we use threads.each(&:join) to tell the main thread to wait for all spawned threads to finish their execution before it continues.

Summary

Parallelism and threads are powerful tools in Ruby, but without proper synchronization, it can lead to unpredictable results. Mutexes provide a simple and effective way to ensure safe access to shared resources in a parallel or threaded environment.

Happy parallelizing!