skip to content

Concurrency & Parallelism

Doing Ruby work at the same time: threads under the GVL, Mutex and Queue, fibers with a scheduler, Ractors, concurrent-ruby and forked processes. Interviewers probe what truly runs in parallel.

on this pageshow

explore

questions

27

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.
open as a page

In CRuby, what is the GVL, and when do Ruby threads actually make a program run faster?

level: juniorimportance: must knowfreq 82%

basics

~20 s

The GVL (Global VM Lock) lets only one thread per Ractor execute Ruby code at a time. Threads speed up I/O-bound work, because blocking I/O and sleep release the lock, but not CPU-bound Ruby code.

open as a page

In Ruby, what do Thread.new, Thread#join and Thread#value return, and what happens to threads the main program never joins?

level: juniorimportance: must knowfreq 70%

basics

~20 s

Thread.new starts a block in a new thread and returns the Thread. join waits and returns that thread, or nil when its limit expires; value returns the block's result or re-raises its exception. Unjoined threads die when main exits.

open as a page

In Ruby, what is a zombie child process, and how do Process.wait, waitpid with WNOHANG and Process.detach each prevent one?

level: middleimportance: must knowfreq 48%

basics

~20 s

A zombie is a child that has exited but whose status the parent never collected. Process.wait(pid) blocks and reaps it, Process.wait(pid, Process::WNOHANG) reaps only if it already exited, and Process.detach(pid) starts a thread that reaps it for you.

open as a page

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%

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.

open as a page

In Ruby, how do Thread::Queue and Thread::SizedQueue coordinate a bounded download queue, and how do you shut its workers down?

level: middleimportance: must knowfreq 58%

basics

~20 s

Thread::Queue is a thread-safe FIFO whose pop blocks until an item arrives; Thread::SizedQueue also blocks push when full, bounding memory. Calling close lets workers drain the queue, after which pop returns nil and the workers exit.

open as a page

In Ruby, how do Fiber.new, Fiber#resume and Fiber.yield pass values back and forth, and what happens when you resume a finished fiber?

level: juniorimportance: should knowfreq 40%

basics

~20 s

Fiber.new creates a paused fiber. resume runs it until Fiber.yield, whose argument becomes resume's return value; the next resume's argument becomes Fiber.yield's return value. When the block ends, alive? is false and resuming raises FiberError.

open as a page

In Ruby, what does fork return in the parent and the child, with and without a block, and why does forking bypass the GVL?

level: juniorimportance: should knowfreq 45%

basics

~20 s

fork copies the running Ruby process. Without a block it returns the child's pid in the parent and nil in the child; with a block, only the child runs it and then exits. Each process has its own GVL, so children run Ruby in parallel.

open as a page

In Ruby, what does the concurrent-ruby gem add that core Thread, Mutex and Queue do not, and why do servers and frameworks depend on it?

level: juniorimportance: should knowfreq 35%

basics

~20 s

Core Ruby ships low-level primitives: Thread, Mutex, Queue, ConditionVariable, Monitor. The concurrent-ruby gem adds tested higher-level tools (thread pools, Concurrent::Promises futures, Concurrent::Map, atomic variables) with the same guarantees on every major Ruby implementation, so libraries build on it instead of hand-rolling them.

open as a page

With Ruby's async gem, how do Async and Sync let one thread serve thousands of chat-socket connections, and what still blocks every connection?

level: middleimportance: should knowfreq 36%

basics

~20 s

Async runs each connection as a task, a non-blocking fiber under a reactor that is the Fiber scheduler, so a socket wait parks only that task. Sync runs a block and returns its value. CPU-heavy work or blocking C calls stall every task.

open as a page

In CRuby, why can Ractors run CPU-bound Ruby code in parallel when threads in one Ractor cannot, and what does that isolation forbid?

level: middleimportance: should knowfreq 38%

basics

~20 s

CRuby gives each Ractor its own interpreter lock, so Ractors run on separate cores while threads inside one Ractor still take turns. The price is isolation: unshareable objects, global variables and class variables cannot be touched across Ractors.

open as a page

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%

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.

open as a page

In Ruby, what happens when an exception escapes a thread's block, and what do report_on_exception and abort_on_exception change?

level: middleimportance: should knowfreq 50%

basics

~20 s

The thread dies while others keep running. report_on_exception (default true) prints the error to $stderr at that moment; join or value re-raise it later. abort_on_exception (default false) re-raises it in the main thread immediately instead.

open as a page

In Ruby, how does Thread.current[:key] differ from Thread.current.thread_variable_get(:key), and when does the difference bite?

level: middleimportance: should knowfreq 32%

basics

~20 s

