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 снимается. Другие потоки могут выполняться.