How to implement multithreading in Ruby

Imagine you’re a busy chef in a bustling kitchen, preparing multiple dishes at once to satisfy hungry customers. You find yourself juggling between chopping vegetables, stirring sauces, and cooking meats—all while ensuring each dish is cooked to perfection. However, as the orders pile up, you realize that working alone isn’t enough to meet the demands efficiently.

In computer programming, developers often face similar challenges when building applications. Imagine a software application that needs to handle multiple tasks simultaneously, such as processing user requests, fetching data from external sources, and updating the user interface in real time. Without a way to handle these tasks concurrently, the application will struggle to keep up with the workload, leading to delays and performance issues.

This is where multithreading comes into play. Multithreading allows programs to perform multiple tasks simultaneously, just like our chef juggling various cooking tasks in the kitchen. Instead of waiting for one task to finish before starting the next, multithreaded programs can execute multiple tasks concurrently, improving efficiency and responsiveness.

What is multithreading?

At its core, multithreading enables a program to create multiple threads, each capable of executing its own set of instructions independently. Threads share the same memory space but operate independently, similar to how different chefs work in the same kitchen but handle different tasks. This concurrency model allows programs to make better use of available resources, resulting in faster and more efficient execution.

Single-threading vs. Multi-threading
Single-threading vs. Multi-threading

Threads in Ruby

In Ruby programming, threads are managed using the Thread library, which provides a set of methods and classes for implementing multithreading. This section explores the basics of threads in Ruby, covering thread creation and synchronization techniques.

Basic thread creation

The Thread library in Ruby simplifies the process of creating and managing threads within a program. Let’s look at a simple example demonstrating thread creation:

require 'thread'
# Using the Thread library to create a new thread
thread = Thread.new do
10.times { |i| puts "Thread #{i}" }
end
# Wait for the thread to finish execution
thread.join

Code explanation

  • Line 1: We include the thread library, which provides the functionality to create and manage threads in Ruby.

  • Lines 3–6: We create a new thread using the Thread.new method. Inside the block, a loop runs 10 times, printing "Thread" followed by the current iteration index (i).

  • Line 9: We call the join method on the thread object, making the main thread wait for the newly created thread to complete its execution before continuing.

Thread synchronization

Thread synchronization is crucial in multithreaded programming to prevent race conditionsA race condition occurs when two or more threads have to access shared data and they try to change it at the same time. and ensure data integrity. Ruby’s Thread library provides built-in synchronization mechanisms like mutexes (Mutex) and condition variables to coordinate access to shared resources among threads.

Let’s explore an example demonstrating the use of a mutex for thread synchronization:

require 'thread'
mutex = Mutex.new
shared_resource = 0
# Define a block of code to be executed in multiple threads
threads = 5.times.map do |i|
Thread.new do
mutex.synchronize do
shared_resource += 1
puts "Thread #{i}: Shared Resource = #{shared_resource}"
end
end
end
# Wait for all threads to finish execution
threads.each(&:join)

Code explanation

  • Line 1: We include the thread library, which provides the functionality to create and manage threads in Ruby.

  • Lines 3: We create a new Mutex object named mutex, which will be used to synchronize access to the shared resource.

  • Lines 4: We initialize a variable named shared_resource with a value of 0. This variable will be shared and modified by multiple threads.

  • Lines 6–14:

    • A block of code is defined to be executed by multiple threads.

    • 5.times.map do |i| ... end creates an array of five threads.

    • Thread.new do ... end creates a new thread for each iteration.

    • mutex.synchronize do ... end ensures that the block of code inside it is executed by only one thread at a time, preventing concurrent modifications of shared_resource.

    • shared_resource += 1 increments the shared resource by one.

    • puts "Thread #{i}: Shared Resource = #{shared_resource}" prints the current thread index and the value of the shared resource after incrementing.

  • Lines 17: threads.each(&:join) iterates over each thread in the threads array and calls the join method on it, ensuring the main thread waits for all the created threads to complete before proceeding.

Blocking operations

In a multithreaded environment, blocking operations can significantly impact the performance and responsiveness of our application. Understanding how blocking works and how to handle it is crucial for effective multithreading.

What are blocking operations?

