Race condition, YARV, GVL e paralelismo em Ruby

Para entender o que de fato é race condition, sugiro fortemente a leitura deste mesmo tópico no módulo de C.

Com isto em mente, vamos mostrar um exemplo em Ruby onde múltiplas threads mudam o valor de uma variável compartilhada, que no final deveria mostrar um valor inconsistente:

balance = 0 # Variável compartilhada

# Cria 100 threads
threads = 100.times.map do
  Thread.new do
    500_000.times do
      balance += 1
    end
  end
end

# Espera as 100 threads terminarem de executar
threads.each(&:join)

puts "Balance is: #{balance} (expected: 50000000)"

Vamos rodar o programa e:

Balance is: 50000000 (expected: 50000000)

Ué? Não era pro saldo final ser diferente devido a race condition, conforme aprendemos no módulo anterior?

Estranho, bora executar de novo então:

Balance is: 50000000 (expected: 50000000)

Que bizarro...de novo:

Balance is: 50000000 (expected: 50000000)

Após executar umas 20 vezes, o resultado ainda é o mesmo.

Tá brincando com a minha cara, né Leandro? Não há race condition em Ruby, é isso? Poderíamos dizer então que Ruby já tem um "mutex embutido" que previne race conditions?

Quase isso, mas não é isso. Já vamos explicar o que exatamente está acontecendo, mas antes vamos entender brevemente o que é esse tal "interpretador Ruby" (CRuby).

Ruby VM, ou YARV

Voltando vários anos atrás, a implementação oficial do Ruby até a versão 1.8, era um simples interpretador. Ou seja, após transformar o código Ruby em uma árvore de sintaxe (AST), o interpretador executava diretamente cada nó da árvore chamando funções em C pré-compiladas. É o que chamamos de interpretador AST-based. Inclusive, nesta versão as threads não eram kernel threads, mas sim green threads.

Entretanto, haviam alguns problemas com este interpretador CRuby 1.8:

  • era lento, pois cada comando percorria a AST do zero, sem otimizações

  • as green threads eram cooperativas e rodavam todas na mesma thread do sistema operacional, o que impedia o uso de múltiplos núcleos de CPU

  • o garbage collector (GC) era "stop-the-world" e pausava todo o programa durante a coleta

Para resolver estes problemas, a versão 1.9 trouxe diversas melhorias. Foi feito um grande refactoring do interpretador, principalmente na parte de execução de código, onde foi implementada uma fina camada de máquina virtual (VM), também chamada carinhosamente pelos Rubystas de YARV, ou Yet Another Ruby Virtual Machine.

Criativo, não? kk

Se quer entender um pouco mais sobre esse vocabulário de interpretadores, sugiro a leitura deste meu artigo onde compartilhei a minha experiência participando de uma competição de compiladores feita no twitter.

A grande mudança foi que, no momento do parsing, ao invés de converter diretamente para funções C do interpretador, o CRuby 1.9 passou a gerar um bytecode intermediário, que seria executado pela YARV. Neste processo então, poderiam ser feitas diversas otimizações antes de ser de fato gerado código de máquina, pelo que a YARV já vem precompilada com todas as funções C e chamadas de sistema, otimizando muito mais tempo e melhorando a performance do interpretador como um todo.

Além disso e outras melhorias no GC, o CRuby 1.9 trouxe ainda melhorias na questão de concorrência:

  • as green threads foram substituídas por threads nativas (kernel threads), permitindo paralelismo em operações de I/O

  • foi introduzido um modelo de "corrotina" com escalonamento 100% cooperativo, através da classe Fiber, permitindo escrever código concorrente leve sem necessidade de sincronização

O Ruby 1.9 foi um marco importante, melhorando drasticamente a performance e o suporte a concorrência!

Ok Leandro, entendi. Mas o quê isso tem a ver com aquele lance lá que em Ruby parece que não há race condition?

Global VM Lock (GVL)

Agora que entendemos o que é a VM do Ruby (YARV), podemos falar sobre o que aconteceu com o código que tentou simular uma condição de corrida (race condition), porém sem sucesso.

Durante o desenvolvimento do CRuby 1.9, ao mudar de green threads para kernel threads, era sabido que o uso de threads do sistema operacional (threads nativas) traria desafios relacionados à race conditions, como vimos no módulo anterior em C.

A nível de programação, isto não seria necessariamente um problema, pois, fornecendo estruturas de sincronização como Mutex, a pessoa programadora Ruby pode evitar race conditions protegendo seções críticas de código.

