Concurrency and Parallelism in Ruby
Tags: ruby, concurrency, parallelism, threads
Published: 2024-06-23 19:46:42

### 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:

```ruby
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:

```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:

```ruby
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:

```ruby
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:

```ruby
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.