Use the ractorize Gem to Easily Turn Existing Ruby Objects into Ractors Whilst Preserving Their Interface!

· Miles Georgi

Hey hey! You can use the ractorize gem to turn any object into a ractor while preserving its existing interface.

This means we can solve some problems we might solve by introducing the Ractor class by instead applying Ractorize to existing code. Ractor can be used to 1) protect critical sections and avoid race-conditions, and 2) in CRuby, provide true concurrency.

<!-- TOC --> <!-- TOC -->

Eliminating a Race-Condition Using Ractorize

Take a peek at this BankAccount class:

class BankAccount def initialize(balance = 0) = @balance = balance attr_reader :balance def deposit(amount) current_balance = @balance current_balance += amount @balance = current_balance end def withdraw(amount) current_balance = @balance sleep 0.01 current_balance -= amount @balance = current_balance end end

It doesn't even bother to protect its critical sections. That's all well and good if there's no concurrency.

But if there were?

Let's add some!

scheduler = Async::Scheduler.new Fiber.set_scheduler(scheduler) account = BankAccount.new(0) Fiber.schedule { account.withdraw(1) } Fiber.schedule { account.deposit(1_000_000) } scheduler.run puts account.balance

We've added a fiber scheduler and we're also performing a couple operations asynchronously on our bank account: withdrawing $1 and depositing $1,000,000. We should have $999,999 when the dust settles.

What do we actually get?

-1

Whaaaaat?? -$1? A race-condition cost us a million bucks!

We can fix this race-condition with the ractorize gem with a couple tweaks:

require "ractorize" # ... account = Ractorize[BankAccount.new(0)] # ...

OK now what do we get?

999999

Yay! Every dollar accounted for!

Additional thoughts

Why did I use a fiber scheduler instead of threads?

Because I think it makes an interesting point: if we had used Fiber directly, there would have been no race-condition at all. Introducing a fiber scheduler introduced a race-condition the way using Thread would have. That is: using fibers directly and using a fiber scheduler directly are NOT the same type of concurrency! I view fiber schedulers as being in the same concurrency bucket as threads, not direct fiber use!

Improving Performance Using Ractorize

Let's first implement an Integer#factorial:

class Integer def factorial raise ArgumentError if negative? return 1 if self <= 1 (2..self).to_a.inject(:*) end end puts 300_000.factorial.digits.size
$ time ./factorial                                                                                                                                            
1512852

real    0m27.764s

Alrighty, ~28 seconds. Before we bring ractors into the picture, we need to batch up this work:

class Integer def factorial raise ArgumentError if negative? return 1 if self <= 1 batch_count = 4 batches = (2..self).each_slice(self / batch_count).to_a products = batches.map { it.inject(:*) } products.inject(:*) end end puts 300_000.factorial.digits.size

Now an interesting fact... even though we haven't added ractors yet, batching this up improves performance, anyways!

Check it out:

$ time ./factorial                                                                                                                                            
1512852

real    0m12.762s

Whoa! But how??

It's because these integers get big fast. Once they cannot fit in 62 bits, Ruby has to start using more regions of memory to contain the integer's data. Multiplying a number against these now requires traversing the integer's data and performing many multiplication operations.

A good reminder that you need to benchmark and/or profile your code if you actually want to know what its performance will be!

OK well let's see if we can improve it further now with true concurrency using Ractorize.

class Integer def factorial raise ArgumentError if negative? return 1 if self <= 1 batch_count = 4 batches = (2..self).each_slice(self / batch_count).to_a batches.map! { Ractorize[it] } products = batches.map { it.inject(:*) } products.inject(:*) end end puts 300_000.factorial.digits.size

We added the batches.map! { Ractorize[it] } line which converts all of the batches into ractors.

Let's see the result:

$ time ./factorial 
1512852

real    0m5.328s

Cool! We got an additional 2x+ boost from Ractorize!

Caveats

As already mentioned, you really ought to profile and/or benchmark performance improvements you attempt. It's often hard to predict which attempts at a performance improvement will actually pay off in a given context. Sometimes they actually hurt performance!

