skip to content

In Ruby, how does Thread.current[:key] differ from Thread.current.thread_variable_get(:key), and when does the difference bite?

level: middleimportance: should knowfreq 32%

answer

  1. two stores on one Thread
  2. Thread#[] is fiber-local
  3. thread_variable_get/set spans fibers
  4. a new Fiber sees nil via []
  5. new threads start empty

basics

~20 s

Thread#[] and #[]= are fiber-local: each fiber on a thread has its own store, so code running in another fiber sees nil. thread_variable_get and thread_variable_set are truly thread-local, shared by every fiber on that thread.

solid answer

~40 s

A Ruby `Thread` carries two separate key-value stores. `Thread.current[:key]` (with `[]=`, `fetch`, `key?` and `keys`) is **fiber-local** despite its name: every fiber, including one created by `Fiber.new` or used behind an external enumerator, gets an empty store. `thread_variable_get`/`thread_variable_set` (with `thread_variable?` and `thread_variables`) are **thread-local**: every fiber running on that thread sees the same value. The difference bites when code switches fibers: a request id put in `Thread.current[:request_id]` disappears inside a fiber, while a thread variable does not. Neither store is inherited by a new `Thread`, which starts with both empty. Ruby chose fiber-local semantics for `[]` so the save-set-restore idiom for dynamically scoped values does not leak between fibers.

code

ruby · 12 lines
ruby
Thread.current[:request_id] = 42

ids = Enumerator.new { |y| y << Thread.current[:request_id] }
ids.first   # => 42  (internal iteration runs in the calling fiber)
ids.next    # => nil (external iteration runs in a separate fiber)

Fiber.new { Thread.current[:request_id] }.resume # => nil

Thread.current.thread_variable_set(:request_id, 42)
Fiber.new { Thread.current.thread_variable_get(:request_id) }.resume # => 42

Thread.new { Thread.current[:request_id] }.value # => nil (new thread starts empty)

go deeper

for a junior

Recall that Thread.current[] exists for per-thread values and that a new thread starts without the creator's values.

for a middle

Explain that Thread#[] is fiber-local while thread_variable_get and thread_variable_set span all fibers of the thread, with an example that shows nil.

for a senior

Diagnose context that disappears inside enumerators or a fiber-based server, and choose between explicit arguments, thread variables and resetting in ensure.

for a principal

Decide how request context travels through a codebase that mixes threads and fibers, and which libraries or conventions own that propagation.

## Two stores on every thread Ruby's `Thread` object offers two families of accessors that look like the same feature but are not: | Family | Methods | Scope | Missing key | |---|---|---|---| | **Fiber-local** | `Thread#[]`, `#[]=`, `#fetch`, `#key?`, `#keys` | the fiber currently running on that thread | `[]` returns `nil`; `fetch` raises `KeyError` unless given a default or block | | **Thread-local** | `#thread_variable_get`, `#thread_variable_set`, `#thread_variable?`, `#thread_variables` | the whole thread, across all its fibers | returns `nil` | Keys may be given as symbols or strings; both families convert them to symbols, so `Thread.current["user"]` and `Thread.current[:user]` name the same slot. ## Why Thread#[] is fiber-local Fibers arrived in Ruby 1.9. At that point the core team made `Thread#[]` fiber-local so a common idiom keeps working: ```ruby def with_locale(value) old = Thread.current[:locale] Thread.current[:locale] = value yield ensure Thread.current[:locale] = old end ``` If the block passed in switches to another fiber mid-way, a thread-wide store would let the temporary value leak into that fiber and out of `with_locale`. Fiber-local storage keeps each fiber's dynamic scope separate. For values that genuinely belong to the thread, `thread_variable_set` was added. ## When the difference bites Most code runs in a thread's root fiber and never notices. The difference appears when something runs your code in **another fiber on the same thread**: - an explicit `Fiber.new { ... }.resume`; - an external enumerator (`enum.next`), which runs the iteration in a fiber; - a fiber-scheduler library such as async, where each task is a fiber. In each case `Thread.current[:request_id]` read inside the fiber returns `nil`, even though the same thread set it a moment earlier, while `Thread.current.thread_variable_get(:request_id)` returns the value. ```ruby Thread.current[:user] = "ana" Thread.current.thread_variable_set(:tenant, "eu") Fiber.new do [Thread.current[:user], Thread.current.thread_variable_get(:tenant)] end.resume # => [nil, "eu"] ``` ## New threads start empty Neither store is copied into a thread created with `Thread.new`. If the main thread sets `Thread.current[:request_id] = 7` and then starts a worker, `Thread.current[:request_id]` inside the worker is `nil`. Pass the value in explicitly: `Thread.new(request_id) { |id| ... }`. Fiber storage accessed with `Fiber[]` (Ruby 3.2+) is the store that new fibers and threads do inherit; it is a separate mechanism. ## Reading another thread's values Both families can be called on a thread other than `Thread.current`, which is how a supervisor can read progress a worker publishes: - `worker[:pages_done]` reads the worker's fiber-local value; - `worker.thread_variable_get(:pages_done)` reads its thread variable. This is fine for monitoring, but the value can change between reads, so do not build control flow on it. ## Inspecting the stores Each family has its own query methods, and they never see each other's keys: - `Thread.current.key?(:locale)` and `Thread.current.keys` report the current fiber's `Thread#[]` store. - `Thread.current.thread_variable?(:tenant)` and `Thread.current.thread_variables` report the thread-wide store. - A key set with `thread_variable_set` is invisible to `key?`, and a key set with `[]=` is invisible to `thread_variable?`. When debugging "the value is gone", checking both stores, and checking which fiber you are in (`Fiber.current`), usually finds the answer quickly. ## Practical guidance 1. Use **explicit arguments** where you can; ambient storage is invisible coupling. 2. For per-request context that must survive fiber switches on one thread, prefer `thread_variable_set`, or a library that handles fibers deliberately. 3. When you set ambient values on a pooled thread, reset them in `ensure`, because the thread outlives the request. 4. Use `Thread#fetch(:key)` when a missing value is a bug; it raises `KeyError` instead of quietly returning `nil`.

  • What does Thread.current.fetch(:tenant) do when the key was never set?
    It raises `KeyError`, mirroring `Hash#fetch`. With a second argument it returns that default, and with a block it returns the block's result. Plain `Thread.current[:tenant]` would return `nil` instead, which can hide a missing value.
  • Why does a value set with Thread.current[:x] vanish inside code that uses an Enumerator's next?
    An external enumerator runs its iteration in a separate fiber, and `Thread#[]` is fiber-local, so inside that fiber the store is empty. Use `thread_variable_set` for values that must be visible there, or pass them into the enumerated code directly.

saying these in an interview costs you the question

  • Thread.current[:key] is shared by every fiber on the thread.
  • thread_variable_set values are copied into threads started from it.
  • A worker thread sees the Thread.current[] values its creator set.
  • Thread#fetch returns nil for a missing key like Thread#[].
  • String and symbol keys name different slots in Thread#[].