Using concurrent-ruby in a Rails application

concurrent-ruby Apr 18, 2021

Concurrency in Ruby is still a gray zone for many developers. If you know anything about concurrency, you probably know that Ruby is thread-safe by default because the Global VM Lock only uses one thread and you are bounded by it. But does that mean that Ruby can't scale?

GVL

Ruby processes are sent to GVL where they acquire a lock or wait for one. Each Ruby process can have one (or more) threads. It is safe to say then that the GVL is a process-based lock, not a thread-based lock.

Because the lock is acquired only when we are running Ruby code, other tasks that do not require Ruby (database writes/reads, call to external services, etc) can be parallelized.

concurrent-ruby

concurrent-ruby is a gem that brings the concepts of modern concurrency tools to Ruby. It lends ideas from languages like Erlang, Clojure, Scala, and Go but in a way that makes sense for Ruby.

One implementation that I like a lot is the concept of Future: an action that happens in the future, where I don't need the result right now but I might be able to check on its status until then.

Using it is pretty straight forward:

count = Concurrent::Future.execute { sleep(10); 42 }
count.state #=> :pending
count.pending? #=> true

count.value #=> 42 (after blocking)
count.rejected? #=> false
count.reason #=> nil

The code passed on the block will run in parallel and we can query the state of the future (pending for 10 seconds) on the main thread without blocking it.

It works fine with Ruby but when using it in a Rails application, things get interesting.

Rails' Executor

Rails have its own set of threads and usually, we don't have to care about them because Rails takes care of everything through the Executor.


The Executor separates the framework code and the application code and every time Rails invokes your application code it will wrap in the Executor, where everything will be taking care of: database connections, constants loading, and so on. But when you start adding Futures to your application, it can be a problem, at least in the development environment.

If you write a thread that will execute your application code, you need to wrap around the executor:

Thread.new do
  Rails.application.executor.wrap do
    # your code here
  end
end

The executor will take care of putting your process/thread on the global VM. This blocks and waits if other threads are autoloading/unloading or reloading the application.

Wrapping the thread on the executor makes sure that you won't have any problem with unloaded constants (You won't see errors like Unable to autoload constant User, expected ../user.rb to define it (LoadError).

permit-concurrent-loads

According to the Rails documentation, the Executor will acquire a lock and put the process into running state but in the meantime, other processes might have acquired a lock too and require autoloading of classes. If this happens, you have a deadlock in your hands. Assuming that User class is not loaded, this will cause a deadlock:

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # inner thread waits here; it cannot load
           # User while another thread is running
    end
  end

  th.join # outer thread waits here, holding 'running' lock
end

To avoid this, the thread should use permit_concurrent_loads: It guarantees that it won't unload any already-loaded constants inside the block. You should put it as close as possible to the blocking call:

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # inner thread can acquire the 'load' lock,
           # load User, and continue
    end
  end

  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    th.join # outer thread waits here, but has no lock
  end
end

Plugging Future

The code above can be adapted to use Concurrent::Future as well:

Rails.application.executor.wrap do
  futures = 3.times.collect do |i|
    Concurrent::Future.execute do
      Rails.application.executor.wrap do
        # Your code here
      end
    end
  end

  values = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    futures.collect(&:value)
  end
end

By doing this, you can safely parallelize your code without having to care about Rails at all.

To help me do this I created a small gem that abstracts most of these configurations and adds some more sugar to them. ConcurrentRails is a wrap on top of Future that allows you to use Futures pretty much the same way as the documentation says but without the burden of configuring Rails Executor.

Give it a try, let me know what you think!

Sources:

The Practical Effects of the GVL on Scaling in Ruby

Threading and code execution in Rails

Future

Tags

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.