Introduction
Ruby 3.2 introduced Object Shapes for standard Ruby objects, but objects backed by C extensions or special layouts stored ivars in a shared global table. Ruby 4.0 replaces that global table with per-instance "fields" structures, removing a major bottleneck for Ractor-based concurrency.
Key Concepts
- Generic ivar storage: The fallback mechanism for objects that cannot embed ivars directly (e.g., objects with C-level
T_DATAlayouts). Before Ruby 4.0, these used a single global hash table protected by a lock. - Per-instance fields: Ruby 4.0 gives each such object its own fields structure, eliminating lock contention when multiple Ractors access different objects.
- Ractor: Ruby's actor-based concurrency primitive. Each Ractor runs in its own thread with isolated memory, but shared data structures (like the old global ivar table) created contention points.
Real World Context
If you run a Ractor-per-core architecture (common in Ruby 4.0 for CPU-bound workloads), the old global ivar table was a serialization bottleneck. With per-instance fields, each Ractor's objects are fully independent, and Class#new with keyword arguments runs significantly faster because there is no global lock to acquire.
Deep Dive
In Ruby 3.x, generic ivars lived in a process-wide hash table:
ruby# Ruby 3.x internal pseudo-code: # GLOBAL_GENERIC_IVAR_TABLE[object_id][:@name] = value # Every read/write acquires a mutex -> contention under Ractors
Ruby 4.0 moves to per-instance storage:
ruby# Ruby 4.0 internal pseudo-code: # object.fields[:@name] = value # No global lock needed — each object owns its fields
The practical benefits for your code are:
- Better Ractor scaling: No contention on shared data structures when different Ractors touch different objects.
- Faster
Class#new: Especially with keyword arguments, because field allocation is local to the new object. - Improved cache locality: Per-object fields stay close in memory to the object header, reducing CPU cache misses.
You can verify that shapes work correctly using the debug API:
rubyobj = MyClass.new(name: "test") shape = RubyVM.debug_get_shape(obj) puts shape # Shows the shape descriptor for this object
To maximize the benefit, follow the same rules as regular shapes — initialize all ivars upfront and in consistent order:
rubyclass Optimized def initialize(a, b, c) @a = a @b = b @c = c end end
Common Pitfalls
- Assuming per-instance fields fix all Ractor issues — Fields eliminate ivar table contention, but sharing mutable objects across Ractors is still restricted. You must still use
Ractor.make_shareableor send copies. - Ignoring the shape depth limit — Ruby imposes a maximum shape tree depth. Dynamically creating many ivars via
instance_variable_set("@#{name}", val)can exceed this limit, causing the VM to fall back to the slow generic hash.
Best Practices
- Avoid dynamic ivar creation — Never loop over data and call
instance_variable_setwith computed names. Use a Hash ivar instead:@data[name] = val. - Benchmark with Ractors enabled — The per-instance fields improvement is most visible under Ractor concurrency. Single-threaded benchmarks may not show the difference.
Summary
- Ruby 4.0 replaces the global generic ivar table with per-instance fields, eliminating a Ractor contention bottleneck.
Class#newwith kwargs is faster, and cache locality improves because fields are co-located with the object.- The shape depth limit still applies — avoid dynamic ivar creation to stay on the fast path.
Code Examples
# Ruby 4.0: Ractor-safe object with per-instance fields
ractor = Ractor.new do
# Each Ractor creates objects independently — no global lock contention
1000.times.map do |i|
Config.new(host: "server-#{i}", port: 8080 + i, timeout: 30)
end
end
results = ractor.take
puts results.size # => 1000
# In Ruby 3.x, this would contend on the global ivar table
# In Ruby 4.0, each Config's fields are per-instance — zero contention