DEV Community

Cover image for Learn about Ractors and build a mini sidekiq
Alexandre Ignjatovic for Doctolib Engineering

Posted on • Updated on

Learn about Ractors and build a mini sidekiq

In this article, you'll learn more about Ractor, and how you can use them to build your own clone of sidekiq (a background processing framework for Ruby).

What is Ractor?

Ruby 3.0 introduced the Ractor class. This is Ruby's Actor-like concurrent abstraction, and its goal is to provide a parallel execution feature of Ruby without thread-safety concerns.

According to wikipedia:

The actor model in computer science is a mathematical model of concurrent computation that treats actor as the universal primitive of concurrent computation.

Actors are able to:

  1. Create more actors
  2. Receive messages
  3. Send messages
  4. Take local decisions

Be aware that the Ractor implementation is not stable yet, do NOT use them for production code. See the warning displayed when you use them if you still need to be convinced to not use it (yet) in production:

warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.

Creating a ractor

Creating a ractor is as simple as using Ractor.new:

ractor = Ractor.new { puts 'Hello Ractor!' }
Enter fullscreen mode Exit fullscreen mode

Receiving a message

There are two ways to receive a message, depending if you have a reference on the ractor sending it.

Use Ractor.receive if you don't know who is sending the message:

message = Ractor.receive
Enter fullscreen mode Exit fullscreen mode

And use Ractor#take if you have a reference on the ractor sending the message:

message = ractor.take
Enter fullscreen mode Exit fullscreen mode

As those method calls are blocking until you receive a message, avoid expecting a message from a ractor that will never send one, or your program is going to be stuck forever.

Also, please be aware that the objects you are sending must be shareable.

Sending a message

If you know your recipient:

ractor.send(message)
# or
ractor << message # `<<` is an alias to `send`
Enter fullscreen mode Exit fullscreen mode

And if you don't:

Ractor.yield(message)
Enter fullscreen mode Exit fullscreen mode

Ractor.yield is blocking as well until some ractor receive your message.

Taking local decision

You can do pretty much anything you want in the block given to Ractor.new. Unless accessing shared objects. An exception will be raised if you try to use a variable defined outside of your block. In order to share a variable between several ractors, you can, for example, use the Ractor::TVar gem.

Another interesting method I'm going to use later on is the Ractor.select(*actors) method. It takes several ractors as an input, and returns the first ractor to send something, and its output:

slow_ractor = Ractor.new { sleep 2; Ractor.yield(:too_late) }
fast_ractor = Ractor.new { Ractor.yield(:fast) }
ractor, output = Ractor.select(slow_ractor, fast_ractor)
# output == :fast && ractor == fast_ractor
Enter fullscreen mode Exit fullscreen mode

Let's build a mini sidekiq!

Please be aware that this crude POC will only allow you to use a pool of 10 ractors to achieve parallel execution of jobs. It won't handle error flow control, statistics, queueing and all the other features that make sidekiq a super useful project.

We'll build a simple design with:

  • A WorkerPool, taking care of our pool of ractors
  • A Job base class, that all our dedicated jobs will inherit from.

The goal being to allow people to easily implement their own jobs without having to handle all the pool logic.

WorkerPool

class WorkerPool
  attr_reader :ractors

  def initialize
    @ractors = 10.times.map { spawn_worker }
  end

  def spawn_worker
    Ractor.new do
      Ractor.yield(:ready)
      loop { Ractor.yield Job.run(Ractor.receive) }
    end
  end

  def self.run(parameters)
    ractor, _ignored_result =
      Ractor.select(*(@instance ||= new).ractors)
    ractor << parameters
  end
end
Enter fullscreen mode Exit fullscreen mode

There is a trick here. When using Ractor.yield(:ready), we are just making sure that the ractors of the pool have something to send for the initial Ractor.select to work (remember, it's blocking).

Job base class

class Job
  def self.process(*args)
    WorkerPool.run({ class: self, args: args })
  end

  def self.run(hash)
    case hash
      in { class: klass, args: args }
      klass.new.process(*args)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Be aware that anything you will provide as argument must be shareable.

Implementing a specific job

Let's say that we'd like to create an asynchronous job that prints something:

class PrintJob < Job
  def process(message)
    puts message
  end
end
Enter fullscreen mode Exit fullscreen mode

Using it asynchronously is now as simple as:

PrintJob.process('Hello World!')
Enter fullscreen mode Exit fullscreen mode

Conclusion

Ractor introduces a new and interesting model for parallel execution in Ruby.

If you found the ractor topic interesting, I suggest you check out these interesting resources:

And if you liked this post, check out our awesome weekly tech newsletter. We are regularly sharing top ruby & javascript content!

Photo by Mark Thompson

Top comments (0)