Concurrency and Parallelism in Ruby
In programming, concurrency and parallelism are essential techniques for improving the performance and efficiency of code. Ruby, a popular programming language, offers various tools to handle these concepts. Let's explore these techniques using a simple example.
Synchronous Code
Synchronous code executes tasks one after the other. Here's an example:
puts "Synchronous Code"
(1..5).each do |i|
puts i
sleep 1
end
In this code, numbers from 1 to 5 are printed with a 1-second delay between each number. The tasks run sequentially, meaning each number is printed only after the previous task (including the sleep) is completed.
Threads
Threads allow multiple sequences of instructions to run concurrently within the same program. Here's how we can use threads in Ruby:
puts "\nThreads"
threads = []
(1..5).each do |i|
threads << Thread.new do
puts i
sleep 1
end
end
threads.each(&:join)
In this example, each number from 1 to 5 is printed by a separate thread. All threads run concurrently, and the join
method ensures that the main program waits for all threads to finish before proceeding. This makes the tasks run in parallel, potentially reducing the total execution time.
Fork
The fork
method creates a new process, which is a separate instance of the Ruby interpreter:
puts "\nFork"
(1..5).each do |i|
pid = fork do
puts i
sleep 1
end
Process.wait(pid)
end
In this code, fork
creates a new process for each number. The parent process waits for each child process to complete using Process.wait
. Each process runs independently, providing true parallelism on multi-core systems.
Fibers
Fibers are lightweight concurrency primitives that enable cooperative multitasking:
puts "\nFibers"
fibers = []
(1..5).each do |i|
fibers << Fiber.new do
puts i
sleep 1
Fiber.yield
end
end
fibers.each(&:resume)
Each fiber runs a block of code and can be paused and resumed. This example prints numbers 1 to 5, pausing after each number. Although fibers provide concurrency, they do not run in parallel; the main program controls when each fiber resumes.
Ractor (Ruby 3)
Ractors, introduced in Ruby 3, enable true parallel execution by running code in isolated compartments:
puts "\nRactor"
ractors = (1..5).map do |i|
Ractor.new(i) do |i|
sleep 1
puts i
end
end
ractors.each(&:take)
In this example, each number from 1 to 5 is printed by a separate ractor. Ractors can run in parallel, making full use of multi-core processors. The take
method waits for each ractor to finish and return its result.
When to Use Concurrency and Parallelism Techniques
Understanding when to use each concurrency and parallelism technique is crucial for optimizing performance in Ruby applications. Here's a guide:
Threads and Fibers
Threads: Use threads when you need to handle I/O-bound tasks, such as reading and writing files or making network requests. Threads can run concurrently but are limited by Ruby's Global Interpreter Lock (GIL), which means only one thread executes Ruby code at a time. Threads are heavier than fibers, requiring more resources, but they are suitable for tasks that involve waiting for external data.
Fibers: Fibers are even lighter than threads and are used for cooperative multitasking. A single thread can manage multiple fibers, making fibers ideal for managing multiple I/O-bound tasks without creating additional threads. Fibers need explicit control for yielding and resuming, which provides fine-grained control over task execution.
Fork and Ractor
Fork: Use
fork
for CPU-bound tasks that require significant computation and can benefit from true parallelism. Forking creates a new process, allowing it to run on a separate CPU core without being limited by the GIL. This is useful for heavy computations but incurs more overhead due to process creation and inter-process communication.Ractor: Introduced in Ruby 3, ractors provide a way to achieve parallelism while ensuring thread safety. Ractors are ideal for heavy computations that can be distributed across multiple CPU cores. Unlike threads, ractors do not share memory and communicate via message passing, avoiding issues with the GIL and improving performance on multi-core systems.
Ruby provides multiple ways to handle concurrency and parallelism, each suited for different scenarios. Synchronous code is simple but sequential. Threads and fibers allow for concurrent execution within a single process, with threads offering parallelism on multi-core systems. Forking creates new processes for true parallelism, while ractors offer a modern and thread-safe way to achieve parallel execution in Ruby 3. Understanding these techniques helps developers write efficient and performant Ruby programs.
Top comments (3)
I have been using Ruby for like 15 years and I still have to find a convincing use case for fibers.
see falcon
I've seen it being used in a "download pause" of a http client