skip to content

In Ruby, what happens when several threads reference the same not-yet-loaded autoload constant, and what can still go wrong?

level: seniorimportance: nice to knowfreq 18%

answer

  1. one loader, the others wait
  2. a mutex per autoloaded feature
  3. constant published after the file ends
  4. cross-file cycles between threads
  5. eager-load before threads start

basics

~20 s

One thread runs the require while others wait; they see the constant only once the file has loaded. Remaining risks: two threads autoloading mutually dependent files can deadlock, and errors surface in whichever thread arrived first.

solid answer

~50 s

CRuby guards each autoloaded feature with its own mutex. The first thread to reference the constant takes the lock and runs `require`; other threads that reference any constant served by that file wait on the same lock. While the file runs, the new constant is visible only to the loading thread, so other threads cannot pick up a half-defined class; the value is published when the `require` completes. Ruby 3.2 fixed several race conditions in this path, and 4.0 is the version assumed here. What remains is design risk: two threads that each start autoloading a different file, where each file references the other's constant, can block each other; an exception or `LoadError` is raised in whichever thread got there first; and a slow file delays every waiting thread. Loading everything eagerly at boot, before threads start, removes all three.

code

ruby · 14 lines
ruby
module Sitegen
  autoload :Markdown, "sitegen/markdown"

  def self.eager_load!
    constants.each { |name| const_get(name) }   # triggers every pending autoload
  end
end

Sitegen.eager_load!        # at boot, single-threaded

workers = 4.times.map do
  Thread.new { Sitegen::Markdown.render("# hi") }  # plain constant lookup now
end
workers.each(&:join)

go deeper

for a junior

Recall that autoload loads a file on first use, and that in a threaded program that first use may happen inside a worker thread.

for a middle

Explain that one thread loads while others wait, and that the new constant is published only when the load has finished.

for a senior

Diagnose hangs and first-request failures from lazy loading, identify cross-file cycles, and move loading to boot with an eager-load step and a CI check.

for a principal

Set the policy: services eager-load everything before threads or forks, and lazy loading is reserved for command-line tools where startup time matters more.

## The situation A static-site generator renders pages in a pool of threads. `Sitegen::Markdown` is registered with `autoload`, and nothing references it during boot, so the first page that needs Markdown triggers the load, in a worker thread, while other workers are about to need the same constant. ## What CRuby does 1. The constant is marked as an autoload that points to a **feature** (the path to `require`). Each feature carries its own mutex. 2. The first thread to reference the constant takes that mutex and calls `require`. 3. Other threads that reference the constant, or any other constant registered to the same file, **block on the mutex** until the loading thread finishes. 4. While the file runs, the constant it defines is kept aside and is visible **only to the loading thread**, so code in that file can use its own class, but other threads do not see a half-built one. 5. When `require` returns, the constants are published and the waiting threads resume, now finding an ordinary constant. 6. If the file did not define the constant, the entry is removed and the reference raises `NameError`. Underneath, `require` has its own protection: two threads requiring the same file do not both run it; one waits and then gets `false`. | Concern | Handled by CRuby? | |---|---| | two threads running the same file | yes, per-feature lock plus `require`'s own lock | | another thread seeing a half-defined class | yes, the constant is published after the load completes | | two files, two threads, each needing the other's constant | no, the threads can wait on each other | | where an error is raised | in the thread that triggered the load | ## What can still go wrong - **Cross-file cycles.** Thread A starts loading `markdown.rb`, which references `Sitegen::Highlighter`; thread B has meanwhile started loading `highlighter.rb`, which references `Sitegen::Markdown`. Each holds one feature's lock and waits for the other's. If every thread is blocked, Ruby aborts with a deadlock error; if other threads are still alive, the two simply hang. - **Latency spikes.** Every worker that needs the constant waits for the slowest file to finish loading, on a live request path. - **Errors in random threads.** A `LoadError` or a `NameError` from a mistyped constant appears in whichever worker arrived first, possibly swallowed by that worker's error handling, while the others see the same failure later. - **Code that is not thread-safe at load time.** A file whose top level mutates shared state (registries, class-level caches) runs while other threads are active, so that mutation needs the same care as any shared write. ## The standard answer: eager-load before concurrency 1. At boot, before threads start or processes fork, reference or `require` every file the program will need. 2. Many frameworks provide an eager-load setting for production that does exactly this; in a plain Ruby program, a small `Sitegen.eager_load!` that requires every file under `lib/sitegen` works. 3. In the test suite, run an eager-load check so a missing or misnamed file fails CI instead of the first request. ## Other runtimes and Ractors The per-feature locking above describes CRuby 4.0. Other engines implement autoload with their own locking. A non-main Ractor cannot run `require` itself; the loading work is handed to the main Ractor, another reason to finish loading before starting parallel work. ## Diagnosing a hang If workers stop making progress right after deploy, dump every thread's stack, for example with `Thread.list.each { |t| warn t.backtrace&.join("\n") }` from a debug endpoint or a signal handler. Threads parked inside `require` or on a constant reference in two different files, each waiting while the other holds the load, are the signature of the cross-file cycle. The fix is the same eager-load step, not a retry.

  • Why can the loading thread use a constant that other threads cannot yet see?
    While its file runs, the autoloaded constant's value is held in the autoload state and returned only to the thread that owns the feature's lock. That lets the file refer to its own class during loading, while every other thread keeps waiting until `require` finishes and the constant is published.
  • How does eager loading at boot remove the cross-file deadlock?
    The deadlock needs two threads loading two files at the same time. Loading everything in one thread before workers start means each file is loaded sequentially; by the time workers run, every constant is ordinary and no autoload lock is ever taken again.

saying these in an interview costs you the question

  • Each thread that references the constant loads the file itself
  • Other threads may see the class half defined while it loads
  • Autoload makes lazy loading free of any concurrency risk
  • An autoload failure is always raised in the main thread
  • Autoload loads files in parallel across threads for speed