skip to content

In Ruby 3.2+, how does Fiber[] storage behave across new fibers, threads and Enumerator#next, and why was it added beside Thread.current[]?

level: middleimportance: nice to knowfreq 18%

answer

  1. Fiber[:key] and Fiber[:key] = value
  2. new fibers and threads inherit a copy
  3. child writes do not reach the parent
  4. Enumerator#next fiber sees it
  5. Fiber.new(storage: nil) starts empty

basics

~20 s

Fiber[:key] reads and Fiber[:key] = value writes the current fiber's storage. Each new fiber or thread starts with a copy of its creator's storage, so values flow down into enumerator fibers and tasks, but a child's writes never reach the parent.

solid answer

~40 s

`Fiber[:request_id] = 42` stores a value in the current fiber's **storage**, a Hash keyed by symbols, added in Ruby 3.2. When code creates a fiber (`Fiber.new`, an external enumerator's `next`, a task in a scheduler library) or a `Thread`, the new execution context starts with a **copy** of its creator's storage, so `Fiber[:request_id]` still returns 42 there. Assigning in the child changes only the child's copy. That differs from `Thread.current[:key]`, which is fiber-local and **not** inherited, so it reads `nil` inside an enumerator fiber. Storage was added so request-scoped context survives code that moves work into fibers. `Fiber.new(storage: nil)` starts empty; `Fiber#storage` returns a copy, and assigning a whole hash with `storage=` is experimental, so set individual keys.

code

ruby · 12 lines
ruby
Thread.current[:fiber_local] = 1
Fiber[:storage_var] = 1

e = Enumerator.new do |y|
  y << [Thread.current[:fiber_local], Fiber[:storage_var]]
  Fiber[:storage_var] += 1
  y << :done
end

e.next               # => [nil, 1]  (new fiber: fiber-local empty, storage inherited)
e.next               # => :done
Fiber[:storage_var]  # => 1 (the enumerator changed its own copy)

go deeper

for a junior

Recall that Fiber[:key] stores a value for the current fiber and that fibers created afterwards start with a copy.

for a middle

Explain inheritance by copy into fibers and threads, why Thread.current[] reads nil in an enumerator fiber, and what storage: nil does.

for a senior

Show how request context should propagate under a fiber scheduler, and why a child's write not reaching the parent is a feature.

for a principal

Decide how a codebase carries ambient context across threads and fibers, and which store each library in the stack should use.

## What Fiber storage is Ruby 3.2 added **fiber storage**: a set of key-value pairs attached to each fiber, read with `Fiber[key]` and written with `Fiber[key] = value`. Keys are symbols. The defining property is **inheritance by copy**: - when a new fiber is created, it receives a copy of the creating fiber's storage; - when a new `Thread` is created, its first fiber also receives a copy; - writes affect only the fiber that makes them. ```ruby Fiber[:locale] = "de" Fiber.new do Fiber[:locale] # => "de" (inherited copy) Fiber[:locale] = "fr" # changes only this fiber's copy end.resume Fiber[:locale] # => "de" Thread.new { Fiber[:locale] }.value # => "de" ``` ## Why it was added Ruby already had two ambient stores on `Thread`: `Thread#[]`, which is fiber-local and starts **empty** in every new fiber, and `thread_variable_get`/`set`, which is shared by every fiber on a thread. Neither fits code that spreads one logical request across several fibers: | Store | New fiber sees parent's value? | New thread sees it? | Writes visible to parent? | |---|---|---|---| | `Thread.current[:k]` | no, starts empty | no | not applicable | | `thread_variable_set` | yes, same thread-wide slot | no | yes, it is one shared slot | | `Fiber[:k]` | yes, a copy | yes, a copy | no | With a fiber scheduler, each concurrent task is a fiber. A request id put in `Thread.current[:request_id]` is invisible inside tasks spawned for that request, and a thread variable would be shared by every request on the thread. Fiber storage gives each task the context of whoever started it, which is the behaviour logging and tracing need. ## Enumerator#next and storage An external enumerator runs its block in a separate fiber the first time you call `next`. The core documentation spells out the consequence: - `Thread.current[:x]` inside that block is `nil` during external iteration (a fresh fiber-local store) but visible during internal iteration with `each`; - `Fiber[:x]` is inherited, so the block sees the caller's value either way; - assigning `Fiber[:x]` inside the enumerator fiber does not change the caller's value, because it ran in a different fiber. To pass information back up, store a mutable object once and mutate it rather than reassigning the key. ## Controlling what a fiber inherits - `Fiber.new(storage: nil) { ... }` starts with empty storage. - `Fiber.new(storage: {tenant: "eu"}) { ... }` starts with a copy of that Hash. - `Fiber.new(storage: true)` asks for the default inheritance explicitly; the documentation marks passing `true` explicitly as experimental. - `Fiber.current.storage` returns a **copy** of the current storage and may only be called on the current fiber; `storage=` replaces the whole hash and is experimental. ## Fiber storage under a scheduler Scheduler libraries create a fiber per task with the default `storage` behaviour, so a task inherits the storage of the fiber that started it: - a request handler sets `Fiber[:request_id]` once; - every task it spawns for that request, and every task those spawn, reads the same id; - two requests handled concurrently on one thread never see each other's id, because each handler's fiber holds its own copy. This is the case `Thread.current[:request_id]` cannot serve: it would be empty in every child task, while a thread variable would be overwritten by whichever request ran last. ## Practical guidance 1. Use fiber storage for request-scoped context in code that may run under a fiber scheduler. 2. Set values at the top of the request, before spawning tasks, so they are inherited. 3. Do not rely on a child's write reaching the parent; return values instead. 4. Keep what you store small and immutable where possible; every new fiber copies the hash.

  • How can code inside an enumerator fiber report something back to the caller through fiber storage?
    Reassigning `Fiber[:key]` only changes the child's copy. Store a mutable object before iterating, for example `Fiber[:stats] = {count: 0}`, and mutate it inside: `Fiber[:stats][:count] += 1`. Both fibers hold references to the same Hash, so the caller sees the change.
  • How do you start a Ruby fiber without inheriting its creator's context?
    Pass `storage: nil` to `Fiber.new`, which starts the fiber with empty storage, or pass a Hash to start from exactly those keys. This is useful when a long-lived background fiber must not carry a request's identity.

saying these in an interview costs you the question

  • Fiber[] storage is shared by reference, so child writes reach the parent.
  • Thread.current[:key] is inherited by an Enumerator's next fiber.
  • Fiber storage is not copied into new threads.
  • Fiber[] is just another name for Thread.current[].
  • Fiber#storage returns the live hash you can mutate in place.