Blocking operations are tasks that prevent a thread from proceeding until a certain condition is met or an operation is completed. Common examples of blocking operations include:

  • I/O operations: Reading from or writing to a file, network requests, and database queries

  • Sleep operations: Pausing execution for a specified amount of time

  • Waiting for a resource: Waiting for a lock, semaphore, or another synchronization primitive to become available

Impact of blocking operations

When a thread encounters a blocking operation, it is put into a waiting state. If not managed properly, this can lead to inefficiencies because other threads might be waiting for the blocked thread to release resources or complete its task. In a single-threaded environment, blocking operations can halt the entire program until the operation is completed.

Handling blocking operations

In Ruby, we can manage blocking operations using threads to ensure that other parts of our application remain responsive. Moving blocking operations to separate threads allows the main thread to continue executing other tasks.

Example of blocking and non-blocking threads

Let’s look at an example where we handle blocking operations using threads:

# Define two threads: one that simulates a blocking operation
# and another that performs non-blocking tasks
threads = []
# Thread 1: Simulate a blocking operation
threads << Thread.new do
puts "Thread 1 started (blocking)"
sleep(5) # Simulate a blocking operation (e.g., I/O or network request)
puts "Thread 1 finished (blocking)"
end
# Thread 2: Perform a non-blocking operation
threads << Thread.new do
puts "Thread 2 started (non-blocking)"
3.times do |i|
puts "Thread 2: Iteration #{i + 1}"
sleep(1) # Simulate some computation
end
puts "Thread 2 finished (non-blocking)"
end
# Wait for all threads to finish execution
threads.each(&:join)

Code explanation

  • Lines 1–3:

    • We initialize an empty array named threads to store the thread objects.

    • We create two threads, one to simulate a blocking operation and the other to perform non-blocking tasks.

  • Lines 5–10:

    • We create a new thread is created and add it to the threads array.

    • puts "Thread 1 started (blocking)" indicates the start of the blocking operation.

    • sleep(5) simulates a blocking operation by pausing the thread for five seconds, representing a task like I/O or a network request.

    • puts "Thread 1 finished (blocking)" indicates the completion of the blocking operation.

  • Lines 12–20:

    • We create another new thread and add it to the threads array.

    • puts "Thread 2 started (non-blocking)" indicates the start of the non-blocking operation.

    • 3.times do |i| ... end runs a loop three times, printing a message and pausing for one second in each iteration to simulate some computation.

    • puts "Thread 2 finished (non-blocking)" indicates the completion of the non-blocking operation.

  • Line 23: threads.each(&:join) iterates over each thread in the threads array and calls the join method, ensuring the main thread waits for both threads to complete before proceeding.

In this example:

  • Thread 1 simulates a blocking operation by using sleep(5), pausing its execution for five seconds.

  • Thread 2 performs non-blocking operations, printing messages in a loop with a short sleep interval.

While Thread 1 is blocked, Thread 2 continues to execute, demonstrating how other threads can continue working even when one thread is blocked.

Common concurrency patterns

In multithreaded programming, certain concurrency patterns emerge as best practices to manage threads effectively and ensure safe and efficient execution. This section will discuss some common concurrency patterns in Ruby and provide examples to illustrate their usage.

Producer-Consumer pattern

The Producer-Consumer pattern is a classic concurrency pattern where one or more threads (producers) generate data, and one or more threads (consumers) process that data. This pattern helps balance the workload between producing and consuming tasks.

An example of the Producer-Consumer pattern with Queue

Ruby’s Queue class from the thread library is often used to implement the producer-consumer pattern.

require 'thread'
queue = Queue.new
# Producer thread
producer = Thread.new do
10.times do |i|
puts "Producing item #{i}"
queue << i
sleep(rand(0.1..0.5)) # Simulate variable production time
end
queue << nil # Sentinel value to signal end of production
end
# Consumer thread
consumer = Thread.new do
loop do
item = queue.pop
break if item.nil? # Exit if sentinel value is encountered
puts "Consuming item #{item}"
sleep(rand(0.2..0.6)) # Simulate variable consumption time
end
end
# Wait for both threads to finish execution
[producer, consumer].each(&:join)

