RubyMiddleTechnical

Как работает GVL и как он влияет на threads в MRI Ruby?

GVL — мьютекс MRI, позволяющий только одному потоку выполнять Ruby-код одновременно; для IO-задач потоки эффективны (GVL отдаётся при ожидании), для CPU-параллелизма используйте процессы или Ractors.

GVL (Global VM Lock) в MRI Ruby

GVL (Global VM Lock), ранее называемый GIL (Global Interpreter Lock) — это мьютекс, встроенный в интерпретатор MRI (CRuby). Он гарантирует, что в любой момент времени только один поток Ruby выполняет Ruby-код. Остальные потоки ждут освобождения блокировки.

Как работает GVL

GVL удерживается потоком, пока он выполняет Ruby-инструкции. Поток освобождает GVL в двух случаях:

  • При блокирующем системном вызове: ввод-вывод (read, write, accept), sleep, wait. В этот момент другой поток может захватить GVL и продолжить работу.
  • По истечении кванта времени (~100 мс или ~1000 инструкций): интерпретатор принудительно переключает потоки через сигнал RUBY_THREAD_TIMESLICE.
require 'benchmark'
require 'thread'

# CPU-bound: потоки не дают прироста из-за GVL
def cpu_work
  10_000_000.times { |i| i * i }
end

sequential_time = Benchmark.realtime do
  cpu_work
  cpu_work
end

threaded_time = Benchmark.realtime do
  t1 = Thread.new { cpu_work }
  t2 = Thread.new { cpu_work }
  t1.join
  t2.join
end

puts "Sequential: #{sequential_time.round(2)}s"
puts "Threaded:   #{threaded_time.round(2)}s"
# Результат почти одинаковый — GVL не даёт параллельности CPU

IO-bound задачи: потоки работают эффективно

Когда поток ожидает IO, он отдаёт GVL, и другие потоки продолжают работать. Поэтому для HTTP-запросов, работы с БД, файловых операций потоки дают реальный прирост.

require 'net/http'
require 'benchmark'

urls = Array.new(5) { "http://example.com" }

# IO-bound: потоки дают ~5x ускорение
threaded_time = Benchmark.realtime do
  threads = urls.map do |url|
    Thread.new do
      Net::HTTP.get(URI(url))
    end
  end
  threads.each(&:join)
end

puts "5 concurrent requests: #{threaded_time.round(2)}s"
# vs ~5x дольше при последовательном выполнении

Нативные C-расширения и GVL

C-расширения могут явно освобождать GVL через rb_thread_call_without_gvl(). Многие популярные гемы делают это для CPU-интенсивных операций: bcrypt, nokogiri (парсинг XML), некоторые драйверы БД. Это позволяет реальному параллелизму в C-коде при многопоточном Ruby.

Альтернативы для CPU-параллелизма

  • Process.fork / multiprocessing: каждый процесс имеет собственный GVL. Puma в режиме cluster использует это.
  • Ractors (Ruby 3.0+): новая модель параллелизма без GVL. Каждый Ractor изолирован, объекты между ними передаются через каналы или freeze. Пока экспериментальная, но работает без GVL.
  • JRuby / TruffleRuby: не имеют GVL, реально параллельны в потоках.
# Ractors — параллельный CPU без GVL (Ruby 3.0+)
results = (1..4).map do |i|
  Ractor.new(i) do |n|
    # Реально параллельно — нет GVL!
    sum = 0
    (n * 1_000_000).times { |j| sum += j }
    sum
  end
end.map(&:take)

puts results.sum

Thread safety при GVL

GVL не защищает от состояний гонки в Ruby! Он гарантирует, что Ruby-инструкции атомарны на уровне C, но составные операции (read-modify-write) всё равно не потокобезопасны без мьютекса.

counter = 0
threads = 100.times.map do
  Thread.new { 1000.times { counter += 1 } }  # race condition!
end
threads.each(&:join)
puts counter  # не 100_000! Гонка данных.

# Правильно:
mutex = Mutex.new
counter = 0
threads = 100.times.map do
  Thread.new { 1000.times { mutex.synchronize { counter += 1 } } }
end
threads.each(&:join)
puts counter  # 100_000

Подводные камни

  • GVL не защищает от race conditions: составные операции типа hash[key] ||= [] не атомарны — используйте Mutex или Concurrent::Map из concurrent-ruby.
  • CPU-bound потоки бесполезны в MRI: добавление потоков для вычислений не даёт ускорения. Используйте процессы (fork) или Ractors.
  • Дедлоки: Thread#join + неверная очерёдность мьютексов = дедлок. Используйте Mutex#try_lock или задавайте таймаут.
  • Thread.raise небезопасен: вызов thread.raise извне может прервать поток в любой точке, включая ensure-блоки. Предпочитайте совместную отмену через флаги или Queue.
  • Fiber vs Thread: Fiber не переключаются автоматически — это кооперативная многозадачность. Fiber::Scheduler (Ruby 3.0+) позволяет неблокирующий IO без потоков.
  • ObjectSpace не потокобезопасен: не итерируйте ObjectSpace в многопоточном коде без остановки GC.

Common mistakes

  • Отвечать определением без production-сценария.
  • Не называть runtime boundary, security boundary или failure mode.
  • Игнорировать версию API, observability и тестовую проверку.

What the interviewer is testing

  • Объясняет механизм своими словами и без выдуманных API.
  • Называет реальные риски, диагностику и критерий корректности.
  • Связывает ответ с текущей документацией и миграционными ограничениями.

Sources

Related topics