Ractors in Ruby: An Overview and Exploratory Guide

Sophia

4 min read

Hi Everyone, Hope all are doing well! I am glad to share this blog on basic concepts about Ractors in Ruby – what it is and how it works. Let’s jump on it.

Ractor, an actor-model abstraction, provides a parallel execution capability that eliminates thread-safety concerns. It facilitates faster and more efficient management and execution of Ruby code for thread-safe workers. Ruby 3 has introduced Ractor as an experimental feature.

This explanation makes it difficult to understand, so let’s do a little background research on Ractors and get a better understanding.

Background concepts:

Concurrent Execution

Parallel Execution

Threads

Thread Security issues

Concurrent Execution

When two or more tasks start, run and get completed in the overlapped time period, it is concurrent execution. It doesn’t mean they’ll be running simultaneously.

For example, let’s take a scenario where you must complete two tasks in one day. When the first task starts, and you decide to execute the second task, the first one will be moved to waiting mode. And once again, when you resume the first task, the other one will be in waiting mode. So, in the end, both tasks get executed separately. It is like multitasking on a single-core machine.

Parallel Execution

Parallel execution is when tasks run simultaneously at the same time. 

Again with the same example, two separate people (or threads) are executing these tasks simultaneously. It is like a multi-core processor.

Concurrent Vs Parallel

Now we have some ideas about concurrent and parallel execution, and we will move to the next concept, Thread.

Threads

In Ruby, a concurrent program model gets achieved through threads. With the help of the Thread class, writing multi-threaded programs becomes easy. Ruby threads are lightweight and efficient and achieve concurrency in your code. One can create independency in the functioning of a new thread from its main one using ::new. Here, the main thread is paused for a while to execute the new one. 

However, running multiple threads in parallel is impossible due to the limitations GIL (Global Interpreter Lock) imposes. 

What is Global Interpreter Lock?

As defined in Wikipedia, A global interpreter lock (GIL) is a mechanism used in computer-language interpreters to synchronize the execution of threads so that only one thread can execute at a time. An interpreter that uses GIL always allows exactly one thread to execute at a time, even if run on a multi-core processor.

So Ruby MRI has GIL that allows one thread execution at a time to avoid thread-safe issues.

Threads safe issues

When multiple threads can read and write on the same data structure/object simultaneously, it opens the door for incorrect or data inconsistency. So GIL prevents this by allowing the execution of one thread at a time. We can see a few thread-safe issues below.

Race Condition

When the timing or order of events affects the correctness of a piece of code, it results in race conditions.

Data Race 

When one thread accesses a mutable object while another thread is writing to it; a data race happens. 

So here we see that Ruby Thread supports concurrency and not parallelism because of the thread safety issues, and GIT controls the single thread execution at the time. Now we will continue how Ractor helps us to achieve parallelism.

What is Ractors?

As mentioned earlier in this blog, Ractor is an actor-model abstraction that paves the way to perform parallel execution without worrying about the thread’s safety. 

How Ractor achieves this parallelism? Let’s find out!

Ractor achieves parallelism through the following:

  • A process can have one or more ractors. Each reactor has at least one thread and a Global Virtual Machine Lock (GVL) that allows multiple ractors to run in parallel. ( remember, threads in a ractor can not run in parallel).
  • Each ractor maintains a private state and uses message sharing for communication.
  • Each Ractor has an incoming port and an outgoing port. The incoming port is connected to the infinite-sized incoming queue.
  • One of the challenges in thread safety is handling data in between threads. Ractor maintains Limited object sharing for Shareable & Unshareable Objects for handling this challenge.
  • Building ractors network with push/pull type communication.

Ractor Creation

We can create our first ractor by using code Ractor.new { expr }

Limited object sharing / Shareable & Unshareable Objects

Limited object sharing has two types of objects, Shareable and Unshareable Objects.

Shareable Objects

Shareable objects are ones that get used by several threads without compromising thread safety. Shareable objects are frozen objects, so they are unchangeable. Shareable objects = immutable objects = Frozen objects. We can check whether the object is shareable by using the below methods:

  • Ractor.shareable? – allows checking the shareable objects
  • Ractor.make_shareable – tries to make an object shareable if it is not

Unshareable Objects

Unshareable Objects are not frozen objects but, when not handled with care, can cause safety issues. For this purpose, it introduces two different ways to pass the unshareable objects.

Copy Object

By default, it makes the object’s full copy by deep cloning non-shareable parts of its structure.

Here the first value in the data array is unshareable data, so the object_id changes when we pass the value via copy method.

Move Object

Receiving ractor receives the object; it becomes inaccessible for the sending ractor.

Here it throws an error while trying to access a moved object.

Ractors Communications

These sections show how the communications between the ractors work. 

We can make a program with the multi-ractor network, each reactor will wait for message arrival, and we can achieve the program control flow by passing data.

There are two types of communication.

  • Push Type Communication (Ractor#send / Ractor.receive)
  • Pull Type Communication (Ractor.yield / Ractor#take)

Push Type Communication

  • Ractor#send(obj) (Ractor#<<(obj) is an alias) sends a message to the Ractor’s incoming port. The incoming port is connected to the infinite-size incoming queue.
  • Ractor.receive dequeue a message from its own incoming queue.
  • The sender knows the receiver (push). The sender pushes data to the receiver, so it is called push-type communication.

Pull Type Communication

  • Ractor.yield(obj) sends a message to a Ractor calling Ractor#take via the outgoing port.
  • Ractor#take receives a message waiting by Ractor.yield(obj) method from the specified Ractor.
  • The receiver knows the sender; it pulls the data from the sender. And this is called pull-type communication.

Ractors with push & pull type communication

We can make ractors with both push and pull type communications.

Conclusion

Ruby programs can run in parallel with Ractor without thread safety headaches. Ractor API and implementation need time to get matured. I hope the future version of Ruby will enhance the Ractor feature and make it permanent.

Related posts:

Leave a Reply

Your email address will not be published. Required fields are marked *