skip to content

In CRuby, if the GVL runs one thread at a time, why can a shared hit counter updated with @hits += 1 still lose updates?

level: middleimportance: must knowfreq 72%

answer

  1. += is read, add, write
  2. switches happen at VM checkpoints
  3. often passes a quick stress test
  4. any call in the gap opens it
  5. fix: Mutex#synchronize around both

basics

~20 s

@hits += 1 is a separate read, add and write. The GVL lets one thread run Ruby at a time but does not keep a thread running across those steps, so a switch between the read and the write loses an increment.

solid answer

~50 s

`@hits += 1` expands to `@hits = @hits + 1`: read the variable, call `+`, write the result. The GVL protects the interpreter's internals and lets one thread run Ruby at a time, but CRuby may switch threads at its checkpoints, such as when a method or block returns, a loop branches, or a thread blocks. If thread A reads 41, is switched out, and thread B writes 42, A later writes 42 too, and one hit is gone. The bare integer form often survives a quick test on CRuby because it has no checkpoint in the gap, which is exactly why the bug ships; a method call, a log line or a Hash default proc between read and write opens the window, and JRuby or TruffleRuby run the threads truly in parallel. Wrap the whole read-modify-write in `mutex.synchronize`.

code

ruby · 17 lines
ruby
hits = 0
workers = 10.times.map do
  Thread.new do
    1_000.times do
      current = hits
      Thread.pass          # any call here opens the window
      hits = current + 1
    end
  end
end
workers.each(&:join)
hits # => typically far below 10_000

lock = Mutex.new
hits = 0
10.times.map { Thread.new { 1_000.times { lock.synchronize { hits += 1 } } } }.each(&:join)
hits # => 10_000

go deeper

for a junior

Recall that += is a read followed by a write, and that the GVL does not stop another thread running in between.

for a middle

Explain where CRuby may switch threads, why a microbenchmark can pass by luck, and how one synchronize must cover the whole read-modify-write.

for a senior

Show you spot the pattern in real code (||= caches, stats hashes, check-then-act) and weigh a lock against thread confinement or an atomic from concurrent-ruby.

for a principal

Argue for code that is correct on JRuby and TruffleRuby too, and set review rules so GVL-dependent assumptions do not reach shared libraries.

## The GVL's promise is narrower than it looks CRuby's **Global VM Lock** means only one thread per Ractor executes Ruby code at any instant. It exists to protect the interpreter itself: object headers, the garbage collector, method caches. It does **not** say a thread will keep running until your statement, method or transaction is complete. The scheduler can move the lock to another thread whenever the running thread reaches one of the VM's interrupt checkpoints, or blocks. ## What @hits += 1 really is Ruby defines `a += b` as `a = a + b`. For an instance variable that is three steps: 1. read `@hits`; 2. call `Integer#+` with 1; 3. write the result back to `@hits`. Two threads can interleave like this: | Step | Thread A | Thread B | `@hits` | |---|---|---|---| | 1 | reads 41 | | 41 | | 2 | switched out | reads 41 | 41 | | 3 | | writes 42 | 42 | | 4 | writes 42 | | 42 | Two increments, one recorded. The same shape appears in `@stats[path] += 1`, `@cache[key] ||= load(key)` and `@list = @list + [item]`. ## Why it often passes a test anyway CRuby does not switch threads between arbitrary instructions. It checks for a pending switch at particular points, such as when a method or block frame returns and on loop jumps and branches. The bare sequence *read ivar, add 1, write ivar* contains none of them, so a microbenchmark with ten threads and `@hits += 1` frequently prints the right total. That is luck built on an implementation detail, and it breaks when: - the gap contains a Ruby-level call: a custom `+`, a setter defined with `def`, a `Hash` default proc, a log line, instrumentation; - the counter lives behind an accessor that later gains logic; - the code runs on JRuby or TruffleRuby, which have no GVL and execute threads in parallel; - a later Ruby changes where checkpoints fall. The code example below makes the window explicit with `Thread.pass`, standing in for any of those calls. ## The fix Put the **whole** read-modify-write inside one lock: ```ruby @lock = Mutex.new def record_hit = @lock.synchronize { @hits += 1 } def hits = @lock.synchronize { @hits } ``` Points that interviewers probe: - **Reads need the lock too** if they must see a value consistent with other fields updated in the same critical section. - **Locking each step separately does not help**: `v = @lock.synchronize { @hits }` then `@lock.synchronize { @hits = v + 1 }` has the same gap. - **Check-then-act is the same bug**: `@cache[key] = load(key) unless @cache.key?(key)` can load twice; lock around both the check and the write. - **Alternatives**: confine the counter to one thread and send it increments through a `Thread::Queue`, or use an atomic counter from the concurrent-ruby gem. ## Diagnosing a lost-update bug The symptom is usually a total that is slightly low under load and correct in development. A practical sequence: 1. Find every place the value is written, not just the obvious increment; setters and `||=` count. 2. Check whether each write is part of a read-modify-write and whether a lock covers the whole sequence. 3. Reproduce it by inserting `Thread.pass` or a short `sleep` between the read and the write in a test; a race that needs luck to appear becomes deterministic. 4. Fix it with one critical section, or by moving ownership of the counter to a single thread. 5. Keep the reproduction as a regression test, with the artificial pause behind a test-only hook. ## What the GVL does give you It is fair to say that in CRuby a single core-method call on a core object, such as one `Array#push`, will not leave that object's internals corrupted. That is an implementation property, not a documented guarantee of the language, and it covers one call, never a sequence of calls that together form your invariant.

  • Why is locking the read and the write in two separate synchronize blocks still wrong?
    The lock is released between them, so another thread can update `@hits` in the gap and the second block writes a stale value. The invariant is the read and the write together, so one `synchronize` must cover both.
  • Is @cache[key] ||= load(key) thread-safe on CRuby?
    No. `||=` reads `@cache[key]`, and if it is nil calls `load(key)`, a method call, and then writes. Two threads can both see nil and both load; with an expensive or side-effecting load that matters. Hold a mutex around the whole expression, or use a thread-safe map from concurrent-ruby.

The GVL is a single pen shared by clerks updating one ledger total: only one writes at a time, but a clerk can read the total, hand the pen over while fetching a calculator, and later write a sum based on a number that someone else has already changed.

saying these in an interview costs you the question

  • The GVL makes every Ruby statement atomic.
  • @hits += 1 is a single indivisible operation in CRuby.
  • If a stress test prints the right total, the counter is thread-safe.
  • Locking the read and the write separately makes the update safe.
  • CRuby can switch threads only when a thread does I/O.