Ractors don't work in Rails, and Fibers nearly do
Ruby gives you three ways to do more than one thing at a time, and the Rails answer for two of them
is not what the release notes implied. One of them can't run Rails.env. The other works, and has
a default that quietly undoes the reason you reached for it.
Measured on Ruby 4.0.5 and Rails 8.1.4.
A Ractor can't read Rails.env
Not a query, not a model. The environment.
Ractor.new { 1 + 1 }.value # => 2
Ractor.new { Float::INFINITY }.value # => Infinity
Ractor.new { Rails.env.to_s }.value # => Ractor::RemoteError
Unwrap the remote error and the cause is specific:
Ractor::IsolationError
can not get unshareable values from instance variables of classes/modules
from non-main Ractors (@_env from Rails)
gems/railties-8.1.4/lib/rails.rb:75:in 'Rails.env'
Rails.env memoises into @_env on the Rails module, and a non-main Ractor isn't allowed to
read an unshareable instance variable off a class or module. That's the rule, and Rails breaks it
on line 75 of its main file.
Go one layer in and it's the same shape:
Post.count
Ractor::IsolationError (@relation_delegate_cache from Post)
gems/activerecord-8.1.4/lib/active_record/relation/delegation.rb:28
Active Record caches the generated relation subclass per model, on the model. Perfectly reasonable design, and unreachable from a Ractor.
So the honest answer to "can I use Ractors in Rails" is no, and not in a way a workaround fixes. This isn't one unlucky gem. Memoising into class-level state is how Rails is built, and a Ractor is defined by not being allowed to see it.
One detail worth having if you go looking: Ractor.shareable?(Post) is true. The class object
itself is fine. It's the memoised state hanging off it that isn't, which is why the failure
happens on first use rather than on reference.
And two things changed in Ruby 4.0 that will bite anyone copying an old snippet. Ractor#take is
gone, respond_to?(:take) returns false, and the method is #value. The API also still announces
itself as experimental on first use, which after this many years is itself information.
Fibers work, which is the useful half
Fiber.new { Post.count }.resume # => 0
No error, no configuration, nothing special. Fibers share the thread, the GVL and the whole object space, so Rails has no reason to object.
That's the basis for fiber-scheduled servers like Falcon, which run many fibers on one thread and swap between them whenever one blocks on IO. The appeal is real: a thread that spends its life waiting on Postgres can be a hundred fibers doing the same waiting, without a hundred threads' worth of stack.
The catch is one line of configuration, and the default is wrong for that use.
The default that shares your connection
ActiveSupport::IsolatedExecutionState.isolation_level # => :thread
main = ActiveRecord::Base.lease_connection.object_id
Fiber.new { ActiveRecord::Base.lease_connection.object_id }.resume == main
# => true
At :thread, which is the default, everything Rails keeps per execution context is keyed to the
thread. The current connection, Current attributes, the query cache, the logger's tags. So a
Fiber inherits all of it, and two fibers on one thread are two pieces of code sharing one database
connection with no coordination.
For a Fiber you spawned yourself to do one small thing, that's fine and often what you want. For a server running hundreds of them concurrently it is not, and the fix is the setting that exists precisely for this:
ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
# the same comparison now returns false
Each fiber gets its own connection, its own Current, its own everything. Which also means your
connection pool now needs to be sized against concurrent fibers rather than threads, and that
number is much larger. Falcon's documentation covers the sizing; the point here is that the
setting is not optional and nothing warns you.
What to actually reach for
Threads, almost always. Puma's default of three per process is the shape Rails is built for, the GVL releases on IO so they genuinely overlap on database calls, and nothing about the framework fights you.
Fibers when you're running a fiber-scheduled server on purpose and have set the isolation level and sized the pool. That's a deliberate architecture, not a tweak.
Ractors for the thing in your codebase that isn't Rails at all. A pure computation over data you can freeze, called from a job, that returns a value. That works today and is a narrow enough target that it's rarely worth the API churn.
What you should not do is wait for Rails to become Ractor-safe before planning anything. The blocker is structural and sits in the first line of the framework you touch.
What this page doesn't cover
No Falcon. Everything here ran in a console and a test, so there's no measurement of a fiber-scheduled server under load, no comparison against Puma, and no number for what the isolation level costs or saves in a real request cycle.
Nothing here touches Async or the gem ecosystem around it, nor the fiber scheduler interface
itself, which is the thing that makes a blocking call yield instead of waiting and is the actual
mechanism underneath all of this. And the Ractor findings are a snapshot of Rails 8.1.4: the
errors above are two specific memoisations, and fixing those two would only move the failure to
the third.
Comments
No comments yet. Be the first.