Thread#[] and #[]= are fiber-local: each fiber on a thread has its own store, so code running in another fiber sees nil. thread_variable_get and thread_variable_set are truly thread-local, shared by every fiber on that thread.

open as a page

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?

level: middleimportance: should knowfreq 30%

basics

~20 s

Concurrent::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.

open as a page

In Ruby with concurrent-ruby, how do Concurrent::Promises.future, zip and then run tasks concurrently and combine results, and what does value return on failure?

level: middleimportance: should knowfreq 40%

basics

~20 s

Concurrent::Promises.future { ... } starts a task immediately on the global :io pool; Promises.zip(a, b) waits for all and then { |x, y| ... } receives the values. On rejection value returns nil silently, while value! raises the error.

open as a page

In Ruby, what does Fiber.set_scheduler do, and how does a non-blocking fiber's socket read become a wait the scheduler can multiplex?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Fiber.set_scheduler installs an object that Ruby calls whenever a non-blocking fiber would block: a socket not ready calls io_wait, sleep calls kernel_sleep. The scheduler parks that fiber, runs others, and resumes it when the event loop reports readiness.

open as a page

A preforking Ruby job runner's workers grow toward the parent's full size soon after fork; why does copy-on-write sharing erode, and how does Process.warmup help?

level: seniorimportance: should knowfreq 40%

basics

~20 s

After fork, pages stay shared only until a process writes to them, and CRuby writes to heap pages constantly: allocating into free slots, promoting survivors to the old generation, filling string caches. Process.warmup does that work once in the parent before forking.

open as a page

In Ruby, what makes an object Ractor-shareable, and how do Ractor.make_shareable and the shareable_constant_value comment fix a constant Ractors cannot read?

level: seniorimportance: should knowfreq 28%

basics

~20 s

An object is shareable when it and everything it references is frozen, or it is inherently shareable (Integers, Symbols, classes). Ractor.make_shareable deep-freezes a graph, and # shareable_constant_value: literal does that for constants assigned from literals.

open as a page

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?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Not 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.

open as a page

Why is Ruby's Timeout.timeout risky around code that holds connections or runs ensure blocks, and what do you use instead?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Timeout.timeout makes a watcher thread raise into your thread with Thread#raise, so the exception can land on any line: mid-write, inside ensure cleanup, while a lock is held. Prefer the timeouts built into the I/O call itself.

open as a page

In concurrent-ruby, how do Concurrent::ThreadPoolExecutor and FixedThreadPool differ, and how would you configure one for sending push notifications safely?

level: seniorimportance: should knowfreq 32%

basics

~20 s

FixedThreadPool.new(n) is a ThreadPoolExecutor with min_threads and max_threads both n and an unbounded queue. For push notifications, bound the queue with max_queue, choose a fallback_policy, handle errors inside each task, and shutdown plus wait_for_termination before exit.

open as a page

In Ruby 4.0, how do you start a Ractor with input, wait for it to finish, and read its block's return value?

level: juniorimportance: nice to knowfreq 22%

basics

~10 s

Call Ractor.new(input) { |x| ... }; the arguments reach the isolated block like messages. r.join waits and returns the Ractor, r.value waits and returns the block's last value, and a crash surfaces as Ractor::RemoteError.

open as a page

In Ruby 3.2+, how does Fiber[] storage behave across new fibers, threads and Enumerator#next, and why was it added beside Thread.current[]?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

Fiber[:key] reads and Fiber[:key] = value writes the current fiber's storage. Each new fiber or thread starts with a copy of its creator's storage, so values flow down into enumerator fibers and tasks, but a child's writes never reach the parent.

open as a page

In Ruby, how do you run code before and after every fork with Process._fork, and why is overriding Kernel#fork not enough?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

Process._fork is the internal method that Kernel#fork, Process.fork and IO.popen("-") all call. Prepend a module to Process.singleton_class that overrides _fork, calls super and branches on the result: 0 in the child, the child's pid in the parent.

open as a page

In Ruby 4.0, how do Ractors exchange messages with Ractor::Port and Ractor.select now that Ractor.yield and Ractor#take are gone?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

A Ractor::Port is a mailbox: any Ractor can send (or <<) to it without blocking, but only the Ractor that created it can receive. Ractor.select(*ports) waits on several ports or Ractors and returns [source, message].

open as a page

In concurrent-ruby, when do you use AtomicFixnum versus AtomicReference, and what must an AtomicReference#update block never do?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

AtomicFixnum holds one Integer with atomic increment, decrement and compare_and_set. AtomicReference holds any object and swaps it atomically. Its update block may run several times, so it must be side-effect free and return a new object rather than mutate the old one.

open as a page