Ruby

 

Concurrency in Ruby: Parallel Execution with Parallel, Concurrent Ruby, and More

Concurrency is a crucial aspect of modern software development, enabling us to write efficient and faster code by executing multiple tasks simultaneously. In Ruby, a popular object-oriented programming language known for its simplicity and ease of use, concurrency is no exception. With the rise of multi-core processors and the demand for high-performance applications, understanding concurrency in Ruby has become increasingly important.

Concurrency in Ruby: Parallel Execution with Parallel, Concurrent Ruby, and More

In this blog, we’ll explore the concept of concurrency in Ruby and various tools and techniques that enable parallel execution. We’ll delve into two popular libraries, “Parallel” and “Concurrent Ruby,” along with some other valuable tips to boost your Ruby code’s performance.

1. Understanding Concurrency in Ruby:

Concurrency in Ruby allows the execution of multiple tasks concurrently, making it possible to handle heavy workloads more efficiently. It enables you to divide your code into smaller tasks that can run in parallel, harnessing the power of multi-core processors.

At its core, Ruby has native support for threads through the “Thread” class, but due to the Global Interpreter Lock (GIL), only one thread can execute Ruby code at a time. This means that Ruby threads are suitable for I/O-bound tasks, but they are not very effective for CPU-bound tasks, as they won’t take full advantage of multi-core processors.

To achieve true parallel execution in Ruby, we’ll explore some libraries and approaches that can bypass the GIL and allow us to leverage the full potential of multi-core processors.

1.1. Parallel Library:

The “Parallel” gem is a simple and effective way to introduce parallelism into your Ruby code. It provides an easy-to-use interface for parallel execution, distributing tasks across multiple processes.

1.1.1. Installation:

To start using the “Parallel” gem, first, install it using the following command:

ruby
gem install parallel

1.1.2. Basic Usage:

The basic usage of “Parallel” involves calling the Parallel.map method, passing an enumerable as input, and a block that defines the operation to be performed on each element of the enumerable.

ruby
require 'parallel'

# Example: Squaring numbers in parallel
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

squared_numbers = Parallel.map(numbers) { |num| num * num }
puts squared_numbers

In this example, the Parallel.map method distributes the squaring operation for each element in numbers across multiple processes, harnessing the power of multi-core CPUs. The output will be the squared numbers in a non-guaranteed order due to parallel execution.

1.1.3. Control the Number of Processes:

By default, “Parallel” uses the number of available CPU cores to determine the number of processes to run in parallel. However, you can also control the number of processes by setting the :in_processes option:

ruby
# Example: Specifying the number of processes
squared_numbers = Parallel.map(numbers, in_processes: 4) { |num| num * num }

In this example, we explicitly set the number of processes to 4, which can be useful when you want to limit the CPU or memory usage.

1.2. Concurrent Ruby:

Concurrent Ruby is another powerful library for managing concurrency in Ruby applications. It provides multiple constructs, including threads, futures, promises, and actors, to handle concurrent tasks effectively.

1.2.1. Installation:

To use Concurrent Ruby, add it to your Gemfile or install it manually:

ruby
gem 'concurrent-ruby'

1.2.2. Threads in Concurrent Ruby:

Concurrent Ruby offers a more efficient way of working with threads compared to Ruby’s native “Thread” class. The Concurrent::Future class, for instance, allows executing a block in a separate thread and obtaining the result once the thread completes its task.

ruby
require 'concurrent'

# Example: Using Concurrent::Future for parallel execution
future = Concurrent::Future.execute { 2 + 2 }
result = future.value
puts result

In this example, the Concurrent::Future executes the block 2 + 2 in a separate thread. The value method blocks until the thread finishes its computation, and we get the result, which is 4 in this case.

1.2.3. Futures and Promises:

Concurrent Ruby also provides Concurrent::Promise, which allows setting the result of a computation manually and obtaining it asynchronously. This is particularly useful when you need to work with shared resources and coordinate between multiple threads.

ruby
require 'concurrent'

# Example: Using Concurrent::Promise for deferred computation
promise = Concurrent::Promise.new { 2 + 2 }
promise.execute

# ... (do other work)

result = promise.value
puts result

In this example, the computation of 2 + 2 is deferred until promise.execute is called, which allows us to perform other tasks in the meantime. When we finally need the result, we call promise.value.

