Thread Safety & GIL

Большинство основных языков программирования поддерживают многопоточность для параллельного выполнения кода. Однако модель многопоточности в Ruby отличается от таких языков, как C# и Java - она не является полностью многопоточной. Это относится к MRI Ruby (также известном как CRuby), а к альтернативным реализациям Ruby(JRuby, Rubinius, и тд).

В MRI Ruby реализована концепция Глобальной Блокировки Интерпретатора. GIL позволяет Ruby выполнять только один поток за раз, ограничивая возможности полного параллелизма. Он был создан для упрощения параллельного выполнения кода Ruby. В большинстве случаев разработчикам не нужно беспокоиться о состояниях гонки или неожиданном поведении при параллельном выполнении кода.

@shared_value = 0

1000.times.map do # создаем
Thread.new do # 1000 потоков
10000.times do # в каждом потоке
value = @shared_value # инкрементация @shared_value
value = value + 1 # выполняется 10000 раз
@shared_value = value
end
end
end.each(&:join)

puts @shared_value
# получается ожидаемое значение - 1000 * 10000
# => 10000000

Однако GIL может создавать ложное ощущение потокобезопасности, поскольку совместное использование изменяемых данных между потоками по-прежнему небезопасно из-за переключения контекста.

@shared_value = 0

# "геттер" для общего значения
def get_shared_value
@shared_value
end

# "сеттер" для общего значения
def set_shared_value(value)
@shared_value = value
end

1000.times.map do
Thread.new do
10000.times do
value = get_shared_value
value = value + 1
set_shared_value(value)
end
end
end.each(&:join)

puts @shared_value
# ожидаемое значение - 1000 * 10000 = 10000000
# но результат иной!
# => 8654017

Второй фрагмент кода логически идентичен первому, за исключением того, что в нём создаются два метода для получения и установки общего значения. Почему же результат выполнения отличается?

MRI в Ruby выполняет переключение контекста каждый раз, когда выполняющийся поток вызывает функцию или возвращается из функции

Если Ruby меняет контекст после вызова get_shared_value, то возвращаемое value становится «грязным» и повреждает общее значение при его возврате. Это также означает, что если внутри функции нет вызова другой функции, то переключения контекста не произойдёт.

К другим случаям, вызывающим переключение контекста относятся:

  • 1) Блокирующие операции ввода-вывода: когда поток выполняет блокирующие операции ввода-вывода, например чтение из сокета, Ruby освобождает GIL и позволяет другим потокам выполняться в режиме ожидания.
  • 2) GC: сборщик мусора освобождает GIL, чтобы другие потоки могли выполняться во время сборки.

Хотя GIL призван упростить параллельное программирование, у него есть свои ограничения. К счастью, в Ruby есть способы вручную запускать переключение контекста между потоками в определённых точках:

  • Thread.pass: При вызове Thread.pass управление добровольно передаётся другому потоку. Поток приостанавливается, и Ruby переключает контексты.
  • Thread.stop: Остановка потока с помощью Thread.stop приостанавливает его работу и позволяет запланировать его перезапуск.
  • sleep: При вызове sleep блокировка GIL снимается. Другие потоки могут выполняться.