Threads, the GVL and Ractors

Ruby · Expert · 18 min

Puma, Sidekiq and every concurrent Ruby service depend on how threads and the GVL behave.

Thread.new { ... } starts a native OS thread; join waits and value returns the block's result. Threads share memory, so concurrent writes to shared state need synchronisation: Mutex#synchronize, or better, pass work through a thread-safe Queue (use SizedQueue for back-pressure).

CRuby has a Global VM Lock (GVL): only one thread executes Ruby code at a time per Ractor. Consequences:

  • Threads give real speedups for I/O-bound work. Blocking I/O, sleep and most C extensions doing I/O release the GVL, so ten threads can wait on ten HTTP requests at once. This is why Puma and Sidekiq use thread pools.
  • CPU-bound Ruby code does not get faster with threads; use multiple processes (Puma workers, fork) or Ractors.
  • The GVL is not a substitute for locking: count += 1 is a read, an add and a write, and a thread switch can happen between them.

Ractors (experimental since 3.0) are actor-like units that run in parallel with separate GVLs. They can't share mutable objects: you send messages (copied or moved) and only shareable, deeply frozen objects cross freely. In 3.4 you exchange values with send/receive and get a result with take. Many gems are not Ractor-safe yet, so treat them as an option for isolated CPU work.

Other hazards: Thread#raise and Timeout.timeout interrupt code at arbitrary points, and an exception in a thread is only re-raised when you join it (set Thread.report_on_exception, on by default, to see it logged).

Example

require "benchmark"

slow_io = -> { sleep 0.2; :ok }
t = Benchmark.realtime { 5.times.map { Thread.new(&slow_io) }.each(&:join) }
puts "5 sleeps in parallel: #{t.round(1)}s"      # ~0.2s, not 1.0s

counter = 0
lock = Mutex.new
10.times.map {
  Thread.new { 10_000.times { lock.synchronize { counter += 1 } } }
}.each(&:join)
p counter                                        # 100000

queue = Queue.new
workers = 3.times.map do |i|
  Thread.new do
    while (job = queue.pop) != :stop
      puts "worker #{i} squared #{job} = #{job * job}"
    end
  end
end
[2, 3, 4].each { queue << it }
3.times { queue << :stop }
workers.each(&:join)

Remember

  • The GVL lets only one thread run Ruby code at a time; I/O releases it
  • Threads speed up I/O-bound work, not CPU-bound Ruby
  • Protect shared state with Mutex or pass work through Queue

Watch out for

  • Assuming the GVL makes counter += 1 thread-safe; it can still lose updates.

Check yourself

  1. A CPU-heavy pure-Ruby computation is split across 4 threads in CRuby. What speedup do you expect?
  2. Why do threads help a script that fetches 20 URLs?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.ruby-lang.org/en/3.4/Thread.html