Using concurrent-ruby in a Rails application
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