In concurrent-ruby, why use Concurrent::Map for a cache shared by threads, and why is map[key] ||= value not safe where compute_if_absent is?
answer
- each call safe, sequences are not
- ||= is a read then a write
- compute_if_absent runs the block atomically
- fetch_or_store is two steps
- never touch the map inside its block
basics
~20 sConcurrent::Map makes each individual read and write thread-safe, but map[key] ||= build is a read followed by a separate write, so two threads can both build and store. compute_if_absent(key) { build } checks and stores in one atomic step.
solid answer
~50 s`Concurrent::Map` is a hash-like object whose single operations are thread-safe on every Ruby implementation and which is meant to scale better under contention than a Hash wrapped in a lock. It does not make **sequences** of calls atomic: `map[key] ||= expensive_client(key)` expands to a read and then a write, so two threads can both miss, both build a client and both store one. `fetch_or_store` is documented as two steps for the same reason. The atomic methods are the ones that take the whole decision: `compute_if_absent(key) { ... }` stores the block's value only if the key is absent and returns the existing or new value, `put_if_absent` stores a given value, and `compute`, `merge_pair`, `replace_pair` and `delete_pair` update under the same guarantee. Blocks passed to those atomic methods must not call the same map, or they deadlock.
go deeper
Remember that a normal Hash is unsafe for shared updates and that Concurrent::Map gives thread-safe reads and writes.
Explain why each call being safe does not make ||= safe, and name compute_if_absent, put_if_absent and compute as the atomic alternatives.
Review caches for check-then-act races, keep atomic blocks short and non-reentrant, and store only immutable or thread-safe values.
Decide whether a shared in-process cache is the right design at all, versus per-thread state or an external store, given memory and invalidation.
## Why not a plain Hash A Hash shared between threads is unsafe for compound updates on any Ruby, and on implementations without a GVL even single operations can corrupt it. Wrapping every access in one `Mutex` works but makes every reader wait for every writer. `Concurrent::Map` is the gem's answer: - **Every single operation is thread-safe**: `[]`, `[]=`, `delete`, `key?`, `size`, iteration. - **It is built for concurrency**: its documentation says it should perform much better under high concurrency than `Concurrent::Hash`, a Hash subclass (which, on CRuby, is currently plain `::Hash` with no extra locking). - **It is not a drop-in Hash**: it does not promise insertion order, and it has no Enumerable methods like `map` or `select` beyond iteration helpers such as `each_pair`, `keys` and `values`. ## The race hiding in ||= Suppose the notification service keeps one HTTP client per push provider: ```ruby CLIENTS = Concurrent::Map.new def client_for(provider) CLIENTS[provider] ||= PushClient.connect(provider) # racy end ``` `a[k] ||= v` means `a[k] || (a[k] = v)`. Two threads that call `client_for(:apns)` at the same moment can both read `nil`, both open a connection and both store one; one connection leaks and callers briefly hold different clients. Each call to the map was safe, but the check and the store were two separate calls. ## The atomic alternatives | Method | What it does atomically | Returns | |---|---|---| | `compute_if_absent(key) { v }` | runs the block and stores only if the key is absent | existing or new value | | `put_if_absent(key, v)` | stores `v` only if absent | previous value, or `nil` if stored | | `compute(key) { \|old\| new }` | replaces the value from the old one; `nil` deletes | new value or `nil` | | `merge_pair(key, v) { \|old\| new }` | stores `v` if absent, otherwise computes from old | new value or `nil` | | `replace_pair(key, old, new)` | swaps only if the current value is the same object as `old` | `true` or `false` | | `get_and_set(key, v)` | sets and hands back what was there | old value or `nil` | The fixed version: ```ruby def client_for(provider) CLIENTS.compute_if_absent(provider) { PushClient.connect(provider) } end ``` On CRuby, `compute_if_absent` first checks without locking and, if the key is missing, takes the map's write lock, checks again and runs the block, so the block runs at most once per key. ## Methods that look atomic but are not 1. **`fetch_or_store(key) { v }`**: documented as a two-step operation; a concurrent store can be overwritten. 2. **`fetch(key) { ... }`**: the fetch-then-yield pair is explicitly not atomic. 3. **`map[key] = map[key].to_i + 1`**: a read and a write; use `compute(key) { |old| old.to_i + 1 }` or `merge_pair(key, 1) { |old| old + 1 }` instead. ## Moving from a Mutex-wrapped Hash Code that guards a Hash with a `Mutex` often translates directly: | Before | After | |---|---| | `lock.synchronize { h[k] \|\|= build }` | `map.compute_if_absent(k) { build }` | | `lock.synchronize { h[k] = (h[k] \|\| 0) + 1 }` | `map.merge_pair(k, 1) { \|old\| old + 1 }` | | `lock.synchronize { h.delete(k) if h[k].equal?(v) }` | `map.delete_pair(k, v)` | | `lock.synchronize { h[k] }` | `map[k]` | The difference is that plain reads no longer wait behind writers, and the atomic operations name exactly which step must not be interleaved. Where one operation must touch several keys at once, a `Mutex` around a plain Hash is still the honest tool; the map only guarantees atomicity per key. ## Rules for the atomic blocks - **Do not use the same map inside the block.** The documentation warns that referring to the map from inside an atomic method's block deadlocks. - **Keep the block short.** On CRuby the atomic methods share one write lock per map, so a slow block, such as a network connect, delays other writers. - **Store thread-safe or immutable values.** The map protects its slots, not the objects in them; mutating a stored Array from several threads is still a race.
- How would you count notifications sent per provider in a `Concurrent::Map` without a race?Use an atomic update rather than read-then-write: `counts.merge_pair(provider, 1) { |old| old + 1 }` stores 1 when the key is absent and otherwise increments under the map's guarantee. `compute(provider) { |old| (old || 0) + 1 }` works too. Alternatively store one `Concurrent::AtomicFixnum` per key via `compute_if_absent` and call `increment` on it.
- Your `compute_if_absent` block builds its value by calling the same map for a related key, and the program hangs. Why?Atomic methods that take a block hold the map's internal lock while the block runs, and the gem documents that using the map itself inside such a block deadlocks. Compute the related value before the call, or restructure so the block only builds from arguments and outside objects.
saying these in an interview costs you the question
- Thinks map[key] ||= value on a Concurrent::Map is atomic
- Uses fetch_or_store believing it can never overwrite a concurrent store
- Calls the same map inside a compute_if_absent block
- Assumes Concurrent::Map preserves insertion order like Hash
- Believes the map makes the mutable objects it stores thread-safe