Entretanto, o interpretador CRuby 1.9 utiliza diversas estruturas de dados internas compartilhadas (como tabelas de métodos, objetos Ruby e coleções internas do interpretador) que precisam ser protegidas contra acesso simultâneo, mesmo em cenários onde o programa Ruby já utiliza Mutex para proteger recursos.

A solução implementada para evitar race conditions internas foi a introdução de um lock global na VM, chamado de GVL, ou Global VM Lock.

GVL e paralelismo

O GVL (também chamado de GIL, ou Global Interpreter Lock) é um bloqueio global implementado no CRuby que garante que apenas uma thread por vez execute código Ruby, mesmo quando o programa possui várias kernel threads.

Como podemos ver, isso traz uma limitação com relação ao paralelismo. Mesmo com múltiplas threads, para o sistema operacional, é como se para todas as threads daquele processo existisse um "lock mutex", mesmo não sendo explícito.

Entretanto, o GVL é liberado em operações que envolvem I/O bloqueante - como leitura de arquivos ou sockets -, ou chamadas de funções C nativas, como ffi.

Com isso, podemos dizer que múltiplas threads Ruby não paralelizam na CPU mas por outro lado, múltiplas threads Ruby podem rodar em simultâneo em operações de I/O.

E é por isso que não conseguimos simular com sucesso a race condition, pois só podemos rodar uma thread por vez na CPU.

Again, é como se já tivesse um "mutex embutido" no interpretador, que é liberado em operações de I/O

GVL em ação: CPU-bound vs I/O -bound

Vamos ver em ação o GVL impedindo o paralelismo. No exemplo a seguir, temos uma tarefa CPU-bound (que faz uso intensivo de CPU) que, se executada, demora cerca de 0.07 segundos:

require 'benchmark'

def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
def cpu_task = fib(30)

time = Benchmark.measure { cpu_task }

puts "Tempo: #{time.real.round(2)} segundos"

No meu computador, um Macbook M1 Pro late-2021 de 16GB, demorou 0.07 segundos:

Tempo: 0.07 segundos

Se precisarmos rodar 50 vezes, podemos querer executar em 50 threads diferentes:

require 'benchmark'

def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
def cpu_task = fib(30)

time = Benchmark.measure do
  threads = 50.times.map { Thread.new { cpu_task }}
  threads.each(&:join)
end

puts "Tempo: #{time.real.round(2)} segundos"

Se as threads rodassem em paralelo, poderíamos assumir que o tempo total ficaria um pouco acima dos 0.07 segundos, correto?

Mas o GVL não deixa rodar em paralelo quando temos uma tarefa CPU-bound:

Tempo: 3.69 segundos

Demorou quase 4 segundos!

Agora outro exemplo. Simulando um fib(30) que demora 0.07 segundos, vamos fazer um sleep(0.07) que é uma operação I/O-bound, que faz uso intensivo de I/O. Com 50 threads, seria esperando um paralelismo totalizando em torno dos 0.07 segundos, correto?

require 'benchmark'

def io_task
  sleep(0.07)
end

time = Benchmark.measure do
  threads = 50.times.map { Thread.new { io_task }}
  threads.each(&:join)
end

puts "Tempo: #{time.real.round(2)} segundos"
Tempo: 0.07 segundos

OMG! Estamos vendo o GVL em ação, sendo liberado quando há uma operação de I/O!

A grande surpresa com o Ruby 3.4

Quero declarar aqui uma coisa. Durante os testes, me deparei com uma situação que eu não esperava. Não esperava mesmo.

Vamos relembrar o código inicial deste tópico:

balance = 0 # Variável compartilhada

# Cria 100 threads
threads = 100.times.map do
  Thread.new do
    500_000.times do
      balance += 1
    end
  end
end

# Espera as 100 threads terminarem de executar
threads.each(&:join)

puts "Balance is: #{balance} (expected: 50000000)"

Lendo o código, vemos um potencial para race condition, correto? Mas aprendemos ao longo deste tópico que o GVL garante que apenas uma thread seja executada por vez na CPU, o que faz com que este pequeno programa sempre retorne o saldo consistente, sem race condition.

But...

Se não me falha a memória, em versões anteriores a 3.3, lembro que este mesmo programa, se trocarmos o valor 1 por uma chamada de método que retorne o valor 1, o resultado do programa seria diferente:

balance = 0 # Variável compartilhada