1.2.4. Actors:

Actors are another powerful concurrency construct in Concurrent Ruby, allowing you to work with shared state in a safe and concurrent manner. Actors encapsulate their own state and can only be accessed through messages, ensuring that concurrent access is properly managed.

ruby
require 'concurrent'

# Example: Creating an Actor
class Counter < Concurrent::Actor::RestartingContext
  def initialize
    @count = 0
  end

  def on_message(msg)
    case msg
    when :increment
      @count += 1
    when :get_count
      @count
    else
      pass
    end
  end
end

# Using the Actor
counter = Counter.spawn

# Send messages to the actor concurrently
10.times { counter.tell(:increment) }

# Get the count from the actor
count = counter.ask(:get_count).value
puts count

In this example, we create an Counter actor that maintains an internal state @count. The actor can only be interacted with by sending messages (:increment and :get_count). This guarantees safe and concurrent access to the internal state of the actor.

1.3. Other Concurrency Techniques:

Apart from the libraries discussed above, Ruby offers several other techniques to achieve concurrency and parallel execution. Let’s explore a few additional methods that can come in handy.

1.3.1. Mutex:

Ruby’s standard library includes the Mutex class, which allows you to synchronize access to shared resources, making it safe for multiple threads to access them. By locking the mutex, only one thread can execute the code block at a time, preventing race conditions.

ruby
require 'thread'

# Example: Using Mutex for thread synchronization
mutex = Mutex.new
shared_resource = 0

# A method that increments the shared_resource safely
def safe_increment(mutex, shared_resource)
  mutex.synchronize { shared_resource += 1 }
end

# Create multiple threads that increment the shared_resource
threads = []
10.times do
  threads << Thread.new { safe_increment(mutex, shared_resource) }
end

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

puts shared_resource

In this example, we use a Mutex to synchronize access to the shared_resource in the safe_increment method. By doing so, we ensure that only one thread can modify the shared resource at a time, preventing data corruption and race conditions.

1.3.2. Condition Variable:

The ConditionVariable class in Ruby allows you to build more complex synchronization patterns. It enables threads to wait for a specific condition to become true before continuing their execution.

ruby
require 'thread'

# Example: Using ConditionVariable for synchronization
mutex = Mutex.new
condition_variable = ConditionVariable.new
shared_resource = 0

# A method that increments the shared_resource safely and notifies waiting threads
def safe_increment(mutex, condition_variable, shared_resource)
  mutex.synchronize do
    shared_resource += 1
    condition_variable.signal
  end
end

# A method that waits until a condition is met
def wait_for_condition(mutex, condition_variable, shared_resource)
  mutex.synchronize do
    condition_variable.wait(mutex) while shared_resource < 5
  end
end

# Create a thread that waits for the condition
wait_thread = Thread.new { wait_for_condition(mutex, condition_variable, shared_resource) }

# Create multiple threads that increment the shared_resource
increment_threads = []
10.times do
  increment_threads << Thread.new { safe_increment(mutex, condition_variable, shared_resource) }
end

# Wait for all threads to finish
wait_thread.join
increment_threads.each(&:join)

puts shared_resource

In this example, we use a ConditionVariable to have the wait_thread wait until the shared_resource reaches a specific condition (in this case, it waits until the resource is at least 5). The other increment_threads increment the shared resource and notify the waiting thread once the condition is met.

Conclusion

Concurrency and parallel execution are essential techniques to harness the full power of multi-core processors and write efficient Ruby code. In this blog, we explored the concept of concurrency in Ruby and discussed various libraries and techniques that facilitate parallel execution.

The “Parallel” gem is a simple and effective way to achieve parallelism, while “Concurrent Ruby” offers powerful constructs like threads, futures, promises, and actors for more advanced concurrency management. Additionally, we explored other techniques like Mutex and ConditionVariable, which can be handy for thread synchronization.

By understanding and applying these concurrency concepts, you can significantly improve the performance and efficiency of your Ruby applications, making them better suited to handle heavy workloads and deliver a smoother user experience. Happy coding!

Previously at
Flag Argentina
Chile
time icon
GMT-3
Experienced software professional with a strong focus on Ruby. Over 10 years in software development, including B2B SaaS platforms and geolocation-based apps.