DEV Community

Cover image for Threading and Code Execution in Rails: Executor, Reloader, and Load Interlock
Shrouk Abozeid
Shrouk Abozeid

Posted on AI-assisted

Threading and Code Execution in Rails: Executor, Reloader, and Load Interlock

Rails handles concurrency for us in many situations. But what happens when we create our own threads? How does Rails safely reload application code while other threads are running? And what does the Executor have to do with database connections and autoloading?

I recently explored these questions while reading the Rails 8 guides. Here are my notes, with examples to make the concepts easier to understand.

1. How Rails Handles Concurrency

Rails allows different operations to run concurrently.

For example, when using a threaded web server such as Puma, multiple HTTP requests can be processed simultaneously. Active Job can also execute multiple jobs concurrently, depending on the queue adapter and its configuration. Action Cable handles messages using threads as well.

Each thread manages its own unit of work, such as a controller instance or a job instance, but threads share the same process space.

This means they can access shared objects, including:

  • Ruby classes and modules.
  • Application configuration.
  • Global variables.
  • Other shared objects in memory.

Consider two requests arriving at the same time:

class UsersController < ApplicationController
  def show
    @user = User.find(params[:id])
  end
end
Enter fullscreen mode Exit fullscreen mode

Each request gets its own controller instance, so the instance variable @user belongs to that particular instance.

However, both requests use the same User class.

This is usually fine, provided your application doesn't introduce unsafe shared state or modify shared objects in ways that cause race conditions.

Rails provides mechanisms to coordinate application execution with its internal systems. Two important ones are the Executor and the Reloader.

Let's understand why we need them.

2. The Rails Executor

The Executor separates application code from framework code.

Whenever Rails invokes application code through its framework entry points, it wraps that execution in the Executor.

The Executor has two callbacks:

  • to_run: Called before application code executes.
  • to_complete: Called after application code finishes.

Think of the Executor as a boundary around a unit of application work.

Framework invokes application code
               |
               v
        Executor starts
               |
               v
        to_run callbacks
               |
               v
       Application code
               |
               v
       to_complete callbacks
               |
               v
        Execution ends
Enter fullscreen mode Exit fullscreen mode

What does the Executor actually do?

In a default Rails application, its callbacks help Rails:

  • Track which threads are in safe positions for autoloading and reloading.
  • Enable and disable the Active Record query cache.
  • Return acquired Active Record connections to the connection pool.
  • Constrain the lifetimes of internal caches.

Instead of making every component manage these responsibilities independently, Rails provides a common execution boundary.

This is particularly important when application code runs concurrently.

What if we create our own thread?

Rails knows how to wrap application code at its own entry points. But Ruby also lets us create threads directly.

For example:

Thread.new do
  user = User.find(1)
  puts user.name
end
Enter fullscreen mode Exit fullscreen mode

Here, we're executing Rails application code inside a manually created Ruby thread.

That thread hasn't automatically been wrapped in the Rails Executor just because it was created inside a Rails application.

Why does this matter?

The code might access Active Record, reference autoloaded constants, or interact with other Rails-managed resources. Rails needs to coordinate those operations with its execution lifecycle.

The solution is straightforward:

Thread.new do
  Rails.application.executor.wrap do
    user = User.find(1)
    puts user.name
  end
end
Enter fullscreen mode Exit fullscreen mode

Now the application code runs inside the Executor.

The important distinction is that the Executor doesn't create the thread. It manages the Rails execution lifecycle within the thread.

This also applies when using thread pools or concurrency libraries that execute application code on threads you manage.

What if we can't use wrap?

Rails also provides run! and complete! for situations where wrapping a block isn't practical.

Thread.new do
  execution_context = Rails.application.executor.run!

  begin
    # Execute application code here.
    user = User.find(1)
    puts user.name
  ensure
    execution_context.complete! if execution_context
  end
end
Enter fullscreen mode Exit fullscreen mode

run! starts the Executor's execution context, while complete! finishes it.

The ensure clause ensures that completion is attempted even if the application code raises an exception.

For ordinary application code, prefer wrap because it handles the lifecycle for you.

One more detail: the Executor is re-entrant. If it's already active on the current thread, calling wrap again doesn't establish a second independent execution context.

However, each new thread needs its own Executor context when it executes Rails application code.

3. The Rails Reloader

The Executor manages the execution lifecycle, but it doesn't decide whether the application needs to reload its code.

That's where the Reloader comes in.

During development, Rails can reload application classes when their source files change. This lets us modify a model or controller without restarting the server.

For example, suppose we have this model:

class User < ApplicationRecord
  def greeting
    "Hello!"
  end
end
Enter fullscreen mode Exit fullscreen mode

