#23: Elixir vs Ruby: Parallelism & Concurrency explained

·

8 min read

I am going to start this post with a bold claim:

"Elixir boasts one of the most advanced concurrency models in modern programming languages!"

Yup, that's right! But you may have already heard about Elixir's reputation for effortlessly scaling across all CPU cores, which is probably the reason why you became interested in Elixir in the first place. This reputation is well-deserved, and, hopefully, by the end of this post, you will have a clear understanding of how Elixir achieves this feat.

Both parallelism and concurrency are important concepts, particularly in the context of designing high-performance systems that are required to operate efficiently at a low cost. A system with robust parallelism can efficiently manage multiple simultaneous requests, such as those from web browsers or asynchronous job systems, using fewer machines, ultimately leading to lower operating costs.

Ruby and Elixir employ vastly different concurrency models. Elixir, in particular, stands out with a concurrency model that diverges from the conventions of most mainstream languages. In this post, I am going to delve into these distinctions and introduce key concepts essential for understanding parallelism and concurrency.

Let’s start this post by first comparing parallelism and concurrency as they are not the same.

Parallelism vs Concurrency

Concurrency and parallelism are often confused, but they represent distinct yet interconnected concepts. Concurrency involves coordinating multiple tasks simultaneously. For instance, on a server with a single CPU core, concurrency enables the execution of tasks needed to handle requests. While it may seem like tasks are being handled simultaneously, they are actually overlapping in their execution, but not necessarily executed at exactly the same time. To truly execute different tasks simultaneously, parallelism is required.

Parallelism, on the other hand, allows for the simultaneous execution of multiple tasks. In a system with multiple CPU cores, parallelism enables these cores to work concurrently on different tasks. This simultaneous processing is essential for maximizing system throughput and overall performance. By leveraging parallelism, a system can efficiently utilize its computing resources to handle requests, often achieving the same results with less CPU usage.

Concurrency is a characteristic of programming languages, but the actual implementation of a language determines how parallelism is achieved. It is possible to have a language that supports concurrency without parallelism. Understanding the distinction between concurrency and parallelism is, therefore, crucial in designing efficient and high-performing systems.

Now, let's first look at both concurrency and parallelism in the context of Ruby.

Ruby’s Concurrency Model

Ruby employs threads to enable concurrency in its programming model. Using Thread.new in Ruby allows a block of code to run concurrently with other code within the same process. The execution order of concurrent code can vary, as demonstrated in a simple example where Thread.new is used to print characters. Running this code multiple times will showcase the changing order of prints.

threads = []

threads << Thread.new { puts "a" }
threads << Thread.new { puts "b" }

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

threads = []

threads << Thread.new { puts "a" }
threads << Thread.new { puts "b" }

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

When you run this code multiple times, you may observe that the order of output for "a" and "b" can vary due to the concurrent execution of threads.

One notable aspect of Ruby's concurrency model is that Ruby threads share memory. This means that modifying a variable in one thread will reflect the change when accessing that variable from another thread. While this feature is not inherently problematic, it can lead to race conditions, a common class of programming bugs.

To address race conditions in Ruby, developers often resort to using Thread::Mutex to coordinate memory access among different threads. However, shared memory poses risks, especially in multi-tenant SaaS environments where a bug could potentially result in data being accessed by incorrect users within an application.

As systems scale, concurrency in Ruby can become intricate, prompting many Ruby developers to avoid using threads altogether. Instead, a common practice is to execute tasks sequentially in a single thread. Recent developments in Ruby have introduced new options for achieving true parallelism, offering exciting prospects for enhancing performance. These advancements in parallelism will be explored further in the subsequent sections.

Parallelism in Ruby

Today, there are various Ruby implementations to choose from, with MRI Ruby being the most prevalent. This section will concentrate on MRI Ruby due to its widespread adoption.

In a single process, MRI Ruby does not support parallelism because of the Global Virtual Machine Lock (GVL), which restricts multiple threads from executing concurrently within a process. This limitation arises from the Ruby Virtual Machine not being internally thread-safe, making parallel execution undesirable.

