Ruby
07 / 07

Concurrency & Performance

Ruby: Concurrency & Performance

The GIL (Global Interpreter Lock)

MRI Ruby (the standard implementation) has a Global VM Lock (GVL). Only one thread runs Ruby code at a time. I/O operations release the GVL, so threads still help with I/O-bound work. For CPU-bound parallelism: use Ractors (Ruby 3+), multiple processes, or JRuby/TruffleRuby.

Threads

# Threads share memory — good for I/O-bound concurrency
threads = (1..5).map do |i|
  Thread.new do
    response = Net::HTTP.get(URI("https://api.example.com/item/#{i}"))
    JSON.parse(response)
  end
end

results = threads.map(&:join).map(&:value)   # wait and collect results

# Mutex — protect shared state
mutex = Mutex.new
counter = 0

threads = 10.times.map do
  Thread.new do
    1000.times do
      mutex.synchronize { counter += 1 }  # atomic increment
    end
  end
end
threads.each(&:join)
puts counter  # 10000 — correct with mutex

# Thread-local variables
Thread.current[:request_id] = SecureRandom.uuid
Thread.current[:request_id]   # only visible to this thread

Fibers

# Fibers — cooperative (not preemptive) concurrency, very lightweight
# Control passes explicitly via Fiber.yield and fiber.resume

producer = Fiber.new do
  5.times do |i|
    puts "Producing #{i}"
    Fiber.yield i              # pause, return value to caller
  end
  nil
end

loop do
  value = producer.resume     # resume from where it yielded
  break if value.nil?
  puts "Consumed #{value}"
end

# Enumerator uses Fibers internally
enum = Enumerator.new do |yielder|
  yielder << 1
  yielder << 2
  yielder << 3
end

enum.next  # 1
enum.next  # 2
enum.next  # 3

Ractors (Ruby 3+)

# Ractors — true parallelism without GVL
# Ractors cannot share mutable objects (enforced at runtime)
# Communicate via message passing

r = Ractor.new do
  msg = Ractor.receive    # block until message arrives
  msg.upcase
end

r.send("hello")
puts r.take              # "HELLO"

# Parallel processing with a worker pool
workers = 4.times.map do
  Ractor.new do
    loop do
      job = Ractor.receive
      Ractor.yield job * 2
    end
  end
end

results = [1, 2, 3, 4].map.with_index do |n, i|
  workers[i % workers.size].send(n)
  workers[i % workers.size].take
end

Performance Tips

  • Frozen string literals: add # frozen_string_literal: true to every file — eliminates string allocation for all literals

  • Avoid N+1 queries: use includes(), eager_load(), or preload() in ActiveRecord. Use Bullet gem in development to detect N+1s automatically.

  • Use pluck() instead of map(&:attr) — returns raw values without instantiating models

  • Database indexes: index all foreign keys, frequently queried columns, and uniqueness constraints

  • Benchmark before optimizing: use the Benchmark module or benchmark-ips gem to measure before changing anything

  • Memory profiling: memory_profiler gem — find objects that are allocated but never freed

  • rack-mini-profiler: shows per-request SQL queries, timing, and memory in the browser

  • Sidekiq over Delayed::Job: Redis-backed, multi-threaded, much faster for background jobs

require 'benchmark/ips'

Benchmark.ips do |x|
  x.report('string concat') { "Hello" + " " + "World" }
  x.report('interpolation') { "Hello #{"World"}" }
  x.report('frozen')        { +"Hello".freeze }
  x.compare!
end

# Check memory allocation
require 'memory_profiler'
report = MemoryProfiler.report do
  1000.times { User.all.to_a }
end
report.pretty_print

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free