Ractors have a decent amount of overhead when it comes to passing around objects. It's pretty easy actually to construct a situation where adding ractors, either directly or indirectly via Ractorize, results in performance degradation instead of improvement, sometimes severely! Don't assume that using more than 1 CPU will improve performance! That's just one variable of a gazillion!

Using Ractorize for a More-Familiar Message-Passing Mental Model

We've been using Ractorize to get the benefits of a ractor without actually writing a ractor. We should take a peek at a Ractor and see how this relates to a ractorized object. We'll also cover some gotchas just in case you think you can drop in Ractorize willy-nilly and be happy with the result.

Typical Ractor Anatomy

multiply_and_add = Ractor.new do product = 1 sum = 0 loop do case receive in :add, number sum += number in :multiply, number product *= number in :add_and_multiply, number sum += number product *= number in :sum, return_port return_port.send(sum) in :product, return_port return_port.send(product) in :stop break end end [product, sum] end

Let's point out its anatomy:

  • Private state is stored as local variables in the block passed to Ractor.new (ractors cannot use their own instance variables!).
  • This state is initialized in code before the loop.
  • We then have a loop.
  • In the loop we will call receive and branch on what we get to handle different types of messages.
  • Finally, a result for the ractor to evaluate.

Here's what invoking the operations in the ractor would look like:

multiply_and_add.send([:multiply, 2]) multiply_and_add.send([:multiply, 3]) multiply_and_add.send([:add, 4]) multiply_and_add.send([:add, 5]) multiply_and_add.send([:add_and_multiply, 6]) port = Ractor::Port.new multiply_and_add.send([:product, port]) puts "The product is #{port.receive}" multiply_and_add.send([:sum, port]) puts "The sum is #{port.receive}"

This outputs:

The product is 36
The sum is 15

We did 2 * 3 * 6 = 36 and 4 + 5 + 6 = 15.

The Already-Existing Paradigm for Sending Messages

But gosh... we're sending messages to the ractor... and Ruby already has a message-passing paradigm.

That makes me wish I was just using that paradigm and writing:

class MultiplyAndAdd attr_accessor :product, :sum def initialize self.product = 1 self.sum = 0 end def add(number) = self.sum += number def multiply(number) = self.product *= number def add_and_multiply(number) add(number) multiply(number) end end multiply_and_add = MultiplyAndAdd.new multiply_and_add.multiply(2) multiply_and_add.multiply(3) multiply_and_add.add(4) multiply_and_add.add(5) multiply_and_add.add_and_multiply(6) puts "The product is #{multiply_and_add.product}" puts "The sum is #{multiply_and_add.sum}"

No loop. No case statement. No exchanging ports. And you might say "Yeah, but, now it doesn't run in parallel."

But it can!

Enter Ractorize

We can ractorize our object by simply passing it to Ractorize.[]:

require "ractorize" # ... multiply_and_add = Ractorize[MultiplyAndAdd.new] # ...

And boom! Now it's a ractor and runs concurrently just like in the Ractor.new version!

Yet it still honors its original interface!

Caveats

Truthy/Falsey

Methods called on a ractorized object return thunks. These thunks, by virtue of not being an instance of FalseClass or NilClass are always truthy. These thunks do have special handling of methods like !, ==, #whatever? to block and resolve the thunk and return the real value so these work just fine. But if you use a thunk directly in a control-flow statement like if/unless/while/until/when/etc then you will always get truthy behavior even if the thunk would eventually resolve as nil or false!

What can I say... scary things can happen when you play with magic!

Increased Risk of Deadlocks when Using Actors

With normal method invocation, which is what Kernel#send invocation is, it's fine for objects with bidirectional dependencies to call methods on one another. A's method pushes a frame on the stack, A's call to B pushes a frame on the stack, B's call to A pushes a frame on the stack, this frame finishes and is popped, and so on, until all frames have popped and we get the final answer.

However, when using actors (and hence ractors), if these operations are synchronous and need to block on one another, it's easy to create a situation where A will block on a response from B and therefore never even pull B's message out of its "mailbox."

Fin.

Thanks for reading! If you want help with ractorize of course feel free to hit me up!

https://github.com/ractor-shack/ractorize