skip to content

In Ruby, why can a lambda kept in a long-lived registry hold a large local array and its creating object in memory, and how do you fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. whole environment, not used names
  2. Proc#binding proves it
  3. self rides along
  4. the method's own block too
  5. build the lambda in a narrow method

basics

~20 s

A CRuby lambda keeps the whole environment it was created in: every local of the enclosing scopes plus self, not only the names its body uses. A large array local stays reachable while the lambda lives, so build long-lived lambdas in small methods.

solid answer

~40 s

When CRuby turns a block into a `Proc` or lambda, it moves the enclosing frames' environments to the heap and the proc points at them. Each environment holds every local variable of its scope, which is why `lam.binding.local_variable_get(:rows)` can read a local the body never mentions, plus `self` and the block the method itself received, if any. So a price hook created in a method that also loaded a huge `rows` array keeps `rows`, and the object that built it, alive for as long as a registry holds the hook. The fix is to create long-lived lambdas in a small method whose only locals are what the lambda needs, ideally a class-level factory so `self` is not a short-lived object, or to set the big local to `nil` before returning.

code

ruby · 18 lines
ruby
class PriceBook
  def install(registry)
    rows  = load_rows                              # large Array
    index = rows.to_h { |row| [row.sku, row.price] }
    registry << ->(sku) { index[sku] }             # keeps rows and self too
  end

  def install_lean(registry)
    index = load_rows.to_h { |row| [row.sku, row.price] }
    registry << PriceBook.lookup(index)            # keeps only index
  end

  def self.lookup(index) = ->(sku) { index[sku] }
end

registry = []
PriceBook.new.install(registry)
registry.first.binding.local_variable_get(:rows)  # => the rows Array

go deeper

for a junior

Recall that a lambda keeps the scope it was written in alive for as long as the lambda itself is kept.

for a middle

Explain that CRuby copies whole environments to the heap, so unused locals, self and the method's block are retained with the lambda.

for a senior

Diagnose growth that tracks registered hooks, prove it with Proc#binding, and fix it with narrow factory methods or by dropping large locals.

for a principal

Set conventions for long-lived callables, such as factories that take explicit inputs and hook lifetimes tied to owners, so retention is visible in review.

## What a proc really points at The `Proc` documentation says procs remember "the entire context in which they were created". In CRuby that is literal. A block normally runs with its locals on the VM stack. When something turns it into a `Proc` object (a lambda literal, `proc`, an `&block` parameter) or asks for its `binding`, the VM copies the enclosing frame's **environment** to the heap: `vm_make_env_each` in `vm.c` copies the frame's whole local table, then does the same for every enclosing frame up to the method. The proc keeps a reference to that chain. That environment contains: - **every local variable** of each enclosing scope, whether or not the lambda body names it; - **`self`** of the scope where the block was written; - the **block the enclosing method received**, if it had one, which is converted to a proc and kept too. CRuby does not trim locals the body never uses from that environment. The proof is in the language itself: `Proc#binding` can read any of them. That access is one reason the environment holds them all: through a `Binding`, code can ask for any local of the scope by name at run time, so the VM has no list of locals it could safely leave out. ## A leaking price hook ```ruby class PriceBook def install(registry) rows = load_rows index = rows.to_h { |row| [row.sku, row.price] } registry << ->(sku) { index[sku] } end end ``` The hook uses only `index`, but its environment also holds `rows` (the raw data, possibly far larger than the index) and `self` (the `PriceBook` and everything its instance variables reference). While `registry` lives, for example in a constant, none of that can be garbage-collected. `hook.binding.local_variable_get(:rows)` returns the array, which confirms the retention in one line. ## What is and is not retained | Held by the lambda's environment | Retained? | |---|---| | Locals the body uses (`index`) | Yes | | Other locals of the same method (`rows`) | Yes | | `self` of the method, and its instance variables | Yes | | The block passed to the enclosing method | Yes | | Locals of the method's caller | No, unless they are reachable through the block the method received | ## Fixes 1. **Build the lambda in a narrow method.** Pass only what it needs: `def self.lookup(index) = ->(sku) { index[sku] }`. Its environment then holds `index` and the class as `self`, which lives for the process anyway. 2. **Drop big locals before returning.** Setting `rows = nil` after building the index works, because the lambda shares the variable rather than a copy, so the array loses its last reference. 3. **Keep the payload in an object.** A small callable object with an explicit instance variable makes the retained state visible in code review. 4. **Unregister hooks** when their owner is done, so the registry does not outlive the need. ## Blocks that are only yielded to Retention needs a `Proc` or `Binding` that something keeps. A block that is only yielded to and never captured runs on the stack and holds nothing after the call returns. The risk sits in code that stores callables: registries, callbacks, event listeners, caches in constants and lazily built lookups. ## Diagnosing it step by step 1. Notice that process memory grows with the number of hooks or callbacks registered, not with the size of the data they serve. 2. Pick one registered callable and list what it keeps: `hook.binding.local_variables` and `hook.binding.receiver`. 3. Read the suspicious locals through `local_variable_get` and check their sizes. 4. Move the lambda's creation into a narrow factory, re-measure, and confirm the growth flattens. ## Confirming it in production - Watch for heap growth that tracks the number of registered hooks rather than the data they use. - Compare a retained-memory report or heap dump before and after registering hooks, and look for large arrays reachable only from `Proc` environments. - Probe a suspect hook directly with `hook.binding.local_variables` to list what it keeps.

  • Why does setting rows = nil at the end of install stop the retention?
    The lambda shares the method's variables rather than copies of their values. Assigning `nil` to `rows` overwrites the slot the environment holds, so the array loses its last reference and can be collected, even though the environment itself stays alive with the hook.
  • Does a block passed to each and only yielded to keep its method's locals alive after the call?
    No. A block that is only yielded to runs with its environment on the stack, and nothing references it once `each` returns. Retention starts when something keeps a `Proc`, a lambda or a `Binding` beyond the call.

saying these in an interview costs you the question

  • A lambda captures only the variables its body mentions
  • Locals are always freed when their method returns
  • A lambda in an instance method does not reference self unless it uses self
  • Calling GC.start frees the array while the hook is still registered
  • Freezing the array stops the lambda from retaining it