LaunchKit
← All posts
· 5 min read · by The LaunchKit team · 1 views

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.

Three pieces of code run inside a Ractor on Ruby 4.0.5 with Rails 8.1.4 loaded. Plain arithmetic returns two and a frozen constant returns its value, so Ractors themselves work. Calling Rails.env raises a Ractor IsolationError naming the instance variable underscore env on the Rails module, at line 75 of railties' rails.rb. Calling Post.count raises the same error one layer deeper, naming the relation delegate cache on the Post class, inside Active Record's delegation.rb. A panel notes that Ractor.shareable? answers true for the Post class object and false for Rails.application, and that the mechanism is Rails memoising into class level instance variables, which a non-main Ractor may not read.

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.

#rails #ruby #performance

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.