To work around this limitation in Ruby, a common approach has been to fork multiple Ruby processes. Popular libraries like Puma and Sidekiq adopt this strategy for achieving parallelism, alongside utilizing multi-threading to enhance concurrency. Each forked process operates independently with its own GVL, enabling it to run in parallel. However, this approach results in increased memory requirements for each process, necessitating machines with higher RAM capacity to support this practice effectively.

While MRI Ruby remains the dominant implementation, other alternatives such as JRuby and TruffleRuby strive to offer comprehensive implementations of Ruby on virtual machines that facilitate parallel execution. Despite their potential benefits, these alternative implementations may present compatibility issues and trade-offs, which have hindered their widespread adoption.

The New Kid On The Block - Ruby Ractors

Ruby Ractors introduces a novel actor-model abstraction that offers both thread-safe concurrency and parallel execution capabilities within Ruby. This development is quite thrilling as it brings a fresh perspective to Ruby's concurrency landscape. While still relatively new, the potential of Ruby Ractors holds significant promise for the future of Ruby programming.

One distinguishing feature of Ractors is that they do not share memory, limiting the memory access that they exclusively own. Communication between Ractors occurs through message passing, where messages are sent, received, and processed accordingly.

The concept of Ruby Ractors bears a striking resemblance to Elixir's concurrency model, showcasing similarities in their approach to handling concurrency. This parallelism between Ruby Ractors and Elixir's concurrency model sets the stage for further exploration into the intricacies of these innovative concurrency paradigms.

Elixir's Concurrency Model

Elixir's Concurrency Model Elixir leverages processes to enable concurrency, although these processes are not traditional operating system processes. Instead, they are lightweight virtual processes implemented by the BEAM (Erlang Virtual Machine). The BEAM's process implementation serves as the cornerstone for all code execution within Elixir.

In Elixir, processes operate in isolation and do not share memory with each other; they can only access their own allocated memory space. Communication between processes is achieved through message passing, where processes send and receive messages to coordinate their tasks. Messages are stored in a process mailbox, a mechanism that closely resembles the behavior of Ractors in Ruby, highlighting the actor-based nature of both the BEAM and Ractors.

Actors represent the fundamental unit of concurrency in actor-based programming paradigms. Each actor receives messages from other actors, processes these messages, and can respond to messages, send messages to other actors, or execute local code. This aligns closely with how Elixir's processes operate.

In Elixir, a process executes its tasks by retrieving messages from its mailbox in the order they were received. The process then executes each message sequentially, following a message-driven execution model. Processes in Elixir can operate indefinitely, continuously awaiting new messages, or they can be configured to handle a specific number of messages before completion.

Notably, a process in Elixir can only handle one message at a time, ensuring that there is no internal concurrency within a single process. This aspect of Elixir's concurrency model provides developers with precise control over whether code runs in parallel or sequentially, offering a level of control that is often overlooked but valuable in designing robust and efficient concurrent systems.

Parallelism in Elixir

Parallelism in Elixir operates on the premise that even within individual processes where there is no inherent concurrency, the BEAM can still execute code in parallel by leveraging the capability to run tens of thousands of processes concurrently. This parallel execution is facilitated by the BEAM's scheduler mechanism, which orchestrates and executes functions on the CPU.

In the BEAM runtime environment, there is typically one scheduler allocated for each logical CPU processor available. For instance, a quad-core hyper-threaded CPU would have eight schedulers, each equipped with a run queue that manages the order of process execution requests.

Schedulers in Elixir execute functions until the completion of a process or until a specified amount of work, known as reductions, has been performed. When a process reaches its reduction limit, it is removed from the scheduler and placed at the end of the run queue. This design ensures that even if a process enters an infinite loop, it will only occupy a CPU for a fixed number of reductions at a time. This characteristic is advantageous for building scalable systems as it prevents a high-CPU request from significantly impacting other requests on the server.

The inherent scalability of Elixir across CPU cores is achieved effortlessly by utilizing processes effectively. Major libraries in Elixir are designed to leverage processes in an optimal manner to ensure the successful implementation of scalable and efficient systems.

This has been a rather long post about concurrency and parallelism in Ruby vs Elixir. In my next post, I will dive deeper into similarly important concepts such as Elixir processes, garbage collection, and GenServers.