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:
Vamos rodar o programa e:
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:
Que bizarro...de novo:
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:
No meu computador, um Macbook M1 Pro late-2021 de 16GB, demorou 0.07 segundos:
Se precisarmos rodar 50 vezes, podemos querer executar em 50 threads diferentes:
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:
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?
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:
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:
Se executarmos este programa no Ruby 3.2.2 (por exemplo), o resultado seria:
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:
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