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,
sleepand 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 += 1is 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.