We change the method:

class User < ApplicationRecord
  def greeting
    "Hello from the updated code!"
  end
end
Enter fullscreen mode Exit fullscreen mode

Rails needs to unload the old class and load the updated version.

But what happens if other threads are executing application code while the reload is happening?

Rails needs to coordinate the reload so that other threads don't continue executing against classes that have been unloaded.

The Reloader helps manage this process.

How do we use the Reloader?

It provides a similar block-based interface:

Rails.application.reloader.wrap do
  # Execute application code here.
end
Enter fullscreen mode Exit fullscreen mode

Before entering the block, the Reloader checks whether the application needs to be reloaded.

If a reload is required, it waits until the reload can safely occur, performs it, and then continues with the application code.

The Reloader also invokes the Executor when one isn't already active on the current thread.

Therefore, you don't need to wrap the same code separately in both the Reloader and the Executor.

Executor vs. Reloader

Although they have similar interfaces, they serve different purposes.

Executor Reloader
Manages the execution lifecycle. Coordinates application code reloading.
Runs its execution callbacks. Uses the Executor and coordinates reloading when necessary.
Suitable for application code executed in manually created threads. Suitable for long-running, top-level processes that repeatedly invoke application code and need reload-aware execution.

For example, a long-running process might repeatedly invoke application code while running in development.

In that case, the Reloader can check for code changes before each unit of work.

However, only long-running, top-level processes should invoke the Reloader directly.

Why?

When a reload is required, the Reloader waits until other threads have completed their active Executor executions. Calling it from a child thread while its parent is waiting inside an Executor block can cause a deadlock.

For manually created child threads, use the Executor instead.

4. What Happens During a Reload?

To understand the Reloader more deeply, we need to look at class unloading.

When Rails reloads application code, it unloads autoloaded classes and modules so they can be loaded again.

The Reloader provides two additional callbacks for coordinating operations around this process:

  • before_class_unload
  • after_class_unload

These callbacks are useful when other components need to perform cleanup before classes are unloaded or take action afterward.

For example, Action Cable uses before_class_unload to disconnect existing WebSocket connections before application classes are unloaded.

The exact point at which class unloading happens relative to the Reloader's to_run and to_complete callbacks depends on the config.reload_classes_only_on_change setting.

The important point is that Rails coordinates unloading with application execution rather than allowing classes to be unloaded arbitrarily while other threads are using them.

5. How Rails Uses These Mechanisms

We don't usually need to wrap every controller action or background job ourselves.

Rails already integrates the Executor and Reloader into its framework entry points.

HTTP requests

ActionDispatch::Executor and ActionDispatch::Reloader are Rack middlewares responsible for wrapping requests in the appropriate execution lifecycle.

In a typical development configuration, the Reloader ensures that incoming requests use the latest application code when a reload is needed.

Active Job

Active Job wraps job executions with the Reloader.

This means that when a job is picked up from the queue, Rails can ensure it runs against the appropriate version of the application code.

Action Cable

Action Cable works a little differently because WebSocket connections are long-lived.

A connection is associated with an instance of a connection class. We can't simply reload the application independently for every incoming WebSocket message while keeping the same connection instance.

Instead, Action Cable wraps individual message handlers with the Executor.

When Rails needs to reload application code, Action Cable uses the Reloader's before_class_unload callback to disconnect its existing connections.

The clients can then reconnect automatically and communicate with the updated application code.

This approach allows Rails to reload application classes without requiring every WebSocket connection to close naturally first.

What about production?

The Reloader checks for file changes only when both config.enable_reloading and config.reload_classes_only_on_change are enabled.

These are the defaults in the development environment.

In production, reloading is disabled by default. When config.enable_reloading is false, the Reloader acts as a pass-through to the Executor.

The Executor still has important work to do, including coordinating Active Record connection management.

This is why the Executor remains relevant even when application code reloading is disabled.

6. The Load Interlock

So far, we've seen that Rails needs to coordinate application execution with class loading and unloading.

The Load Interlock is the mechanism that helps make autoloading and reloading safe in a multithreaded environment.

Let's look at the problem it solves.

Why can't two threads load the same class independently?

Suppose two threads encounter a constant that hasn't been loaded yet:

User
Enter fullscreen mode Exit fullscreen mode

Rails needs to load the class definition from the appropriate file.

Imagine another thread tries to access User while that class definition is only partially evaluated.

That thread could observe an inconsistent state.

Similarly, unloading a class while another thread is executing code that uses it can cause problems.

The Load Interlock coordinates these operations.

It tracks threads that are:

  • Running application code.
  • Loading a class.
  • Unloading autoloaded constants.

