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!