skip to content

In Ruby, what does Mutex#synchronize do and return, and why is it preferred over calling lock and unlock yourself?

level: juniorimportance: must knowfreq 66%

answer

  1. Thread::Mutex, also plain Mutex
  2. returns the block's value
  3. unlocks even when the block raises
  4. try_lock returns true or false
  5. re-locking raises ThreadError

basics

~10 s

Mutex#synchronize locks the mutex, runs the block, unlocks it even if the block raises, and returns the block's value. Manual lock/unlock can leave the mutex locked forever when an exception skips the unlock.

solid answer

~40 s

`Thread::Mutex` (also reachable as `Mutex`) lets one thread at a time run a critical section. `mutex.synchronize { ... }` acquires the lock, waiting if another thread holds it, runs the block, **always** releases the lock afterwards, including when the block raises or `break`s, and returns the block's value, so `total = lock.synchronize { @hits }` works. Calling `lock` and `unlock` by hand needs a `begin`/`ensure` to be equally safe; forget it and one exception leaves the mutex held, so every later caller blocks. The other methods matter at the edges: `try_lock` returns `true` or `false` immediately, `locked?` and `owned?` report state, locking a mutex you already hold raises `ThreadError` ("deadlock; recursive locking"), and unlocking one you do not hold raises `ThreadError` too.

code

ruby · 14 lines
ruby
lock = Mutex.new

lock.synchronize { 21 * 2 }   # => 42 (block value)

begin
  lock.synchronize { raise "boom" }
rescue RuntimeError
end
lock.locked?                  # => false (released despite the error)

lock.lock
lock.try_lock                 # => false (already held)
lock.owned?                   # => true
lock.lock                     # ThreadError: deadlock; recursive locking

go deeper

for a junior

Recall that synchronize runs the block under the lock, always releases it, and returns the block's value; know that Mutex needs no require.

for a middle

Explain why hand-written lock/unlock needs ensure, what try_lock, owned? and locked? return, and why re-locking raises ThreadError.

for a senior

Show you keep critical sections short, avoid I/O under a lock, and restructure nested locking instead of reaching for a reentrant lock by reflex.

for a principal

Set conventions for shared state in a codebase: one owner and one lock per resource, and when to prefer message passing with queues over locks.

## What a Ruby Mutex is `Thread::Mutex` is Ruby's core mutual-exclusion lock. The class is also bound to the top-level constant `Mutex`, so `Mutex.new` and `Thread::Mutex.new` are the same thing, and no `require` is needed. Code wrapped in the same mutex runs in **one thread at a time**; other threads that want it wait. ```ruby class HitCounter def initialize @hits = 0 @lock = Mutex.new end def record = @lock.synchronize { @hits += 1 } def total = @lock.synchronize { @hits } end ``` ## synchronize: the default way to lock `Mutex#synchronize` does four things: 1. Acquires the lock, suspending the calling thread while another thread holds it. 2. Runs the block. 3. Releases the lock when the block finishes, whether it returns normally, raises, or exits with `break` or `return`. 4. Returns the block's value. Calling it without a block raises `ThreadError` ("must be called with a block"). Because release is guaranteed, `synchronize` makes it hard to leak a held lock, and because it returns the block's value, reads fit on one line: `@lock.synchronize { @hits }`. ## lock and unlock by hand The lower-level methods exist for cases where the critical section does not fit one block: ```ruby @lock.lock begin @hits += 1 ensure @lock.unlock end ``` Without the `ensure`, an exception between `lock` and `unlock` leaves the mutex locked. Every later call to `lock` or `synchronize` then waits forever, and the symptom (a stuck thread, far from the original error) is hard to trace. ## The full method set | Method | Returns | Raises | |---|---|---| | `synchronize { }` | the block's value | `ThreadError` without a block | | `lock` | the mutex | `ThreadError` if the current thread (fiber) already holds it | | `try_lock` | `true` if acquired, `false` at once if not | nothing | | `unlock` | the mutex | `ThreadError` if not locked, or locked by another thread or fiber | | `locked?` | whether any thread holds it | nothing | | `owned?` | whether the current thread holds it | nothing | | `sleep(timeout = nil)` | slept seconds, or `nil` on timeout | `ThreadError` if not held | ## Not reentrant A Ruby `Mutex` cannot be taken twice by its holder. If `record` calls another method that also does `@lock.synchronize`, the inner call raises `ThreadError` with the message `deadlock; recursive locking` instead of hanging. Two ways out: - restructure so only the public entry point locks, and private helpers assume the lock is held; - use `Monitor`, which counts nested entries by the same owner. Since Ruby 3.0 a mutex is owned by a **fiber**, not a thread. Two fibers on one thread therefore contend for it like two threads. Without a Fiber scheduler, a second fiber trying to lock it gets `ThreadError` (`lock already owned by another fiber belonging to the same thread`); with a scheduler, it waits like any other contender. ## Practical rules - Keep the block short: compute outside, lock only around reads and writes of shared state. - Never do slow I/O inside `synchronize` unless that I/O is what must be serialised; other threads queue behind it. - Give each piece of shared state one mutex and always use it; a second, unrelated mutex protects nothing. - Use `try_lock` only when doing something else is a real option, such as skipping a cache refresh another thread is already doing. ## Two less obvious behaviours **Signal handlers.** Code running inside a signal handler installed with `Signal.trap` may not take a mutex: `lock` and `synchronize` raise `ThreadError` ("can't be called from trap context"). The reason is that the interrupted code might already hold the same mutex, so waiting would deadlock the thread against itself. Handlers should do the minimum, such as pushing onto a queue or setting a flag, and leave locked work to a normal thread. **Mutex#sleep.** `mutex.sleep(timeout)` releases a mutex you hold, sleeps, and reacquires it before returning; it returns the number of seconds slept, or `nil` if the timeout expired. It is the primitive that `Thread::ConditionVariable#wait` builds on, and it raises `ThreadError` when called without holding the mutex. Application code rarely calls it directly. ## What interviewers listen for - that you name `synchronize` first and explain the guaranteed release; - that you know the return value, so reads under the lock are one-liners; - that you know a Ruby `Mutex` is not reentrant and what error nested locking produces.

  • What happens if a method holding a Ruby Mutex calls another method that synchronizes on the same Mutex?
    The inner `lock` sees that the current fiber already owns the mutex and raises `ThreadError` with `deadlock; recursive locking`. Ruby's `Mutex` is not reentrant. Either lock only at the public entry point and have helpers assume the lock is held, or switch to `Monitor`, which allows the owner to re-enter.
  • When is Mutex#try_lock a better choice than synchronize?
    When waiting is pointless because another thread is already doing the same work, for example refreshing a cached exchange rate. `if lock.try_lock` then `begin; refresh; ensure; lock.unlock; end`, otherwise serve the current value. `try_lock` never blocks: it returns `true` when it acquired the lock and `false` otherwise.

saying these in an interview costs you the question

  • Mutex#synchronize returns the mutex, not the block's value.
  • A Ruby Mutex can be locked again by the thread that holds it.
  • An exception inside synchronize leaves the mutex locked.
  • Mutex#try_lock waits a short time before giving up.
  • Any thread may unlock a mutex another thread locked.