Concurrent Ruby - Hello, Async

Over the last few years, I have been fiddling on-and-off with Elixir and have loved it. One of the reasons there is so much hype around Elixir is the out-of-the-box support for writing highly concurrent code. The concurrency model is a native part of the language and comes from the Erlang VM which Elixir runs on.

Even though I'm working primarily in Ruby, I don't want to give up on the benefits of concurrency I've seen in Elixir. While I don't expect to see an Elixir level of concurrency support, I believe Ruby has viable options.

One option I found is the concurrent-ruby gem. From the gem's description:

Modern concurrency tools including agents, futures, promises, thread pools, supervisors, and more. Inspired by Erlang, Clojure, Scala, Go, Java, JavaScript, and classic concurrency patterns.

Having multiple concurrency paradigms provides the opportunity to pick-and-choose based on needs. It also provides the opportunity to learn about the various methods of concurrency all in one place.

Choosing where to start

I thought a good place to start would be to simply pick a concurrency model and write some code. The first option listed in the README is their Async module. This concurrency model also happens to be inspired by Erlang's (and therefore Elixir's) gen_server, so it was the perfect place to start.

In Elixir/Erlang, a gen_server would run in a new Erlang process (insert disclaimer about Erlang's processes not being Operating System processes here). This allows it to run independently of the caller's process. concurrent-ruby provides similar functionality with Ruby's Thread.

When included in a class, Async adds two new methods to the class that can be used as proxies. When used, these methods will "wrap" the method you are calling. By wrapping the method, the proxies will handle the setup for running in a separate thread. These new methods are (1) async which will immediately return to the caller while continuing the work in the new thread and (2) await which will also do the work in the other thread, but, when called, will block (or (a)wait) the main thread until the method finishes and returns.

If you are familiar with the Erlang terms, you can also use cast (async) and call (await) instead.

Starting with an example

I decided to start with the example they have in the docs:

require 'concurrent-ruby'

class Hello
  include Concurrent::Async

  def hello(name)
    "Hello, #{name}!"
  end
end

Starting out in irb I was able to make the following calls.

Await Version

> Hello.new.await.hello("world")

=> #<Concurrent::IVar:0x00007fb62e8d5ec8
 @__Condition__=#<Thread::ConditionVariable:0x00007fb62e8d5e28>,
 @__Lock__=#<Thread::Mutex:0x00007fb62e8d5e50>,
 @copy_on_deref=nil,
 @do_nothing_on_deref=true,
 @dup_on_deref=nil,
 @event=
  #<Concurrent::Event:0x00007fb62e8d5db0
   @__Condition__=#<Thread::ConditionVariable:0x00007fb62e8d58d8>,
   @__Lock__=#<Thread::Mutex:0x00007fb62e8d5d38>,
   @iteration=0,
   @set=true>,
 @freeze_on_deref=nil,
 @observers=
  #<Concurrent::Collection::CopyOnWriteObserverSet:0x00007fb62e8d5888
   @__Condition__=#<Thread::ConditionVariable:0x00007fb62e8d57c0>,
   @__Lock__=#<Thread::Mutex:0x00007fb62e8d5838>,
   @observers={}>,
 @reason=nil,
 @state=:fulfilled,
 @value="Hello, world!">

Async Version

> Hello.new.async.hello("world")

=> #<Concurrent::IVar:0x00007fe09b08b230 
@__Lock__=#<Thread::Mutex:0x00007fe09b08b1b8>, 
@__Condition__=#<Thread::ConditionVariable:0x00007fe09b08b190>, 
@event=
 #<Concurrent::Event:0x00007fe09b08b118 
  @__Lock__=#<Thread::Mutex:0x00007fe09b08b0a0>, 
  @__Condition__=#<Thread::ConditionVariable:0x00007fe09b08b078>, 
  @set=false, 
  @iteration=0>, 
@reason=nil, 
@observers=
 #<Concurrent::Collection::CopyOnWriteObserverSet:0x00007fe09b08b028 
   @__Lock__=#<Thread::Mutex:0x00007fe09b08afd8>, 
   @__Condition__=#<Thread::ConditionVariable:0x00007fe09b08afb0>, 
   @observers={}>, 
@dup_on_deref=nil, 
@freeze_on_deref=nil, 
@copy_on_deref=nil, 
@do_nothing_on_deref=true, 
@state=:pending,
@value=nil>

Return Types

The hello method simply returns a string. This is fast enough that I didn't notice a speed difference between the two calls. I did, however, notice a difference in what is being returned. Proxying through async and await result in method calls returning a Concurrent::IVar instead of the original method's "raw" response.

From the docs:

An IVar is like a future that you can assign. As a future is a value that is being computed that you can wait on, an IVar is a value that is waiting to be assigned, that you can wait on. IVars are single assignment and deterministic.

Without digging too much into IVars at this point, a few things stick out.

In the await version, the IVar has a @state of :fulfilled. We also have a @value of "Hello, world!" (the method's return value). Intuitively, this makes sense - with await we wait for the method to finish before continuing. By finishing the method call, the work has been "fulfilled" and we know what our return value is.

Contrast this with the result of the async version. The async call returns an IVar with a @state of :pending and a @value of nil. The async version returns without waiting for the method to run. This means we are still waiting for our results.

For now, I am going to assume this is similar enough to JavaScript's Promise that I can continue to focus on the Async module and learn more about IVars at a later time.

Is anything happening?

At this point, things seem to be working. I am getting back IVars instead of the string the hello method returns, so the Async module is doing something. However, the await version runs so fast that it doesn't seem like the methods are performing any differently.

Often times, you would move work into another thread if it's slow and gets in the way of your main thread. To replicate this in my testing, I decided to fake working hard by sleeping. I also decided to print the "hello" string instead of returning it. This allows me to avoid dealing with thinking about IVars during my initial exploration. Printing gives me a visual indication the methods are running without having to worry about what is being returned.

Now, my test class looks something like:

require 'concurrent-ruby'

class Hello
  include Concurrent::Async

  def hello(name)
    sleep(3) 
    puts "Hello, #{name}!"
  end
end

After reloading this class into irb, I now have:

  • A visual indication of when the method has run from the puts statement.
  • A method that takes longer to run as a result of the call to sleep.

Await Version

When proxying through await, the call to sleep in the hello method is blocking. This means that, even though it's running in a separate thread, I have to wait for the sleep to finish before the main thread (the irb session in this case) will respond:

Async Version

With async, the method returns right away and the main thread continues to run on its own. This means I can continue to interact with the main irb thread while waiting for my result to print:

Conclusion

While we haven't done much, we now have a class that provides us with async and await proxy methods. We were also able to make the difference in behavior between these two proxy methods more obvious through the use of puts and sleep.

This is not even scratching the surface of the Async module, let alone the entire concurrent-ruby gem. What this does provide, however, is a great jumping-off point to explore the Async module further in the future.


Notice something wrong? Please consider proposing an edit or opening an issue.