Only one thread can perform a load or unload at a time, and these operations must wait until other threads have finished running application code.

This ensures that class loading and unloading happen at safe points.

What does the Executor have to do with it?

The Executor automatically acquires a running lock for the duration of its block.

When application code references a constant that needs to be autoloaded, Rails can transition to the appropriate loading state and then return to the running state.

Conceptually:

Executor starts
      |
      v
Acquire running lock
      |
      v
Execute application code
      |
      v
Constant needs autoloading?
      |
      v
Load the class safely
      |
      v
Return to running state
      |
      v
Executor completes
Enter fullscreen mode Exit fullscreen mode

This coordination is particularly important in development, where application classes can be reloaded.

With the default production configuration, reloading is disabled and eager loading is enabled, so Rails doesn't need the Load Interlock for reloading. The Executor still performs its other responsibilities.

7. How permit_concurrent_loads Prevents Deadlocks

This is one of the more subtle parts of the guide.

The Executor holds the running lock while application code executes.

Now imagine one thread starts another thread and waits for it to finish.

Rails.application.executor.wrap do
  thread = Thread.new do
    Rails.application.executor.wrap do
      User
    end
  end

  thread.join
end
Enter fullscreen mode Exit fullscreen mode

Assume User hasn't been loaded yet.

Let's follow what happens.

  1. The outer thread enters the Executor and acquires the running lock.
  2. It creates a child thread.
  3. The child thread enters its own Executor block.
  4. The child tries to reference User, which needs to be autoloaded.
  5. Loading User requires the other thread's application execution to finish.
  6. But the outer thread is waiting for the child to finish using thread.join.
  7. The outer thread is still inside its Executor block, holding the running lock.

Neither thread can make progress.

The outer thread is waiting for the child, while the child is waiting for the outer thread to stop running application code.

That's a deadlock.

The solution

Rails provides permit_concurrent_loads through the Load Interlock.

It allows a thread to release its running lock while waiting for another operation to complete, provided it won't reference any potentially autoloaded constants inside that block.

Here's the revised example:

Rails.application.executor.wrap do
  thread = Thread.new do
    Rails.application.executor.wrap do
      User
    end
  end

  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    thread.join
  end
end
Enter fullscreen mode Exit fullscreen mode

Now the outer thread releases its running lock while waiting for the child thread.

The child can acquire the loading lock, load User, and finish execution.

The outer thread then resumes.

Why must we be careful?

permit_concurrent_loads makes a specific promise: the calling thread won't dereference any potentially autoloaded constant inside the supplied block.

The safest approach is to keep the block as small as possible and place it directly around the blocking operation.

For example, wrapping thread.join is preferable to wrapping a large section of application logic.

Don't use this method as a general-purpose way to disable thread safety. It's intended for situations where a thread must wait for another operation that may need to autoload application code.

8. Practical Takeaways

Here are the rules I took away from reading the Rails guide.

Use the Executor when you create your own threads.

Thread.new do
  Rails.application.executor.wrap do
    # Rails application code
  end
end
Enter fullscreen mode Exit fullscreen mode

This ensures that your code participates in Rails' execution lifecycle.

Use the Reloader only when you need reload-aware execution in a long-running, top-level process.

Rails.application.reloader.wrap do
  # Application code that should run with the latest code
end
Enter fullscreen mode Exit fullscreen mode

Don't call it indiscriminately from child threads.

Understand that Rails already handles its own entry points.

Controller actions, Active Job executions, and Action Cable message handlers are integrated with these mechanisms. You generally don't need to wrap them manually.

Remember that disabling reloading doesn't make the Executor unnecessary.

The Executor still coordinates responsibilities such as Active Record connection management.

Be careful when combining threads, blocking operations, and autoloading.

A call to join, or another blocking operation inside an Executor block, can retain the running lock and cause a deadlock if another thread needs to autoload a constant.

In such cases, permit_concurrent_loads may help, provided you follow its requirements.

Conclusion

Rails makes concurrent application execution relatively straightforward, but it still needs to coordinate shared resources and application code loading.

The Executor provides a common boundary for running application code. The Reloader builds on that boundary to coordinate code reloading, while the Load Interlock helps ensure that classes are loaded and unloaded safely across threads.

Understanding these mechanisms helps explain why Rails automatically wraps its own entry points, why manually created threads need special attention, and why certain combinations of blocking operations and autoloading can cause deadlocks.

These are internal mechanisms that we don't usually need to think about during everyday Rails development. But understanding why they exist makes it much easier to write safe concurrent code when we need to go beyond the framework's default behavior.

These notes are based on the official Rails 8.0 guide, “Threading and Code Execution in Rails.”

Top comments (0)