In Ruby, is it safe for several threads to write one shared Hash or Array, and what do you do in a library that must also run on JRuby?
answer
- one call is safe on CRuby only
- compound updates still race
- iterating while another thread adds
- can't add a new key during iteration
- no GVL on JRuby or TruffleRuby
basics
~20 sNot reliably. On CRuby the GVL keeps one Array#push or Hash#[]= from corrupting the object, but compound updates race, iteration can raise, and JRuby has no GVL at all. Guard shared collections with a Mutex or confine them to one thread.
solid answer
~50 sCore `Array` and `Hash` are **not documented as thread-safe**. On CRuby, one core-method call such as `list << item` or `hash[k] = v` runs while holding the GVL, so the object's internals survive, and much code quietly depends on that. What still breaks there: compound operations (`h[k] ||= build`, `h[k] += 1`, `list.shift if list.any?`), whose steps interleave, and iterating while another thread inserts, which raises `RuntimeError` ("can't add a new key into hash during iteration") for a Hash. JRuby and TruffleRuby run threads in parallel without a GVL, where unsynchronized writes to one Array or Hash can lose elements or raise internal errors. So a library that shares a collection guards it with a `Mutex`, confines it to one thread fed by a `Thread::Queue`, or uses a structure built for concurrent access, such as those in concurrent-ruby.
code
ruby · 13 linesrates = { "EUR" => 1.0 }
reader = Thread.new do
rates.each { |_code, _rate| sleep 0.01 } # slow iteration
end
sleep 0.001
begin
rates["USD"] = 1.08
rescue RuntimeError => e
e.message # => "can't add a new key into hash during iteration"
end
reader.joingo deeper
Recall that core Array and Hash are not guaranteed thread-safe and that shared collections need a lock or a single owner.
Explain why single calls survive on CRuby while compound updates and iteration during writes do not, including the RuntimeError a Hash raises.
Show how you review a codebase for shared mutable collections, wrap them behind locked methods that return copies, and keep a gem correct on JRuby.
Set a policy for shared state in libraries: confinement first, frozen configuration, and which concurrent data structures the organisation standardises on.
## What the language promises Ruby's core documentation describes `Array` and `Hash` operations, not their behaviour under concurrent mutation. Nothing states that they are thread-safe. Whatever safety you observe comes from the **implementation** you run on. ## CRuby: single calls survive, sequences do not On CRuby a C-implemented core method such as `Array#push` or `Hash#[]=` runs to completion while its thread holds the Global VM Lock (unless it calls back into Ruby code, for example through a `Hash` default proc or a custom `hash` method). In practice that means: - one `list << item` from each of ten threads leaves ten items; - one `cache[key] = value` never leaves the hash's internal table half-updated. It does **not** protect anything built from several calls: | Code | Why it races | |---|---| | `stats[path] += 1` | read, add, write: two threads can write the same new value | | `cache[key] ||= build(key)` | both threads can see `nil` and both build | | `jobs.shift unless jobs.empty?` | check-then-act: another thread can empty it in between | | `list.each { ... }` while others push | the loop may skip or repeat items | | `hash.each { ... }` while others add keys | raises `RuntimeError`: can't add a new key into hash during iteration | The last row surprises people: the error is raised in the **writing** thread, because a Hash refuses new keys while any iteration over it is in progress, even one running in another thread. ## The edges of single-call safety Even on CRuby, "one call" is only safe while that call stays in C. Several core operations call back into Ruby code, and every such call is a point where the thread can be switched out mid-operation: - `Hash#[]` on a hash with a **default proc** runs the proc for a missing key, so `Hash.new { |h, k| h[k] = load(k) }` is itself a read-call-write sequence; - keys whose class defines its own `hash` or `eql?` in Ruby are hashed by calling that Ruby method; - `Array#sort_by!`, `Array#map!` and similar methods run your block for every element, while other threads may mutate the same array. So the realistic rule for CRuby is narrower than "core methods are atomic": a core method that never leaves C is not interrupted, and you rarely know for certain which ones those are. ## JRuby and TruffleRuby: no GVL JRuby and TruffleRuby execute Ruby threads in parallel on several cores. Two threads calling `Array#<<` on the same array at the same moment can lose an element or raise an internal error, and a `Hash` written concurrently can be left inconsistent. Code that happened to work on CRuby because of the GVL fails there, and a gem cannot choose which implementation its users run. ## Making shared collections safe Three approaches, in rough order of preference: 1. **Do not share.** Confine the collection to one thread and send it messages through a `Thread::Queue`. Only the owning thread reads or writes it. 2. **Lock every access.** Wrap the collection in an object that holds a `Mutex` and exposes only whole operations: ```ruby class HitStats def initialize @lock = Mutex.new @hits = Hash.new(0) end def record(path) = @lock.synchronize { @hits[path] += 1 } def snapshot = @lock.synchronize { @hits.dup } end ``` Return copies (`dup`) so callers can iterate without holding the lock. 3. **Use a concurrent structure**, such as the maps and arrays in concurrent-ruby, which are designed for multiple writers on every Ruby implementation. ## Reviewing code for this bug - Look for collections stored in constants, class-level instance variables or globals and written from request handlers or background threads; these are shared by default. - Check every `||=` memoisation on shared objects. - Treat "it works on CRuby" as evidence about one implementation's scheduling, not about correctness. - Prefer freezing shared configuration after boot, so it cannot be written at all.
- Why does a snapshot method return @hits.dup inside the lock rather than @hits itself?Returning the live Hash lets callers iterate it after the lock is released, while other threads keep writing, which reintroduces the race and can raise the iteration error. Copying inside `synchronize` gives the caller a private, consistent view.
- Is updating a frozen, shared configuration Hash a concurrency risk?Not a race: a frozen Hash cannot be modified, so any write raises `FrozenError` at once. Freezing shared configuration after boot turns a silent race into a loud error, and readers need no lock.
saying these in an interview costs you the question
- Ruby documents Array and Hash as thread-safe.
- The GVL makes stats[path] += 1 on a shared Hash safe.
- Code that is safe on CRuby is safe on JRuby too.
- Iterating a Hash in one thread cannot affect writers in another.
- Returning the internal Hash from a synchronized method keeps callers safe.