def one = 1 # Um método que apenas retorna "1". Duh!

# Cria 100 threads
threads = 100.times.map do
  Thread.new do
    500_000.times do
      balance += one # Aqui trocamos "1" pela chamada do método.
                     # Matematicamente, são coisas equivalentes
    end
  end
end

# Espera as 100 threads terminarem de executar
threads.each(&:join)

puts "Balance is: #{balance} (expected: 50000000)"

Se executarmos este programa no Ruby 3.2.2 (por exemplo), o resultado seria:

Balance is: 33693083 (expected: 50000000)

Mas Leandro, cadê o GVL?

Então...é isto que eu esperava mesmo. Lembra que quando há chamada de I/O bloqueante o GVL é liberado? Em instruções atômicas, como entrada e saída de funções/métodos, o GVL também é liberado.

E neste curto espaço de tempo, o escalonador do sistema operacional troca as threads e não temos controle de qual será executada. Concorrência é linda, pois não?

Porém, amigues, me veio a surpresa. Ao executar com Ruby 3.4 ou Ruby 3.3, eu esperava este mesmo comportamento, afinal, não vi nada na documentação informando que o Ruby 3.3 ou 3.4 trariam alguma mudança com relação a como o GVL é liberado.

Mas o que eu vi foi outra coisa:

OMFG!!!!!!!11111111one

Eu procurei nas release notes do Ruby 3.3 e também do Ruby 3.4 e não encontrei nada que pudesse indicar o motivo disso ter mudado. Talvez analisando o código do interpretador em detalhes possa nos dar um pista.

Quem souber me avisa, please

Acredito que houve muita melhoria feita na sincronização do GVL com relação a operações atômicas, por isto em Ruby 3.3+ este programa que eu trouxe não apresenta problemas com race condition.

Mas em versões anteriores, operações atômicas liberam sim o GVL e apresentam risco de race condition. Por isto, vamos explorar como sincronizar este programa com Mutex.

Sincronização com Mutex (utilizando a versão 3.2.2)

Vamos agora voltar à versão 3.2, já que a versão deste guia (3.4) não apresentou problemas de race condition.

A seguir, modificamos o programa, mas desta vez sincronizando o acesso com mutex:

balance = 0 # Variável compartilhada

def one = 1
mutex = Mutex.new

# Cria 100 threads
threads = 100.times.map do
  Thread.new do
    500_000.times do
      mutex.synchronize do # Sincronização: o mutex é liberado no fim do bloco
        balance += one
      end
    end
  end
end

# Espera as 100 threads terminarem de executar
threads.each(&:join)

puts "Balance is: #{balance} (expected: 50000000)"
Balance is: 50000000 (expected: 50000000)

Yay!

Sincronize sempre

Mesmo com versões mais recentes tendo uma melhor sincronização e eficiência do uso do GVL, não temos controle a nível de programa de quando o GVL é liberado. Portanto, em operações de potencial condição de corrida, sincronize sempre! Nunca sabemos como o programa irá se comportar.

Afinal,

Isto, senhoras e senhores, é a maravilha da concorrência. Não temos controle algum sobre a ordem e execução das tarefas!

Resumão

Este tópico em específico foi bem extenso, o que faz sentido pois são pontos cruciais sobre concorrência em Ruby. Vamos a um resumo do que foi abordado:

  • o quê é o interpretador do Ruby: um pouco de história e como o Ruby 1.9 foi um marco no ecossistema com a introdução de uma VM e Kernel Threads

  • GVL e paralelismo: motivo da existência do lock global da VM, e seus impactos no paralelismo de CPU

  • quando o GVL é liberado: em algumas operações atômicas na CPU ou quando há chamada de I/O bloqueante, o GVL é liberado

  • a surpresa com o Ruby 3.4: desenvolvimentos recentes no Ruby fazem ainda com que o GVL seja melhor utilizado, melhorando o desempenho e assertividade em programas concorrentes

  • sincronize sempre: não confie no GVL. Se há risco de race condition, mutex neles!

  • threads e I/O: sim, podemos usar multi-thread em operações de I/O, já vimos em ação que o GVL é liberado nestas situações. Teu web server escala HTTP requests tranquilamente, pode confiar


Calma Rubysta, ainda não terminamos. Vamos falar de modelo de atores, thread pool, I/O não-bloqueante, fibers. Nossa...tem muita coisa ainda pra ver em Ruby.

Stay tuned!

Last updated