Threads and the GVL

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. A thread is an independent flow of control inside one Ruby process: it has its own call stack and its own local state, and it shares every object, constant, global variable and class with the other threads. The reference implementation, CRuby (also called MRI), schedules Ruby threads onto operating-system threads but lets only one of them run Ruby code at a time, using a lock that the community calls the GVL (global VM lock, historically the GIL). This page covers the whole picture: creating and ending threads, what happens when a thread fails, thread-local and fiber-local storage, what the GVL does and does not guarantee (the difference between I/O-bound and CPU-bound work, and the M:N scheduler of Ruby 3.3), the synchronization tools (Mutex, Monitor, ConditionVariable, Queue and SizedQueue), deadlocks, interrupting threads (Thread#raise, Thread.handle_interrupt, Timeout), and why ||= memoization and class-level state need care. Alternative implementations such as JRuby and TruffleRuby run threads in parallel without a GVL; everything below describes CRuby.

Threads are one of three concurrency tools in Ruby 4.0. Fibers, the cooperative building block behind non-blocking I/O, are in Fibers and the Fiber Scheduler; Ractors, the way to run Ruby code in parallel inside one process, are in Ractors. Separate processes, signals and fork are in Processes, Signals and Shell, and the exception classes used here are introduced in Exceptions and Error Handling. For the same ideas in other languages see Python concurrency and async (which has a GIL of its own), Java virtual threads, PHP fibers, Rust threads and message passing, Go goroutines and the scheduler and C# threads and synchronization.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings). The output shown in # => comments, and in the output blocks, is the real output; a few outputs depend on timing or on the machine (they are marked) and were produced on a ten-core Mac. Thread inspect strings show an address, which the output blocks abbreviate as 0x.... Each file is self-contained so it can be run on its own, and the ones that need the running shelf scenario carry a trimmed copy of the class they use.

Concurrency in Ruby at a glance

Ruby has four ways to do more than one thing at a time. They differ in what they share, who decides when to switch, and whether Ruby code runs in parallel.

Tool Isolation and sharing Who switches Parallel Ruby code?

Process (fork, spawn)

Separate memory; communicate through pipes, sockets or files.

The operating system.

Yes, on several cores.

Thread (Thread)

Share all objects of the process.

The VM, on a timer and whenever a thread blocks.

No in CRuby: one thread runs Ruby code at a time (the GVL). Blocking calls and some native code run in parallel.

Fiber (Fiber)

Share all objects of their thread.

The program, explicitly (resume and Fiber.yield), or a scheduler for non-blocking fibers (3.0).

No: fibers of one thread never run at the same instant.

Ractor (Ractor)

Isolated heaps; only shareable objects cross the boundary, by message passing.

The VM, per Ractor.

Yes, one GVL per Ractor. Experimental in 4.0.

The rule of thumb that follows from the table: use threads when the program spends its time waiting (network, disk, a subprocess, a database), use processes or Ractors when it spends its time computing, and use fibers with a scheduler when there are so many concurrent waits that one operating-system thread per wait is too heavy. Threads are still the right default for I/O concurrency in a typical program, because every blocking call in the core library and in most extensions releases the GVL while it waits.

Creating and ending threads

Thread.new, start and fork

Thread.new starts a new thread that runs the block immediately and returns the Thread object. Thread.start and Thread.fork do the same (they are aliases of each other) but skip the subclass’s initialize; Thread.new without a block raises ThreadError (must be called with a block). Arguments given to Thread.new are passed to the block as parameters, which is the safe way to hand a thread its own value: a block that closes over a loop variable shares that variable with the loop.

Thread#join waits for the thread to finish and returns the thread; join(limit) waits at most limit seconds and returns nil on a timeout, leaving the thread running. Thread#value joins the thread and returns the value of its block (the last expression evaluated), so it is the natural way to collect a result. Both re-raise, in the calling thread, any exception that ended the target thread (covered below). A thread that nobody joins is simply killed when the main thread ends: the program does not wait for it.

The example looks up two ISBNs on two threads, waits with a limit, and shows the status values of finished and failed threads.

# frozen_string_literal: true

# Thread.new starts the block at once on a new thread; the arguments are
# passed to the block, which keeps them out of the closure.
isbns = %w[9780306406157 9780140449136]
lookups = isbns.map do |isbn|
  Thread.new(isbn) do |digits|
    Thread.current.name = "lookup-#{digits[-4..]}"
    sleep 0.01
    digits.reverse
  end
end

lookups.map(&:value) # => ["7516046030879", "6319440410879"]
lookups.map(&:name) # => ["lookup-6157", "lookup-9136"]
lookups.map(&:status) # => [false, false]
lookups.map(&:alive?) # => [false, false]

# join(limit) gives up after the limit and returns nil; the thread keeps running.
slow = Thread.new { sleep 0.2 }
slow.join(0.01) # => nil
slow.status # => "sleep"
slow.join # => #<Thread:0x... thread_basics.rb:21 dead>
slow.status # => false

# A thread that ends with an exception reports nil from status.
failed = Thread.new do
  Thread.current.report_on_exception = false
  raise IOError, "disk full"
end
sleep 0.05
failed.status # => nil
begin
  failed.join
rescue IOError => e
  e.message # => "disk full"
end

Thread.main.equal?(Thread.current) # => true
Thread.list.size # => 1

Thread names (Thread#name=) appear in inspect and in the failure report, which makes them worth setting for any long-lived thread. Thread#native_thread_id returns the operating-system id of the thread that currently backs a Ruby thread, which is what profilers and top -H show.

Status and lifecycle

Thread#status returns a string while the thread is alive and false or nil afterwards. Thread#alive? is true until the thread ends, and Thread#stop? is true when the thread is dead or sleeping.

status Meaning

"run"

The thread is running, or is ready to run and waiting for the GVL.

"sleep"

The thread is sleeping, blocked on I/O, a lock, a queue or a join, or stopped with Thread.stop.

"aborting"

The thread is being killed and is running its ensure clauses.

false

The thread finished normally (or was killed).

nil

The thread ended with an exception.

Threads can be moved between those states from outside. Thread.stop puts the current thread to sleep until another thread calls wakeup on it (which only marks it eligible) or run (which also schedules it right away). Thread.pass is a hint that the scheduler may switch to another thread; the interpreter may or may not do so. Thread#priority= is another hint, in the range -3 to 3 and ignored on some platforms, so a program must never depend on it for correctness. Thread#kill, Thread#exit, Thread#terminate and Thread.exit all end a thread and run its ensure clauses. A ThreadGroup collects threads for bulk operations (ThreadGroup#list); every new thread joins the group of the thread that created it.

# frozen_string_literal: true

# status: "run" while running, "sleep" while blocked or sleeping, false when finished
# normally, nil when ended by an exception, "aborting" while being killed.
napper = Thread.new { Thread.stop }
Thread.pass until napper.stop?
napper.status # => "sleep"
napper.stop? # => true
napper.alive? # => true

# wakeup marks the thread runnable; run also schedules it right away.
napper.wakeup.equal?(napper) # => true
napper.join
napper.status # => false

Thread.current.status # => "run"
Thread.main.priority # => 0

# Threads belong to a ThreadGroup, inherited from the creating thread.
group = ThreadGroup.new
member = Thread.new { sleep }
group.add(member).equal?(group) # => true
group.list.first.equal?(member) # => true
member.group.equal?(group) # => true
ThreadGroup::Default.list.include?(Thread.main) # => true
member.kill
member.join

# A name makes inspect and debugging output readable.
named = Thread.new { sleep }
named.name = "returns-desk"
Thread.pass until named.stop?
puts named.inspect.sub(/0x\h+/, "0x...") # prints #<Thread:0x...@returns-desk thread_lifecycle.rb:30 sleep>
named.kill

# Thread.exit, Thread.current.exit and Thread#kill all end the target thread.
Thread.new { Thread.exit }.value # => nil

Output of the puts line (the address varies):

#<Thread:0x...@returns-desk thread_lifecycle.rb:30 sleep>

Two lifecycle rules are worth remembering. The main thread’s end ends the program, and all other threads are killed without being joined (their ensure clauses do run, but a program that needs a thread’s work to finish must join it). And after fork, only the thread that called fork exists in the child process; the other threads vanish, and a lock that one of them held can never be released in the child. The next example makes the difference between a process and a thread visible: the child changes its own copy of the counter, the thread changes the shared one.

# frozen_string_literal: true

# Processes have separate memory; threads share it. After fork, only the thread
# that called fork exists in the child.
counter = 0
helper = Thread.new { sleep }
Thread.pass until helper.stop?

reader, writer = IO.pipe
pid = fork do
  reader.close
  counter += 1
  writer.puts "child: threads=#{Thread.list.size}, counter=#{counter}"
  writer.close
end
writer.close
puts reader.read
Process.wait(pid)
puts "parent: threads=#{Thread.list.size}, counter=#{counter}"

thread_counter = 0
Thread.new { thread_counter += 1 }.join
puts "after a thread: counter=#{thread_counter}"

Output:

child: threads=1, counter=1
parent: threads=2, counter=0
after a thread: counter=1

Exceptions in threads

An exception that is not rescued inside a thread ends only that thread. It does not propagate to other threads by default: the exception is stored in the Thread object and raised again, in whichever thread calls join or value on it. Because a thread that nobody joins can fail silently, Ruby reports the failure on standard error the moment the thread dies. The report is controlled by report_on_exception, which is true by default (since Ruby 2.5), and exists as a global setting (Thread.report_on_exception=, which sets the default for threads created afterwards) and as a per-thread setting (Thread#report_on_exception=, normally set as the first statement of the thread’s block).

A second switch, abort_on_exception, goes the other way and re-raises the exception of a dying thread in the main thread at once. It exists globally (Thread.abort_on_exception=), per thread (Thread#abort_on_exception=), and is also turned on by the -d command-line option or $DEBUG = true. It is useful in short scripts and tests where any failing thread should stop the whole program, and it is rarely appropriate in a server, where the main thread would be interrupted at an arbitrary point.

The same program shows the four situations. The report lines on standard error come from case 1, where the failure is both reported and re-raised by join.

# frozen_string_literal: true

# Standard output and standard error are separate streams; unbuffered output keeps them in order.
$stdout.sync = true

def attempt(label)
  yield
rescue StandardError => e
  puts "#{label}: #{e.class}: #{e.message}"
end

# 1. By default the error is reported on $stderr as soon as the thread dies,
#    and raised again in whoever calls join or value.
worker = Thread.new { Integer("9780306406157x") }
attempt("join re-raises") { worker.join }
puts "status after the error: #{worker.status.inspect}"

# 2. report_on_exception = false silences the report; the error still waits in the thread.
Thread.report_on_exception # => true
quiet = Thread.new do
  Thread.current.report_on_exception = false
  raise KeyError, "no such shelf"
end
attempt("quiet join") { quiet.join }
attempt("quiet value") { quiet.value }

# 3. abort_on_exception = true re-raises the error in the main thread at once.
attempt("abort_on_exception") do
  Thread.new do
    Thread.current.abort_on_exception = true
    Thread.current.report_on_exception = false
    raise IOError, "catalogue unreachable"
  end
  sleep 1
end

# 4. Thread#raise delivers an exception to another thread, and Thread#kill ends it.
sleeper = Thread.new do
  sleep
rescue RuntimeError => e
  "woken by #{e.message}"
end
Thread.pass until sleeper.stop?
sleeper.raise("wake up")
sleeper.value # => "woken by wake up"

The output, with standard error and standard output merged (the thread inspect address varies):

#<Thread:0x... thread_errors.rb:14 run> terminated with exception (report_on_exception is true):
thread_errors.rb:14:in 'Kernel#Integer': invalid value for Integer(): "9780306406157x" (ArgumentError)

worker = Thread.new { Integer("9780306406157x") }
                              ^^^^^^^^^^^^^^^^
	from thread_errors.rb:14:in 'block in <main>'
join re-raises: ArgumentError: invalid value for Integer(): "9780306406157x"
status after the error: nil
quiet join: KeyError: no such shelf
quiet value: KeyError: no such shelf
abort_on_exception: IOError: catalogue unreachable

A few details of that output are worth reading closely. The first line names the thread by its inspect string and tells which setting produced the report. The next lines are the ordinary uncaught-exception report, with the error-highlight excerpt that Ruby 3.1 added. status is nil (not false) because the thread ended with an exception. And case 3 shows why abort_on_exception must be used with care: the IOError from the worker was raised inside the main thread’s sleep.

The practical rules are short. Rescue and handle what you can inside the thread. Always join, or collect value, from every thread whose failure matters, so the error reaches code that can act on it. Turn the report off only for a thread that will certainly be joined. And never rely on Thread.abort_on_exception = true in library code, because it changes the behaviour of every thread in the process, including threads created by other libraries.

Thread-local and fiber-local storage

A thread often needs private state: the current request, a database connection, a user. Local variables are private to a thread automatically when they are defined inside the thread’s block, since the block is an ordinary closure. For state that has to be reachable from many methods without being passed along, Ruby offers three levels of "current" storage, and the names are confusing enough to deserve a table.

API Scope Notes

Thread#[], Thread#[]=, key?, keys, fetch

One thread and one fiber: a fiber-local.

Despite the class it is stored on, each fiber has its own bucket. A new fiber sees nothing the thread stored.

thread_variable_get, thread_variable_set, thread_variable?, thread_variables

One thread, shared by all of its fibers: a true thread-local.

Use these when the value must be visible from every fiber of the thread.

Fiber[], Fiber[]=, Fiber#storage, Fiber.new(storage:) (3.2)

Inheritable storage: copied into every fiber and thread created afterwards.

The unit is an execution context, for example one request. The copy is made at creation, so changes in a child are not visible to the parent. Fiber#storage is experimental.

The example shows all three. The fiber sees the thread-local but not the fiber-local, a new thread sees neither, and Fiber storage flows into both child fibers and child threads.

# frozen_string_literal: true

# Thread#[] is fiber-local despite its name; thread_variable_get is thread-local.
Thread.current[:request] = "main-fiber"
Thread.current.thread_variable_set(:user, "alice")

fiber = Fiber.new do
  [Thread.current[:request], Thread.current.thread_variable_get(:user)]
end
fiber.resume # => [nil, "alice"]

# A new thread starts with empty locals of both kinds.
Thread.new { [Thread.current[:request], Thread.current.thread_variable_get(:user)] }.value # => [nil, nil]

Thread.current.key?(:request) # => true
Thread.current.keys # => [:request]
Thread.current.thread_variable?(:user) # => true
Thread.current.thread_variables # => [:user]
Thread.current.fetch(:request) # => "main-fiber"
Thread.current.fetch(:missing, "default") # => "default"

# Fiber storage (3.2) is inherited by the fibers and threads created afterwards.
Fiber[:request_id] = "req-42"
Fiber.new { Fiber[:request_id] }.resume # => "req-42"
Thread.new { Fiber[:request_id] }.value # => "req-42"

# Inheritance is a copy: a child that assigns does not change its parent.
Thread.new { Fiber[:request_id] = "child" }.join
Fiber[:request_id] # => "req-42"

# Fiber.new(storage: nil) starts with no storage at all.
Fiber.new(storage: nil) { Fiber[:request_id] }.resume # => nil
Fiber.current.storage # => {request_id: "req-42"}

Which one to pick follows from the question "what is the unit of work?". A request handled by a thread-per-request server is a thread, but a server that runs each request in a fiber (or a framework that moves work between fibers) makes Thread#[] the right choice only if the data must not leak into other fibers of the same thread. Per-thread caches that survive across fibers, such as a reusable buffer, are thread_variable_get. A request id or a logging level that should follow the work into the fibers and threads that the request spawns is Fiber storage, which exists exactly for that purpose (the 3.2 release notes name connection pools, request ids, loggers and configuration as its uses). Be aware that all three are global state with a narrower scope, so a method that reads them has a hidden input: pass values explicitly where it is cheap, and keep the storage for cross-cutting concerns. The application architecture page returns to this trade-off.

Ractors have a fourth level: Ractor[] and Ractor[]= (3.4) store per-Ractor values, and Ractor.store_if_absent initializes them safely (see Ractors).

The GVL

What the GVL is

CRuby protects its internal data structures (the object heap, the method caches, the garbage collector) with a lock. In the thread-based model that lock is the GVL: a thread must hold it to execute Ruby code, and the VM passes it from thread to thread. Two events make the running thread give it up:

  • The thread blocks. Any blocking operation implemented by the core library (sleep, reading from a socket, waiting for a subprocess, Queue#pop on an empty queue, Mutex#lock on a held lock) releases the GVL for the duration of the wait, so other threads run meanwhile. This is why threads give real concurrency for I/O.

  • The time slice ends. A timer thread interrupts the running thread when it has used its share, and the VM hands the GVL to another thread that is ready. A CPU-bound thread therefore does not starve its siblings, but it also does not run in parallel with them.

The next program starts two threads that only compute, and logs when each one gets control back after a gap. The gaps are the time slices (the numbers vary from run to run by a few milliseconds; they are about 100 ms on this machine).

# frozen_string_literal: true

log = Thread::Queue.new
stop = false
workers = %i[a b].map do |name|
  Thread.new do
    last = nil
    until stop
      now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      log << [name, now] if last.nil? || now - last > 0.02
      last = now
    end
  end
end
sleep 1
stop = true
workers.each(&:join)
log.close
events = Array.new(log.size) { log.pop }.sort_by(&:last)
base = events.first.last
turns = events.map { |name, time| "#{name}@#{((time - base) * 1000).round}ms" }
puts turns.first(8).join(" ")

Output (timing dependent):

a@0ms b@110ms a@220ms b@330ms a@443ms b@558ms a@668ms b@778ms

The figure shows the same mechanism for three threads, one of which spends most of its time waiting for I/O.

Timeline of three Ruby threads. The top row shows the owner of the GVL: A and B alternate, C holds it briefly twice. Threads A and B are CPU-bound and wait for the lock while the other runs. Thread C blocks in I/O, which releases the GVL, so A and B keep running until the I/O completes and C must wait for the current time slice to end.

Three consequences follow, and they are the corrections to a widespread folk model of Ruby concurrency:

  • Threads speed up waiting, not computing. Four threads that each wait 200 ms finish in about 200 ms; four threads that each compute for a second of pure Ruby take about four seconds, the same as one thread after the other, plus a little switching overhead.

  • "Adding more threads does not help throughput" is too strong. It is true for pure Ruby computation. It is false for programs that spend most of their time in blocking calls, which is most programs that talk to a network or a disk. A thread pool sized for the number of concurrent requests is a perfectly good design.

  • Native code can run in parallel. A C extension, or the core library, releases the GVL around a long native call so that other threads can use it: zlib compression, IO#read and IO#write, Process.wait and IO::Buffer#copy (3.4) are examples in the core and standard library. Whether the native code of a given gem does depends on how it is written, and the documentation of the gem is the only reliable source.

The measurement below puts numbers on the three cases: sleeping (standing in for a blocking read), pure Ruby arithmetic and zlib compression. Each runs four jobs serially and then on four threads, and the output is the ratio of the two times.

# frozen_string_literal: true

require "zlib"

# Pure Ruby CPU work: a naive checksum over many strings.
def checksum_work(rounds)
  rounds.times.sum { |i| (i.to_s * 3).sum }
end

# Waiting work: sleep stands in for a blocking read from a slow service.
def lookup_work = sleep(0.2)

# Native work that releases the GVL: compressing 8 MB of random bytes with zlib.
def compress_work(payload) = Zlib::Deflate.deflate(payload).bytesize

def seconds
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  yield
  Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
end

def in_threads(count, &)
  Array.new(count) { Thread.new(&) }.each(&:join)
end

jobs = 4
payload = Random.new(1).bytes(8_000_000)

wait_serial = seconds { jobs.times { lookup_work } }
wait_threads = seconds { in_threads(jobs) { lookup_work } }

ruby_serial = seconds { jobs.times { checksum_work(300_000) } }
ruby_threads = seconds { in_threads(jobs) { checksum_work(300_000) } }

zlib_serial = seconds { jobs.times { compress_work(payload) } }
zlib_threads = seconds { in_threads(jobs) { compress_work(payload) } }

puts format("waiting:   threads take %<r>.2f times the serial time", r: wait_threads / wait_serial)
puts format("pure Ruby: threads take %<r>.2f times the serial time", r: ruby_threads / ruby_serial)
puts format("zlib:      threads take %<r>.2f times the serial time", r: zlib_threads / zlib_serial)

Output (the exact ratios depend on the machine and the load; the shape does not):

waiting:   threads take 0.25 times the serial time
pure Ruby: threads take 1.03 times the serial time
zlib:      threads take 0.32 times the serial time

With a ratio near 0.25 the four jobs overlapped completely (waiting, and zlib, which releases the GVL); with a ratio near 1.0 they ran one after another (pure Ruby). A ratio above 1.0 for CPU-bound threads is normal and is the cost of the time-slice switching.

What the GVL does not give you

The GVL makes each call of a core C method indivisible with respect to the other Ruby threads: appending to an Array from eight threads loses no elements, and a Hash cannot be corrupted into a crash. Many programs lean on this, and it is the reason that simple counters and flags rarely misbehave in testing. But it is an implementation property, not a language guarantee, and it stops at the boundary of one method call. A sequence of calls, such as "check whether the key exists, then add it", can be interrupted between the calls, because the VM may switch threads at the end of any time slice, at a blocking call and at several other safe points. A single Ruby-level statement like count += 1 is a read, an add and a write, and the switch can land in between. JRuby and TruffleRuby run threads in parallel, so code that is only correct because of the GVL breaks there at once. The rule therefore does not change: shared mutable state needs a lock, and the next sections show the tools.

The GVL also relates to the other concurrency tools. Each Ractor has its own GVL, which is why Ractors can run Ruby code in parallel (the threads inside one Ractor still share that Ractor’s GVL). A fiber belongs to one thread, so it holds the thread’s turn and needs no lock for state that only that thread touches. And the Ruby 4.0 notes record that deadlocks when Ractors and threads were combined were fixed. The contributor documentation (contributing/concurrency_guide_md) describes the internal VM lock, the timer thread and the interrupt handling for people working on CRuby itself, and also states that "GVL" is better understood as a family of locks since Ractors exist.

The M:N thread scheduler (Ruby 3.3)

By default every Ruby thread is backed by one operating-system thread (a 1:1 model). Ruby 3.3 introduced an M:N scheduler, which multiplexes many Ruby threads (M) onto a smaller pool of native threads (N). It reduces the cost of programs with thousands of mostly sleeping threads: a thread blocked in Ruby no longer pins a native thread. The facts documented in the 3.3 release notes are:

  • In one Ractor only one thread can run at a time anyway, so for a program that uses only the main Ractor the M:N scheduler behaves as an M:1 scheduler.

  • Because it can be incompatible with C extensions, it is off by default on the main Ractor. Set the environment variable RUBY_MN_THREADS=1 to enable it.

  • On non-main Ractors it is always enabled and cannot be disabled.

  • The number of native threads N is set by RUBY_MAX_CPU (default 8). More native threads than N can exist, to serve the blocking operations that need one.

The probe below creates 200 sleeping threads and counts the operating-system threads of the process (using /proc on Linux and ps -M on macOS). It is run three times: by default, with RUBY_MN_THREADS=1, and with RUBY_MN_THREADS=1 RUBY_MAX_CPU=2.

# frozen_string_literal: true

# Counts operating-system threads: /proc on Linux, ps -M on macOS.
def native_threads
  if File.directory?("/proc/self/task")
    Dir.children("/proc/self/task").size
  else
    IO.popen(["ps", "-M", "-p", Process.pid.to_s], &:readlines).size - 1
  end
end

threads = Array.new(200) { Thread.new { sleep } }
Thread.pass until threads.all?(&:stop?)
puts "ruby threads: #{Thread.list.size}, native threads: #{native_threads}"

Output, by default:

ruby threads: 201, native threads: 202

Output with RUBY_MN_THREADS=1 (the same with RUBY_MAX_CPU=2 here, because all 200 threads are idle):

ruby threads: 201, native threads: 3

From Ruby code nothing changes: Thread.list still has 201 threads, and the program behaves identically. What changes is the resource use, and the scheduling of native threads that the operating system sees. The default remains the 1:1 model in Ruby 4.0, and a server should enable the M:N scheduler only after testing its C extensions with it. The variable and the other interpreter environment variables are listed in Ruby Command Line and Environment.

Sharing state safely

Race conditions and Mutex

A race condition is a result that depends on how the threads happened to be interleaved. The classic case is a read-modify-write sequence: two threads read the same value, both add one, both write, and one update is lost. Thread::Mutex (also reachable as the top-level alias Mutex) is the basic remedy: a mutual-exclusion lock that only one thread holds at a time. Mutex#synchronize takes the lock, runs the block, and releases the lock even when the block raises, which is why it is almost always preferable to a manual lock and unlock pair.

The example makes the race deterministic by calling Thread.pass between the read and the write (on a real workload the switch happens at an unpredictable moment, which is worse, because the bug is rare). Then it uses synchronize to fix it, and walks through the rest of the Mutex API.

# frozen_string_literal: true

# A check-then-act sequence is not atomic: the GVL can switch threads between
# the read and the write. Thread.pass makes the switch deterministic here.
class LoanCounter
  attr_reader :count

  def initialize
    @count = 0
    @mutex = Thread::Mutex.new
  end

  def unsafe_increment
    current = @count
    Thread.pass
    @count = current + 1
  end

  # The same sequence inside Mutex#synchronize cannot interleave.
  def safe_increment
    @mutex.synchronize { unsafe_increment }
  end
end

def run_threads(times, &)
  Array.new(times) { Thread.new(&) }.each(&:join)
end

unsafe = LoanCounter.new
run_threads(10) { 100.times { unsafe.unsafe_increment } }
unsafe.count.between?(1, 999) # => true

safe = LoanCounter.new
run_threads(10) { 100.times { safe.safe_increment } }
safe.count # => 1000

# Mutex API: lock state can be inspected, but never use it to decide what to do.
mutex = Thread::Mutex.new
mutex.locked? # => false
mutex.synchronize { [mutex.locked?, mutex.owned?] } # => [true, true]
mutex.try_lock # => true
mutex.try_lock # => false
mutex.unlock.equal?(mutex) # => true

# A Mutex is not reentrant: locking it twice in one thread raises ThreadError.
mutex.synchronize do
  mutex.synchronize { :never }
rescue ThreadError => e
  e.message # => "deadlock; recursive locking"
end

begin
  mutex.unlock
rescue ThreadError => e
  e.message # => "Attempt to unlock a mutex which is not locked"
end

# Mutex#sleep releases the lock while it sleeps and reacquires it on waking.
# It returns nil when the timeout elapsed, and the slept seconds when woken early.
mutex.synchronize do
  mutex.sleep(0.01) # => nil
  [mutex.locked?, mutex.owned?] # => [true, true]
end
Method Behaviour

synchronize { ... }

Locks, yields, and unlocks in an ensure. Returns the block’s value. Raises ThreadError if the current thread already holds the lock.

lock

Blocks until the lock is free, then takes it. Raises ThreadError on recursive locking (deadlock; recursive locking).

unlock

Releases the lock and returns the mutex. Raises ThreadError if the mutex is not locked, or is locked by another thread.

try_lock

Takes the lock if it is free and returns true; returns false at once if it is not. Never blocks.

locked?

Whether any thread holds the lock. Use it for diagnostics, never for decisions: the answer is stale as soon as it is returned.

owned?

Whether the current fiber holds the lock.

sleep(timeout = nil)

Releases the lock, sleeps, and takes the lock again before returning. Returns the slept time, or nil when the timeout elapsed.

The rules for using a Mutex well are the same in every language:

  • Protect data, not code. Decide which lock guards which variables, and take it around every access, reads included. A class that owns one piece of shared state owns one mutex, as Shelf::Catalog does.

  • Keep critical sections short and free of blocking calls and callbacks into code you do not control. In the Catalog the lock is held only to copy the array, never while the caller’s block runs.

  • A Mutex is not reentrant. A method that holds the lock must not call another method that takes it. Either split the method into a public one that locks and a private one that assumes the lock (as the example’s unsafe_increment does), or use a Monitor.

  • Take several locks in one fixed order, or take only one. Opposite orders are how deadlocks arise (see below).

A Mutex is acquired per fiber, not per thread, since Ruby 3.0, so that it works correctly with the fiber scheduler. A lock taken by one fiber cannot be unlocked or re-entered from another fiber of the same thread:

# frozen_string_literal: true

# Since Ruby 3.0 a Mutex belongs to a fiber, not to a thread.
mutex = Thread::Mutex.new
mutex.lock

other_fiber = Fiber.new do
  mutex.lock
rescue ThreadError => e
  e.message
end
other_fiber.resume # => "deadlock; lock already owned by another fiber belonging to the same thread"

mutex.owned? # => true
Fiber.new { mutex.owned? }.resume # => false
mutex.unlock

# Thread.ignore_deadlock (3.0) disables the "No live threads left" check.
Thread.ignore_deadlock # => false
Thread.report_on_exception # => true
Thread.abort_on_exception # => false

A last restriction applies to signal handlers. A trap block runs in the main thread, interrupting whatever it was doing, and it may not take a Mutex. The usual pattern is to push a message on a Queue (which is allowed in a trap handler) and let a normal thread act on it.

# frozen_string_literal: true

mutex = Thread::Mutex.new
events = Thread::Queue.new

# A signal handler runs in the main thread between instructions, where locking
# a Mutex is refused. Hand the work to a thread through a Queue instead.
Signal.trap("USR1") do
  mutex.synchronize { :never }
rescue ThreadError => e
  events << "mutex in trap: #{e.message}"
end

Signal.trap("USR2") { events << "queued from trap" }

Process.kill("USR1", Process.pid)
Process.kill("USR2", Process.pid)
2.times { puts events.pop }

Output:

mutex in trap: can't be called from trap context
queued from trap

Monitor and MonitorMixin

Monitor is a reentrant lock: the thread that owns it can enter it again, and every enter needs a matching exit. That removes the "method that locks calls method that locks" problem at the price of a little more overhead and a weaker guarantee (a method cannot assume that the state is consistent just because it holds the monitor, since an outer call may be in the middle of an update). Monitor lives in the monitor library, loaded with require "monitor": it is not part of the core (ruby --disable-gems -e Monitor fails with uninitialized constant Monitor) and only looks preloaded because RubyGems loads it. MonitorMixin adds the same operations to a class (synchronize, mon_enter, mon_exit, mon_locked?, mon_owned?, mon_try_enter) and to a single object through extend. A class that includes it must call super from its own initialize, because the mixin initializes its monitor there; without it the first synchronize fails with NoMethodError (undefined method 'synchronize' for nil).

MonitorMixin#new_cond builds a condition variable bound to the monitor, with wait, wait_while, wait_until, signal and broadcast; wait_while and wait_until re-check their block after each wake-up, so the loop that must always surround a condition wait is written for you.

# frozen_string_literal: true

require "monitor"

# A Monitor is a reentrant mutex: the owner may enter it again.
class ReturnDesk
  include MonitorMixin

  def initialize
    super # MonitorMixin#initialize must run
    @returned = []
    @arrived = new_cond
  end

  def give_back(title)
    synchronize do
      @returned << title
      @arrived.signal
    end
  end

  # Re-enters the monitor already held by this method.
  def give_back_all(titles)
    synchronize { titles.each { |title| give_back(title) } }
  end

  # wait_while releases the monitor while it waits and re-checks the block on every wake-up.
  def next_return
    synchronize do
      @arrived.wait_while { @returned.empty? }
      @returned.shift
    end
  end

  def size = synchronize { @returned.size }
end

desk = ReturnDesk.new
reader = Thread.new { desk.next_return }
desk.give_back_all(%w[Dune Emma Ulysses])
reader.value # => "Dune"
desk.size # => 2

monitor = Monitor.new
monitor.enter
monitor.mon_locked? # => true
monitor.mon_owned? # => true
monitor.try_enter # => true
monitor.exit
monitor.exit
monitor.mon_locked? # => false

begin
  monitor.exit
rescue ThreadError => e
  e.message # => "current fiber not owner"
end

# An existing object can be extended with the mixin instead of subclassing.
tally = [].extend(MonitorMixin)
tally.synchronize { tally << 1 }
tally.mon_locked? # => false

Use a plain Mutex when the locking is confined to one class and the methods are layered cleanly, and a Monitor when callbacks or inheritance make re-entry likely. Do not use a Monitor to hide an unclear design: a lock held across a callback that calls back into the object is a sign that the critical section is too large.

ConditionVariable

A condition variable lets a thread sleep until some state changes, without polling. A thread that cannot proceed calls ConditionVariable#wait(mutex, timeout = nil) while holding the mutex: wait releases the mutex and sleeps atomically, and takes the mutex again before it returns. Another thread changes the state under the same mutex and calls signal (wakes one waiter) or broadcast (wakes all of them). Three rules apply:

  • Wait in a loop that re-tests the condition (until ready), because a wake-up does not prove the condition is true: another thread may have consumed it first, and a wake-up can be spurious.

  • Change the state while holding the mutex, so that a waiter cannot test the condition, be about to sleep, and miss the signal.

  • A signal with no waiter is lost; the condition variable has no memory. The state, not the signal, carries the information.

# frozen_string_literal: true

# A gate that opens once: waiters sleep on a ConditionVariable until the flag changes.
class OpeningGate
  def initialize
    @mutex = Thread::Mutex.new
    @opened = Thread::ConditionVariable.new
    @open = false
    @log = []
  end

  attr_reader :log

  def wait_for_opening(name)
    @mutex.synchronize do
      # Always wait in a loop: wake-ups can be spurious, and another thread
      # may have changed the state again before this one got the mutex back.
      @opened.wait(@mutex) until @open
      @log << name
    end
  end

  def open
    @mutex.synchronize do
      @open = true
      @opened.broadcast
    end
  end

  def wait_with_timeout(seconds)
    @mutex.synchronize do
      @opened.wait(@mutex, seconds) # with a timeout, wait returns when time is up, signalled or not
      @open
    end
  end
end

gate = OpeningGate.new
gate.wait_with_timeout(0.01) # => false

readers = %w[ana ben cleo].map { |name| Thread.new { gate.wait_for_opening(name) } }
Thread.pass until readers.all?(&:stop?)
gate.open
readers.each(&:join)
gate.log.sort # => ["ana", "ben", "cleo"]

# signal wakes one waiter, broadcast wakes all of them. Calling either with no
# waiters is harmless but the signal is not remembered.
cv = Thread::ConditionVariable.new
cv.signal.equal?(cv) # => true
cv.broadcast.equal?(cv) # => true

In most programs a Queue is easier than a hand-written condition variable, because it already contains the lock, the state and the signalling for the very common "wait for the next item" case.

Queue and SizedQueue

Thread::Queue (alias Queue) is a thread-safe FIFO. push, << and enq add an item; pop, shift and deq remove one and block while the queue is empty. Thread::SizedQueue (alias SizedQueue) has a maximum size: push blocks while it is full, which is back-pressure, the way to stop a fast producer from running far ahead of a slow consumer and filling memory. Both have size, length, empty?, clear, num_waiting (how many threads are blocked on the queue) and close. SizedQueue also has max and max=.

Call Behaviour

pop

Blocks until an item is available. Returns nil when the queue is closed and empty.

pop(true)

Non-blocking: raises ThreadError (queue empty) instead of waiting.

pop(timeout: seconds)

Waits at most that long and returns nil on a timeout (3.2). timeout: 0 is a non-blocking poll.

push(item) on a SizedQueue

Blocks while the queue is full.

push(item, true)

Non-blocking: raises ThreadError (queue full).

push(item, timeout: seconds)

Waits at most that long and returns nil on a timeout (3.2).

close

Marks the queue as closed. Waiting consumers wake; later pop calls drain the remaining items and then return nil. Later push calls raise ClosedQueueError.

Queue.new(enumerable)

Creates a queue pre-filled with the items of any enumerable.

Closing a queue is the clean way to say "no more work". A consumer written as while (item = queue.pop) ends by itself when the producer closes the queue and the queue drains, so no sentinel value is needed. ClosedQueueError is a subclass of StopIteration, which has a surprising consequence: loop rescues StopIteration, so a loop { queue.push(x) } against a closed queue ends silently, as if the loop had finished normally. Queues cannot be frozen: freeze raises TypeError since Ruby 3.3, because a frozen queue could not be used.

The first example is the complete producer-consumer pipeline: one producer fills a job queue with ISBNs, three workers validate them, and the results travel back through a second queue. Closing the job queue ends the workers.

# frozen_string_literal: true

# Producer-consumer: one producer feeds a Queue, a pool of workers drains it.
ISBNS = %w[9780306406157 9780306406158 9780140449136 not-an-isbn 9781234567897].freeze

def valid_isbn?(value)
  digits = value.delete("-")
  return false unless digits.match?(/\A\d{13}\z/)

  sum = digits.chars.each_with_index.sum { |d, i| d.to_i * (i.even? ? 1 : 3) }
  (sum % 10).zero?
end

jobs = Thread::Queue.new
results = Thread::Queue.new

workers = Array.new(3) do
  Thread.new do
    # pop blocks while the queue is empty and returns nil once it is closed and drained.
    while (isbn = jobs.pop)
      results << [isbn, valid_isbn?(isbn)]
    end
  end
end

ISBNS.each { |isbn| jobs << isbn }
jobs.close
workers.each(&:join)
results.close

checked = []
while (pair = results.pop)
  checked << pair
end
checked.size # => 5
checked.to_h.select { |_, valid| valid }.keys.sort # => ["9780140449136", "9780306406157", "9781234567897"]

jobs.closed? # => true
jobs.empty? # => true
jobs.num_waiting # => 0
jobs.length # => 0

begin
  jobs << "9780306406157"
rescue ClosedQueueError => e
  e.message # => "queue closed"
end

The second example is a SizedQueue with a capacity of two. The producer pushes five items but blocks on the third, which the example observes, and the timeout variants and the non-blocking forms follow.

# frozen_string_literal: true

# A SizedQueue applies back-pressure: push blocks while the queue is full.
buffer = Thread::SizedQueue.new(2)
produced = []
producer = Thread.new do
  %w[a b c d e].each do |item|
    buffer.push(item)
    produced << item
  end
  buffer.close
end

Thread.pass until buffer.size == 2 && producer.stop?
produced.join # => "ab"
buffer.max # => 2
buffer.num_waiting # => 1

consumed = []
while (item = buffer.pop)
  consumed << item
end
producer.join
consumed.join # => "abcde"

# Timeouts (3.2): push and pop give up and return nil instead of blocking forever.
full = Thread::SizedQueue.new(1)
full.push(:first)
full.push(:second, timeout: 0.01) # => nil
full.pop # => :first
full.pop(timeout: 0.01) # => nil

empty = Thread::Queue.new
empty.pop(timeout: 0) # => nil
empty.push(1)
empty.pop(timeout: 0) # => 1

# non_block = true raises instead of waiting.
begin
  empty.pop(true)
rescue ThreadError => e
  e.message # => "queue empty"
end

begin
  full.push(:a, true)
  full.push(:b, true)
rescue ThreadError => e
  e.message # => "queue full"
end

# A Queue can be created from any enumerable, and a closed queue refuses new items.
Thread::Queue.new([1, 2, 3]).size # => 3
closed = Thread::Queue.new.close
begin
  closed.push(1)
rescue ClosedQueueError => e
  [e.class, e.message] # => [ClosedQueueError, "queue closed"]
end
closed.pop # => nil

# Queues cannot be frozen (3.3): freezing would make them unusable.
begin
  Thread::Queue.new.freeze
rescue TypeError => e
  e.message # => "cannot freeze #<Thread::Queue:0x...>"
end

A fixed pool of workers reading one queue is how thread pools are built from these parts: the size of the pool (often a small multiple of the number of cores for CPU-bound native work, or the number of concurrent connections for I/O) bounds the concurrency, and the queue’s size bounds the memory. Etc.nprocessors returns the number of processors. The pipeline example is already a thread pool. For production use, a pool from a maintained library (such as concurrent-ruby, a community gem) adds shutdown, error handling and metrics, but it is built on the same Thread and Queue and nothing on this page is lost by learning them first.

Deadlocks

A deadlock is a state in which every thread is waiting for something that only another waiting thread can provide. CRuby detects the simplest form: when every thread is asleep and nothing can wake them, the VM raises a fatal error in the main thread with the message No live threads left. Deadlock? and a dump of every thread and the locks it owns. The program below takes two locks in opposite orders on two threads, the classic cause.

# frozen_string_literal: true

# Two threads take two locks in opposite orders. Ruby notices that every
# thread is asleep and nothing can wake them, and raises fatal in the main thread.
loans = Thread::Mutex.new
returns = Thread::Mutex.new
ready = Thread::Queue.new

first = Thread.new do
  loans.synchronize do
    ready << :first
    sleep 0.05
    returns.synchronize { :never }
  end
end

second = Thread.new do
  returns.synchronize do
    ready << :second
    sleep 0.05
    loans.synchronize { :never }
  end
end

2.times { ready.pop }
[first, second].each(&:join)

Output (it ends the program with status 1; addresses vary):

deadlock.rb:26:in 'Thread#join': No live threads left. Deadlock? (fatal)
3 threads, 3 sleeps current:0x... main thread:0x...
* #<Thread:0x... sleep_forever>
   rb_thread_t:0x... native:0x... int:0
   deadlock.rb:26:in 'Thread#join'
   deadlock.rb:26:in 'Array#each'
   deadlock.rb:26:in '<main>'
* #<Thread:0x... deadlock.rb:9 sleep_forever>
   rb_thread_t:0x... native:0x... int:0 mutex:3 cond:1
    depended by: tb_thread_id:0x...
   deadlock.rb:13:in 'Thread::Mutex#synchronize'
   deadlock.rb:13:in 'block (2 levels) in <main>'
   deadlock.rb:10:in 'Thread::Mutex#synchronize'
   deadlock.rb:10:in 'block in <main>'
* #<Thread:0x... deadlock.rb:17 sleep_forever>
   rb_thread_t:0x... native:0x... int:0
   deadlock.rb:21:in 'Thread::Mutex#synchronize'
   deadlock.rb:21:in 'block (2 levels) in <main>'
   deadlock.rb:18:in 'Thread::Mutex#synchronize'
   deadlock.rb:18:in 'block in <main>'

	from deadlock.rb:26:in 'Array#each'
	from deadlock.rb:26:in '<main>'

The detection has limits. It cannot see a deadlock while any thread can still be woken by something (a timer, an I/O event, a signal handler), and a program in which one thread holds the lock forever while others spin does not trigger it. Thread.ignore_deadlock = true (3.0) turns the detection off, for programs that rely on a signal handler to break the wait. The fixes are structural: a fixed lock order, a try_lock with a back-off, a timeout on every wait, or a design that needs only one lock.

Memoization and class-level state

Memoizing with ||= is idiomatic Ruby, and it is not atomic. @index ||= build_index reads the variable, tests it, runs the block, and writes the result. If two threads both read an unset variable, both run the expensive block and both write; the last write wins, and the work was done twice, with any side effects. When the block is fast and free of side effects the duplicate work is harmless, and in CRuby the write itself is safe; when the block opens a connection, builds a large structure, or must run once, it is a bug. The remedy is to compute inside a mutex, as safe_index does. The same reasoning applies to class-level state: class instance variables (@default on the class), class variables, constants that hold mutable objects and Shelf.config are shared by every thread of the process.

# frozen_string_literal: true

# ||= is a read, a test and a write: not atomic, so two threads can both
# find the ivar empty and both run the expensive block.
class Branch
  LOCK = Thread::Mutex.new

  # One shared default instance, created under a lock like Shelf.config could be.
  def self.default
    LOCK.synchronize { @default ||= new }
  end

  attr_reader :builds

  def initialize
    @builds = Thread::Queue.new
    @mutex = Thread::Mutex.new
  end

  def index
    @index ||= build_index
  end

  def safe_index
    @mutex.synchronize { @safe_index ||= build_index }
  end

  private

  def build_index
    @builds << :built
    sleep 0.05
    { "9780306406157" => "Dune" }.freeze
  end
end

def build_count(branch, method)
  Array.new(5) { Thread.new { branch.public_send(method) } }.each(&:join)
  branch.builds.size
end

build_count(Branch.new, :index) # => 5
build_count(Branch.new, :safe_index) # => 1

# Class-level state is shared by every thread: the same rule applies to
# Shelf.config, class variables and constants holding mutable objects.
Array.new(4) { Thread.new { Branch.default } }.map(&:value).uniq.size # => 1

The same applies to the hand-written singleton @instance ||= new, which is a frequent idiom in Ruby code and in books. It can build two instances under contention. The Singleton module from the standard library creates the instance under a mutex (the source of the singleton library, version 0.3.0 in Ruby 4.0, uses a double-checked lock) and makes new private:

# frozen_string_literal: true

require "singleton"

# Singleton makes .new private and adds .instance, which guards creation with a mutex.
class Config
  include Singleton

  # The hand-written variant: @unsafe ||= new is a check-then-act, so two
  # threads can each build an instance when new is slow.
  def self.unsafe_instance
    @unsafe_instance ||= new
  end

  def initialize
    super
    sleep 0.05
  end
end

hand_rolled = Array.new(4) { Thread.new { Config.unsafe_instance } }.map(&:value)
hand_rolled.uniq.size # => 4

guarded = Array.new(4) { Thread.new { Config.instance } }.map(&:value)
guarded.uniq.size # => 1

begin
  Config.new
rescue NoMethodError => e
  e.message # => "private method 'new' called for class Config"
end

The cleanest alternatives avoid shared mutable state altogether: build the object eagerly at load time, before any thread starts (CONFIG = Config.load.freeze); make shared objects immutable with freeze (a frozen object cannot be corrupted by races, and a deeply frozen one is what a Ractor can share, see Object Lifecycle, Freezing and Copying); give each thread its own copy; or hand the work to one thread through a queue. The class-level helpers of Singleton Classes and Class-Level Behavior use @x ||= at the class level in the same way.

A thread-safe catalogue

The shelf gem’s Shelf::Catalog puts the rules together. It owns one Thread::Mutex, takes it around every access to the store, and each copies the books while holding the lock and iterates over the copy after releasing it. A reader that adds a book from inside its block therefore cannot deadlock, and never observes a half-updated list. The example uses a trimmed copy of the class.

# frozen_string_literal: true

Book = Data.define(:isbn, :title)

# A trimmed copy of Shelf::Catalog: every access to the store goes through one mutex.
class Catalog
  include Enumerable

  def initialize
    @books = []
    @mutex = Thread::Mutex.new
  end

  def add(book)
    @mutex.synchronize { @books << book }
    self
  end

  # Iterate over a snapshot so that a block that calls #add cannot deadlock or
  # see a half-updated list.
  def each(&)
    return enum_for(:each) unless block_given?

    @mutex.synchronize { @books.dup }.each(&)
  end
end

catalog = Catalog.new
writers = Array.new(4) do |n|
  Thread.new do
    25.times { |i| catalog.add(Book.new("#{n}-#{i}", "Title #{n}-#{i}")) }
  end
end
writers.each(&:join)
catalog.count # => 100
catalog.map(&:isbn).uniq.size # => 100

# A reader may add while iterating: each works on a snapshot and the lock is not held.
catalog.each { |book| catalog.add(book) if book.isbn == "0-0" }
catalog.count # => 101

# Had each yielded while holding @mutex, the add above would raise ThreadError
# ("deadlock; recursive locking").

The design choice behind the snapshot is the usual one for locked collections: the lock protects the data structure, not the caller’s code. If iteration must see a consistent state across a long operation, take the snapshot first, as here, rather than holding the lock for the whole iteration.

How safe are the core classes?

CRuby’s built-in Array, Hash, String and the other core classes were written under the GVL and do not synchronize themselves. A single method call is indivisible (eight threads appending 1,000 elements each to one array lost none in a test on this machine), but combinations are not. Adding a key to a Hash while another thread iterates it raises RuntimeError (can't add a new key into hash during iteration) in the thread that adds the key, and none of this is a promise of the language that other implementations keep. The safe habits are: share immutable (frozen) data freely; guard shared mutable collections with a mutex or a queue; keep each mutable object owned by one thread at a time; and treat any Enumerable method that takes a block on a shared collection as a long operation that needs the same protection as an iteration.

Interrupting threads

Thread#raise, Thread#kill and cause: (4.0)

Thread#raise queues an exception in another thread: it is raised at the next point where the target thread can handle an interrupt, which includes the middle of a blocking call such as sleep or Queue#pop. It accepts the same arguments as Kernel#raise, and since Ruby 4.0 also the cause: keyword, so the target thread can be told why it is being interrupted. In Ruby 3.4 the same call fails: the keyword hash is taken for the backtrace argument and TypeError (backtrace must be an Array of String or an Array of Thread::Backtrace::Location) is raised. Ruby 3.4 also let Thread#raise take an array of Thread::Backtrace::Location as the backtrace. Thread#kill ends the thread and runs its ensure clauses, and Thread#join after it returns once the cleanup is done.

Interrupting a thread from outside is inherently dangerous: the exception can arrive in the middle of any statement, including inside an ensure clause or between two updates of shared state. Prefer cooperative cancellation, where the thread checks a flag or reads a stop message from a queue, and use Thread#raise and Thread#kill for emergencies and for waking a thread that is blocked.

Thread.handle_interrupt

Thread.handle_interrupt(ExceptionClass => timing) { ... } controls when asynchronous interrupts (Thread#raise, Thread#kill, the exceptions raised by Timeout) are delivered inside the block. The timing is one of:

Timing Meaning

:immediate

Deliver at once (the default outside any handle_interrupt block).

:on_blocking

Deliver only while the thread is in a blocking operation (in CRuby, any operation run without the GVL).

:never

Defer until the block ends or an :immediate section starts.

Pass Object rather than Exception to cover Thread#kill, which is not an Exception. Blocks nest, and the innermost matching rule wins. Thread.pending_interrupt? (and Thread#pending_interrupt?) tells whether an interrupt is waiting, so a long critical section can poll and then open an :immediate section. The documentation itself says that asynchronous interrupts are difficult to use and recommends a queue for communication between threads when possible.

# frozen_string_literal: true

class Cancelled < StandardError; end

# Thread#raise queues an exception in the target thread. Since Ruby 4.0 it
# accepts cause:, like Kernel#raise.
reason = IOError.new("lender went away")
waiting = Thread.new do
  Thread.current.report_on_exception = false
  sleep
rescue Cancelled => e
  [e.message, e.cause&.message]
end
Thread.pass until waiting.stop?
waiting.raise(Cancelled, "loan cancelled", cause: reason)
waiting.value # => ["loan cancelled", "lender went away"]

# Without cause:, the cause is whatever was being handled in the target thread (here nothing).
plain = Thread.new do
  sleep
rescue Cancelled => e
  e.cause
end
Thread.pass until plain.stop?
plain.raise(Cancelled)
plain.value # => nil

# Thread#kill runs ensure clauses and ends the thread; value is then nil.
log = []
victim = Thread.new do
  sleep
ensure
  log << :cleaned_up
end
Thread.pass until victim.stop?
victim.kill.join.equal?(victim) # => true
log.dup # => [:cleaned_up]
victim.value # => nil
victim.status # => false

# Thread.handle_interrupt defers asynchronous exceptions (raise, kill, Timeout)
# until the protected section ends.
steps = []
worker = Thread.new do
  Thread.current.report_on_exception = false
  Thread.handle_interrupt(Cancelled => :never) do
    steps << :critical_start
    sleep 0.05
    steps << :critical_end
    Thread.pending_interrupt? # => true
  end
  steps << :never_reached
rescue Cancelled
  steps << :cancelled_after_section
end
Thread.pass until steps.include?(:critical_start)
worker.raise(Cancelled)
worker.join
steps.dup # => [:critical_start, :critical_end, :cancelled_after_section]

Timeout and its caveats

Timeout.timeout(seconds) { ... } (from the timeout library, a default gem at version 0.6.0 in Ruby 4.0, loaded with require "timeout") runs the block and, if it has not finished after seconds, interrupts it. Internally a helper thread calls Thread#raise on the thread that runs the block. A nil or 0 limit means no limit, and since Ruby 3.4 a negative limit raises ArgumentError. The optional second and third arguments choose the exception class and message. When the block times out, the caller sees Timeout::Error (a RuntimeError); inside the block the default exception is Timeout::ExitException, which is an Exception, not a StandardError, specifically so that ordinary rescue clauses do not swallow it. When the scheduler of a non-blocking fiber supports timeout_after, Timeout uses it (see Fibers and the Fiber Scheduler).

# frozen_string_literal: true

require "timeout"

class LookupTimeout < StandardError; end

def attempt(label)
  puts "#{label}: returned #{yield.inspect}"
rescue Timeout::Error, LookupTimeout, ArgumentError => e
  puts "#{label}: #{e.class}: #{e.message}"
end

# Timeout.timeout raises inside the block's thread when time is up, and the
# caller sees Timeout::Error.
attempt("slow lookup") { Timeout.timeout(0.05) { sleep 1 } }
attempt("fast lookup") { Timeout.timeout(0.5) { :found } }

# A nil or zero limit means no limit; negative limits raise ArgumentError.
attempt("nil limit") { Timeout.timeout(nil) { :unbounded } }
attempt("negative") { Timeout.timeout(-1) { :never } }

# Inside the block the default exception is Timeout::ExitException, an
# Exception (not a StandardError) so that ordinary rescue clauses do not see it.
Timeout::ExitException.superclass # => Exception
Timeout::Error.superclass # => RuntimeError

attempt("rescue StandardError inside") do
  Timeout.timeout(0.05) do
    sleep 1
  rescue StandardError
    :swallowed
  end
end

# Anything that rescues Exception inside the block, or a custom StandardError
# class chosen for the timeout, can swallow it and the block then carries on.
attempt("rescue Exception inside") do
  Timeout.timeout(0.05) do
    sleep 1
  rescue Exception # rubocop:disable Lint/RescueException
    :swallowed
  end
end

attempt("custom class, caller") { Timeout.timeout(0.05, LookupTimeout, "catalogue too slow") { sleep 1 } }
attempt("custom class, swallowed") do
  Timeout.timeout(0.05, LookupTimeout) do
    sleep 1
  rescue StandardError
    :swallowed
  end
end

# Prefer an operation-level timeout when the API has one.
Thread::Queue.new.pop(timeout: 0.05) # => nil

The caveats come from the fact that it is an asynchronous interrupt, with all the problems of the previous section:

  • The exception can arrive anywhere, including in the middle of a critical section, inside an ensure or rescue clause of the block, or inside a library’s cleanup code. State that was half-updated stays half-updated. Open resources must be released in ensure clauses that tolerate being interrupted.

  • It can be defeated. A rescue Exception in the block, or the use of a custom exception class that inherits from StandardError, swallows the interrupt and the block carries on past the deadline, as the output above shows. The documentation states that Timeout.timeout cannot be relied on to enforce timeouts for untrusted blocks.

  • Native code that does not release the GVL or cannot be interrupted is not interrupted until it returns, so the deadline can be overshot by the length of that call.

  • To keep the exception out of an ensure, put the Timeout.timeout call inside the begin body, so the ensure clause runs outside it. If that is impossible, the documentation shows wrapping the call in Thread.handle_interrupt(Timeout::Error => :never) with Timeout.timeout(sec, Timeout::Error) and an inner :immediate section; the exception class must be passed explicitly, and Timeout::ExitException must not be used with handle_interrupt.

  • Each call has a cost: the timeout library keeps a helper thread alive.

Whenever the API offers a timeout of its own, use it instead: it is delivered at a safe point and leaves state consistent. Ruby 3.2 added IO#timeout= (a blocking operation raises IO::TimeoutError), Regexp.timeout= and Queue#pop(timeout:); Socket.tcp and TCPSocket.new take open_timeout: (4.0) for the connection, and Thread#join(limit) and Mutex#sleep(timeout) bound their waits. The networking pages show these in context (Networking and HTTP).

Pitfalls and a decision guide

Situation Problem Do this instead

Counting or accumulating in a shared variable

Read, modify and write are separate steps and the switch can land between them.

Guard it with a Mutex, or give each thread its own counter and combine the results after join.

@x ||= expensive used by several threads

The block can run more than once, with its side effects.

Compute under a mutex, or build the value before the threads start.

@instance ||= new as a singleton

Two threads can each create an instance.

Use include Singleton, or an eager constant.

A thread that raises and nobody joins it

The failure is reported on standard error and then lost.

join or value every thread whose result matters, or catch and log inside the thread.

Thread.abort_on_exception = true in a library

It changes the behaviour of every thread in the process.

Set it per thread, or only in scripts and tests.

Many CPU-bound threads for speed

The GVL runs one of them at a time.

Use processes, Ractors, or native code that releases the GVL.

Unbounded Queue between a fast producer and a slow consumer

The queue grows until memory runs out.

Use a SizedQueue for back-pressure.

loop { queue.push(x) } on a queue that may be closed

ClosedQueueError is a StopIteration, so loop ends silently.

Rescue ClosedQueueError explicitly, or push outside loop.

Holding a lock while calling a block or a callback

The callback may take the same lock (deadlock) or take a long time.

Copy the data under the lock and call the code after releasing it.

Taking two locks in different orders

Deadlock.

Always acquire locks in one agreed order, or use only one.

Timeout.timeout around code that holds resources

The exception arrives at an arbitrary point and can skip cleanup.

Use the API’s own timeout, or keep ensure clauses safe with Thread.handle_interrupt.

Using Mutex in a trap handler

ThreadError: the handler cannot lock.

Push a message on a Queue and handle it in a thread.

fork in a process with threads

Only the calling thread survives in the child, and locks held by the others stay locked.

Fork before starting threads, or exec straight away.

Relying on Thread#priority or Thread.pass for correctness

Both are only hints.

Use locks, queues or condition variables to order work.

The decision of which tool to use can be read from the shape of the problem. Waiting on the network, a disk or another program: threads, with a bounded pool and a queue. Computing on many cores: processes, or Ractors for isolated, message-passing work. Very many concurrent connections: non-blocking fibers with a scheduler, or an event-loop library. Shared mutable state that cannot be avoided: one owner thread and a queue first, a Mutex second, a Monitor third.

Where older material is outdated

Books and articles written for Ruby 2.x describe a Ruby that no longer exists in several points about concurrency. When reading them, translate as follows.

Older claim Why it is misleading Ruby 4.0

"Adding more threads does not help throughput"

True only for pure Ruby computation.

Threads help any workload dominated by blocking calls; native code that releases the GVL also runs in parallel.

"Fibers automatically yield on I/O since Ruby 3"

Only a non-blocking fiber (blocking: false, the default of Fiber.new) yields on I/O, and only when a Fiber::Scheduler is installed on the thread with Fiber.set_scheduler. Without a scheduler blocking and non-blocking fibers behave identically.

Fibers are cooperative, and without a scheduler a fiber blocks its thread on I/O. See Fibers and the Fiber Scheduler.

One GVL for the whole process

It stops being true once Ractors exist.

One GVL per Ractor; the threads of a Ractor share that Ractor’s lock. The M:N scheduler exists since Ruby 3.3, off by default on the main Ractor.

@instance ||= new for singletons (also in books about patterns)

Not thread-safe.

include Singleton, whose instance takes a mutex.

Thread#[] described as "thread-local"

It is fiber-local.

thread_variable_get and thread_variable_set for thread-locals; Fiber[] (3.2) for inheritable storage.

A Mutex is owned by a thread

Changed in 3.0.

A Mutex is owned by a fiber.

Deadlock detection cannot be turned off

Added Thread.ignore_deadlock in 3.0.

Thread.ignore_deadlock = true disables the fatal check.

Timeouts only through Timeout.timeout

The era before native timeouts.

IO#timeout= (3.2), Queue#pop(timeout:) (3.2), SizedQueue#push(timeout:) (3.2), Regexp.timeout= (3.2).

Thread#raise without a cause

Not possible before 4.0.

Thread#raise(Klass, "message", cause: error) (4.0).

Thread.exclusive, Thread#priority-based scheduling

Removed (exclusive) or advisory only.

Use a Mutex.

require "thread" before using Queue or Mutex

They have been built in for a long time.

Thread::Queue, Thread::Mutex, Thread::ConditionVariable and Thread::SizedQueue need no require. Monitor, Timeout and Singleton still do.

Version notes

Version Change

3.0

Mutex is acquired per fiber. Thread.ignore_deadlock. Queue, SizedQueue, ConditionVariable and Thread#join call the fiber scheduler hooks in a non-blocking fiber. Fiber::Scheduler introduced.

3.1

error_highlight adds the code excerpt to uncaught-exception reports, including thread reports.

3.2

Fiber[], Fiber[]=, Fiber#storage and Fiber.new(storage:). Queue#pop(timeout:), SizedQueue#pop(timeout:), SizedQueue#push(timeout:). IO#timeout=. Regexp.timeout=. Thread.each_caller_location.

3.3

M:N thread scheduler (RUBY_MN_THREADS=1, RUBY_MAX_CPU). Queue#freeze and SizedQueue#freeze raise TypeError.

3.4

Timeout.timeout rejects negative limits. IO::Buffer#copy can release the GVL. Thread#raise accepts Thread::Backtrace::Location arrays. mutex_m becomes a bundled gem. Ractor[] and Ractor.store_if_absent.

4.0

Thread#raise(cause:). Ractor::Port, Ractor#join and Ractor#value (see Ractors). Socket.tcp and TCPSocket.new take open_timeout:. Deadlocks when combining Ractors and threads fixed. rb_thread_call_with_gvl works with or without the GVL for C extensions.

References