skip to content

In Ruby, when do you need Monitor instead of Mutex, and how do Monitor's new_cond helpers compare with Thread::ConditionVariable?

level: middleimportance: should knowfreq 34%

answer

  1. require "monitor"
  2. Monitor is reentrant, Mutex is not
  3. include MonitorMixin in a class
  4. ConditionVariable#wait(mutex, timeout)
  5. wait_while / wait_until loop for you

basics

~20 s

Monitor is a reentrant lock: its owner may enter synchronize again, which a Mutex refuses with ThreadError. Its new_cond returns a condition variable with wait_while and wait_until, which loop on the predicate; Thread::ConditionVariable needs a Mutex and a hand-written loop.

solid answer

~40 s

`Monitor` (after `require "monitor"`) is a lock its **owner can re-enter**: a synchronized method may call another synchronized method on the same object, and Monitor counts the nesting. A `Mutex` raises `ThreadError` ("deadlock; recursive locking") in that situation. `include MonitorMixin` gives a class `synchronize` and `new_cond` directly. For waiting on a condition, core Ruby offers `Thread::ConditionVariable`: hold a mutex, call `cv.wait(mutex)` (optionally with a timeout), and another thread calls `signal` or `broadcast` after changing state; `wait` releases the mutex while sleeping and retakes it on wakeup, and can wake spuriously, so it sits in a `while` loop. A Monitor's `new_cond` returns a `MonitorMixin::ConditionVariable` whose `wait_while { buf.empty? }` and `wait_until { ... }` contain that loop. For simple hand-offs, `Thread::Queue` usually replaces both.

code

ruby · 18 lines
ruby
require "monitor"

lock = Monitor.new
lock.synchronize { lock.synchronize { :nested_ok } } # => :nested_ok

mutex = Mutex.new
mutex.synchronize { mutex.synchronize { } }         # ThreadError: deadlock; recursive locking

buffer = []
ready = lock.new_cond
consumer = Thread.new do
  lock.synchronize do
    ready.wait_while { buffer.empty? }
    buffer.shift
  end
end
lock.synchronize { buffer << :rates_file; ready.signal }
consumer.value # => :rates_file

go deeper

for a junior

Recall that Monitor is a lock you can re-enter from the same thread, loaded with require "monitor", while Mutex is not reentrant.

for a middle

Explain ConditionVariable#wait(mutex, timeout) releasing and retaking the mutex, signal versus broadcast, and wait_while on a monitor's new_cond.

for a senior

Show when reentrancy hides a design problem, and when a Queue is a simpler replacement for a hand-built condition-variable protocol.

for a principal

Set guidance on which synchronization primitives a shared codebase allows, favouring queues and confinement over custom lock protocols.

## Monitor: a reentrant lock `Monitor` lives in the `monitor` extension that ships with Ruby, not in the core classes. RubyGems itself requires it at boot, so it is usually already defined, but code that uses it should say `require "monitor"` rather than rely on that side effect. Like `Mutex` it allows one owner at a time, but it records **who** owns it and **how many times** they entered: - `synchronize { }` enters, runs the block and exits, even on exceptions; - `enter`/`exit` and `try_enter` are the manual forms; - `mon_locked?` and `mon_owned?` report state. If the owner calls `synchronize` again, the count goes up and the call proceeds; the lock is released when the count returns to zero. A `Mutex` in the same situation raises `ThreadError` with `deadlock; recursive locking`. Ownership is per fiber: since Ruby 3.1 Monitor is fiber-safe, and a different fiber on the same thread counts as a different owner. ## MonitorMixin: locking built into a class ```ruby require "monitor" class HitLog include MonitorMixin def initialize super() # sets up the monitor @hits = Hash.new(0) end def record(path) = synchronize { @hits[path] += 1; trim if @hits.size > 1_000 } def trim = synchronize { @hits.delete(@hits.min_by { |_, n| n }.first) } end ``` `record` calls `trim` while holding the lock and `trim` synchronizes again; with a Monitor that is fine. The `super()` call matters: `MonitorMixin#initialize` creates the internal monitor, and a class that defines `initialize` must call `super`. Objects can also opt in individually with `obj.extend(MonitorMixin)`. ## Thread::ConditionVariable with a Mutex `Thread::ConditionVariable` (also `ConditionVariable`) lets a thread sleep until another thread says the state has changed: | Method | Effect | |---|---| | `wait(mutex, timeout = nil)` | releases `mutex`, sleeps, reacquires `mutex` before returning | | `signal` | wakes the first waiting thread | | `broadcast` | wakes all waiting threads | Rules that are specific to the Ruby API: 1. The caller must hold `mutex` when calling `wait`; otherwise releasing it raises `ThreadError`. 2. `wait` may return spuriously, and after a `timeout` it returns even if nobody signalled, so the predicate is re-checked in a `while` loop. 3. The signalling thread should change the shared state and call `signal` while holding the same mutex. A minimal hand-off with a plain mutex: ```ruby lock = Mutex.new ready = Thread::ConditionVariable.new files = [] consumer = Thread.new do lock.synchronize do ready.wait(lock) while files.empty? files.shift end end lock.synchronize { files << "rates.csv"; ready.signal } consumer.value # => "rates.csv" ``` If the producer runs first, the consumer finds `files` non-empty and never waits; if the consumer runs first, it sleeps inside `wait` with the mutex released, so the producer can take it. Either order ends with the same result, which is the point of checking the predicate under the lock. ## MonitorMixin::ConditionVariable `monitor.new_cond` (or `new_cond` inside a `MonitorMixin` class) returns a condition variable bound to that monitor: - `wait(timeout = nil)` waits on the monitor, no mutex argument needed; - `wait_while { buffer.empty? }` loops while the block is true; - `wait_until { buffer.any? }` loops until the block is true; - `signal` and `broadcast` as before. The predicate loop is built in, which removes the most common hand-written mistake. ## Choosing | Need | Reach for | |---|---| | Short critical section, no nesting | `Mutex#synchronize` | | Methods that lock and call each other | `Monitor` / `MonitorMixin` | | Wait for a condition with a plain mutex | `Thread::ConditionVariable` in a `while` loop | | Wait for a condition with a monitor | `new_cond.wait_while` / `wait_until` | | Hand items from producers to consumers | `Thread::Queue` / `Thread::SizedQueue` | Reentrancy has a cost: it hides that a method was already inside the critical section, which can mask a design where too much happens under one lock. Choose it deliberately rather than as a way to silence a `ThreadError`.

  • What goes wrong if a class includes MonitorMixin and defines initialize without calling super?
    `MonitorMixin#initialize` is what creates the internal `Monitor`. Skip `super` and the object has no monitor, so the first `synchronize` fails with `NoMethodError` on nil. Call `super()` (with the arguments the parent expects), or call `mon_initialize` explicitly.
  • In Ruby, what does ConditionVariable#wait do with the mutex it is given?
    It releases the mutex, puts the thread to sleep until `signal`, `broadcast`, the optional timeout or a spurious wakeup, and reacquires the mutex before returning. The caller must hold the mutex when calling `wait`, and should re-check its condition in a `while` loop afterwards.

saying these in an interview costs you the question

  • A Ruby Mutex lets its owner lock it again, like Monitor does.
  • Monitor#synchronize raises ThreadError when its owner enters again.
  • ConditionVariable#wait keeps holding the mutex while it sleeps.
  • After ConditionVariable#wait returns, the condition is guaranteed true.
  • MonitorMixin works even if initialize never calls super.