Introduction
Exceptions in concurrent code behave differently than in sequential programs. A raised exception in a thread, fiber, or ractor does not automatically crash your program — it might vanish silently, propagate unexpectedly, or leave resources in a broken state. Understanding these differences is essential for building reliable concurrent systems.
Key Concepts
- Exception propagation: How and when an exception raised in a concurrent context becomes visible to the caller.
- Supervision: A pattern where a parent process monitors child workers and restarts them on failure.
- Graceful shutdown: Stopping concurrent workers cleanly so that in-flight work finishes and resources are released.
- Ensure block: Ruby's
ensurekeyword guarantees cleanup code runs whether or not an exception occurred.
Real World Context
In a background job processor, a single unhandled exception in a worker thread can silently corrupt a job queue, leaving tasks permanently stuck. In production, every concurrent worker must have explicit error handling, logging, and cleanup — otherwise failures become invisible and debugging turns into guesswork.
Deep Dive
Thread Exception Behavior
By default, Ruby threads swallow exceptions. The exception is stored on the thread object but never surfaces unless you explicitly check:
rubythread = Thread.new do raise "Something broke!" end # The main thread continues as if nothing happened sleep 0.1 puts "Main thread is fine" # This prints! # To see the error, call join or value: thread.join # Re-raises "Something broke!" in the main thread
After calling join, the stored exception propagates to the caller. Alternatively, thread.value both waits for completion and re-raises any exception.
You can change the default behavior globally:
ruby# Make ALL thread exceptions abort the program Thread.abort_on_exception = true # Or per-thread: thread = Thread.new { raise "boom" } thread.abort_on_exception = true
This is useful during development but risky in production where you want controlled error handling.
Ractor Exception Behavior
Ractors propagate exceptions through the port system. When a Ractor raises, the exception is delivered when you call value or receive on it:
rubyractor = Ractor.new do raise "Ractor failed!" end begin ractor.value rescue Ractor::RemoteError => e puts e.message # => "Ractor failed!" puts e.cause # => #<RuntimeError: Ractor failed!> end
Notice that the exception is wrapped in Ractor::RemoteError. The original exception is accessible via the cause method. This wrapping exists because the exception object must cross the Ractor isolation boundary.
Fiber Exception Behavior
Fibers propagate exceptions directly to the caller of resume:
rubyfiber = Fiber.new do raise "Fiber error!" end begin fiber.resume rescue RuntimeError => e puts "Caught: #{e.message}" # => "Caught: Fiber error!" end
This is the most intuitive model — exceptions propagate exactly as if the fiber's code ran inline at the point of resume.
Supervision Pattern
For long-running workers, wrap each unit of work in its own error handler so one failure does not kill the entire worker:
rubydef supervised_worker(name, queue) Thread.new do loop do job = queue.pop break if job == :shutdown begin process_job(job) rescue StandardError => e logger.error("Worker #{name} failed on job #{job.id}: #{e.message}") logger.error(e.backtrace.first(5).join("\n")) # Worker continues processing the next job end end end end
The rescue inside the loop catches errors for individual jobs while the worker thread stays alive to process more work.
Cleanup with ensure
Always use ensure to release resources in concurrent code. Without it, an exception can leave file handles, database connections, or locks dangling:
rubydef fetch_with_cleanup(url) connection = open_connection(url) connection.get("/data") rescue IOError => e logger.error("Connection failed: #{e.message}") nil ensure connection&.close # Always runs, even if an exception occurred end
The ensure block runs regardless of whether the method succeeds, raises, or is interrupted. The safe navigation operator &. handles the case where connection was never assigned.
Common Pitfalls
- Forgetting to join threads — If you never call
joinorvalue, thread exceptions are silently lost. Always join threads you care about, or setabort_on_exceptionduring development. - Rescuing Exception instead of StandardError — Catching
ExceptionswallowsInterruptandSystemExit, making your program impossible to kill with Ctrl+C. Always rescueStandardErrorunless you have a specific reason. - Missing ensure in locked sections — If an exception occurs while a Mutex is held and you do not release it in
ensure, other threads deadlock waiting for the lock forever.
Best Practices
- Log before rescuing — Always log the exception message and backtrace before swallowing an error. Silent failures are the hardest bugs to diagnose.
- Use structured error handling per worker — Wrap each job or request in its own begin/rescue/ensure so one failure does not take down the whole pool.
- Propagate fatal errors upward — Re-raise exceptions that indicate unrecoverable conditions (out of memory, configuration errors) rather than retrying endlessly.
Summary
- Threads swallow exceptions by default; always call
joinorvalueto surface them. - Ractor exceptions are wrapped in
Ractor::RemoteErrorand delivered throughvalue. - Fiber exceptions propagate directly to the caller of
resume. - Use the supervision pattern to keep workers alive after individual failures.
- Always use
ensurefor resource cleanup in concurrent code.
Code Examples
# Supervised thread pool with per-job error handling
class SupervisedPool
def initialize(size, logger:)
@queue = Thread::Queue.new
@logger = logger
@threads = size.times.map { |i| spawn_worker(i) }
end
def schedule(&block)
@queue << block
end
def shutdown
@threads.size.times { @queue << :stop }
@threads.each(&:join)
end
private
def spawn_worker(id)
Thread.new do
while (job = @queue.pop) != :stop
begin
job.call
rescue StandardError => e
@logger.error("Worker #{id}: #{e.message}")
end
end
end
end
end
# Usage:
# pool = SupervisedPool.new(4, logger: Logger.new($stdout))
# pool.schedule { process_order(order) }
# pool.shutdown