Code explanation

  • Line 3: We create a Queue to hold items produced by the producer and consumed by the consumer. The Queue class in Ruby provides thread-safe FIFO (First-In-First-Out) operations.

  • Lines 5–13:

    • We create the producer thread using Thread.new.

    • It generates 10 items (numbers from 0 to 9) and pushes each item to the queue.

    • The sleep method simulates a delay in production, making the process more realistic by introducing variable production times.

    • After producing all items, the producer thread pushes a sentinel value (nil) to the queue to signal the end of production.

  • Lines 15–23:

    • We create the consumer thread using Thread.new.

    • It enters an infinite loop where it continuously pops items from the queue.

    • If the item is nil, it breaks out of the loop, signaling the end of consumption.

    • Otherwise, it processes the item (here, simply printing it) and simulates a delay using sleep.

  • Line 26: The join method is called on both threads to ensure the main thread waits for both the producer and consumer threads to complete their execution.

Worker Pool pattern

The Worker Pool pattern involves creating a pool of worker threads that process tasks from a shared queue. This pattern is useful for parallelizing tasks and improving throughput.

An example of the Worker Pool pattern with Queue

require 'thread'
queue = Queue.new
num_workers = 3
# Populate the queue with tasks
10.times do |i|
queue << i
end
# Create worker threads
workers = num_workers.times.map do |worker_id|
Thread.new do
while (task = queue.pop(true) rescue nil)
puts "Worker #{worker_id} processing task #{task}"
sleep(rand(0.1..0.5)) # Simulate task processing time
end
end
end
# Wait for all workers to finish execution
workers.each(&:join)

Code explanation

  • Line 3: We initialize a Queue to hold the tasks that need to be processed by worker threads.

  • Lines 6–9:

    • We add 10 tasks (numbers from 0 to 9) to the queue.

    • These tasks simulate units of work that need to be processed.

  • Lines 11–19:

    • We create a specified number of worker threads (3 in this case) using Thread.new within a loop.

    • Each thread continuously pops tasks from the queue using queue.pop(true). The true parameter makes the pop operation non-blocking and raises an exception if the queue is empty, which is handled by the rescue nil.

    • If a task is retrieved, the thread processes it (here, printing the task and worker ID) and simulates variable processing time using sleep.

  • Line 22: We call the join method on all worker threads to ensure the main thread waits for all workers to complete their tasks.

Thread-Safe Singleton pattern

This pattern ensures a singleton class is thread-safe. Ruby’s Mutex can be used to achieve this pattern.

An example of the Thread-Safe Singleton pattern

Here’s a code example of the Thread-Safe Singleton pattern:

require 'singleton'
require 'thread'
class Logger
include Singleton
def initialize
@log = []
@mutex = Mutex.new
end
def log(message)
@mutex.synchronize do
@log << message
puts "Logged: #{message}"
end
end
def show_log
@log.each { |message| puts message }
end
end
threads = 5.times.map do |i|
Thread.new do
Logger.instance.log("Message from thread #{i}")
end
end
threads.each(&:join)
Logger.instance.show_log

Code explanation

  • Line 4: The Logger class includes the Singleton module, ensuring only one instance of the class is created.

  • Lines 7–10:

    • We initialize a Mutex in the Logger class to synchronize access to shared resources.

    • We use the @log array to store log messages.

  • Lines 12–17:

    • The log method uses @mutex.synchronize to ensure that only one thread can execute the block of code at a time, making the method thread-safe.

    • Each thread adds a message to the log and prints it.

  • Lines 19–21: The show_log method prints all logged messages, demonstrating the cumulative effect of concurrent logging.

  • Lines 24–28:

    • We create five threads using Thread.new, each logging a unique message.

    • We call the Logger.instance.log method within each thread, demonstrating thread-safe access to the singleton instance.

  • Line 30: The join method ensures the main thread waits for all logging threads to finish execution.

Conclusion

Multithreading in Ruby provides a powerful way to enhance the efficiency and responsiveness of applications by allowing multiple threads to run concurrently. There are many common concurrency patterns, such as Producer-Consumer, Worker Pool, and Thread-Safe Singleton, which are essential for managing and synchronizing threads effectively.

By mastering these techniques and patterns, we can write robust, efficient, and maintainable multithreaded applications in Ruby. While multithreading introduces complexity, careful management and the use of established patterns can help us avoid common pitfalls and harness the full potential of concurrent programming.


Free Resources

Copyright ©2025 Educative, Inc. All rights reserved