Concorrência em Ruby
Durante a jornada de um desenvolvedor Ruby, é comum ouvir que Ruby é single-threaded. No começo, pode parecer um pouco obscuro o que isso realmente significa e como Threads funcionam em Ruby. Este post tenta ajudar aqueles que ainda tem dúvidas sobre o assunto, explicando em mais detalhes sobre como Ruby trata alguns aspectos de concorrência.
Vamos começar fazendo uma importante distinção: quando as pessoas falam que Ruby é single-threaded, eles se referem ao Ruby MRI. O mesmo não é verdade para JRuby ou Rubinius, por exemplo. O motivo disso é a existência do GIL (Global Interpreter Lock) no Ruby MRI.
Global Interpreter Lock
O GIL é um lock global sobre código Ruby que previne o paralelismo. Basicamente, qualquer Thread que quiser executar código Ruby precisa adquirir esse lock global, mas apenas uma Thread pode adquiri-lo por vez.
Vamos analisar o código abaixo, que verifica números primos, para entender esse conceito um pouco melhor:
Para cada número de 1 a 100, o código cria uma nova Thread, que tenta adquirir o GIL, e verifica se o número é primo. O GIL é um simples mutex e o sistema operacional garante que uma única Thread possua o mutex por vez. Enquanto uma Thread detém o lock e executa código Ruby, todas as demais aguardam uma chance de executar o seu código. Esse tempo de espera é indefinido e controlado pela implementação do MRI.
Com isso, podemos garantir que código Ruby nunca é executado em paralelo no Ruby MRI.
Apesar de o GIL previnir o paralelismo, ele não evita que código Ruby seja executado concorrentemente.
Concorrência vs Paralelismo
É importante saber que concorrência é diferente de paralelismo. O melhor exemplo desta diferença eu encontrei no livro Working with Ruby Threads do Jesse Storimer:
Imagine que você é um programador de uma agência e eles têm dois projetos, que levam um dia inteiro de programação cada. Há (pelo menos) três maneiras de concluir os projetos:
1. Você poderia completar o Projeto A hoje e completar o Projeto B amanhã;
2. Você poderia trabalhar no Projeto A na parte da manhã e depois mudar para o Projeto B pela tarde e depois fazer o mesmo amanhã;
3. Você poderia trabalhar no Projeto A e outro programador poderia trabalhar no Projeto B;
A primeira maneira representa o trabalho em série, que é semelhante a um código single-threaded.
A segunda maneira representa trabalhar concorrentemente, que é semelhante a um código multi-threaded executado em uma CPU de um único núcleo.
A terceira maneira representa trabalhar paralelamente, que é semelhante a um código multi-threaded executado em uma CPU de vários núcleos.
A parte interessante desse exemplo é que trabalhar em série ou de forma concorrente leva o mesmo tempo (2 dias), enquanto trabalhar em paralelo leva a metade do tempo (1 dia). Portanto, trabalhar concorrentemente não significa trabalhar mais rápido.
Então, quando devemos usar múltiplas Thread em um programa Ruby? Vamos analisar alguns exemplos.
Exemplo 1: programa IO-bound
Neste primeiro exemplo, implementamos um código que faz várias requisições HTTP. Este é um clássico exemplo de um programa IO-bound.
I/O Bound means the rate at which a process progresses is limited by the speed of the I/O subsystem.
Em programas IO-bound, a Thread em execução fica bloqueada até que a operação de I/O seja concluída. Neste caso, faz sentido usar mais Threads, pois enquanto a primeira Thread aguarda, outras Threads podem usar a CPU para executar seu trabalho.
Abaixo está o resultado deste programa.
$ ruby thread-io-bound.rb
Without threads:
0.130000 0.050000 0.180000 ( 0.591372)
With threads
0.060000 0.040000 0.100000 ( 0.114966)Como você pode ver, o bloco com várias Threads executa 5x mais rápido que o bloco com apenas uma Thread.
Exemplo 2: programa CPU-bound
Neste segundo exemplo, calculamos o valor fibonacci dos números de 1 a 30. Este é um exemplo de programa CPU-bound.
CPU Bound means the rate at which a process progresses is limited by the speed of the CPU.
Nesses casos, a performance (no MRI Ruby) não é afetada com a introdução de mais Threads. Como o GIL garante que apenas uma Thread seja executada por vez e este programa não possui I/O, a mudança de contexto para executar outra Thread não traz ganhos de performance.
Abaixo, é possível ver o resultado desse código.
$ ruby thread-cpu-bound.rb
Without threads:
0.660000 0.000000 0.660000 ( 0.663142)
With threads
0.640000 0.010000 0.650000 ( 0.635634)Como esperado, os dois blocos de código (com uma Thread e com várias Threads) executam em tempos bastante similares.
(Se executarmos o mesmo exemplo em JRuby, será possível ver que o bloco com mais Threads executará significativamente mais rápido devido ao paralelismo da linguagem)
Thread Safety
Existe um equívoco comum de que o GIL garante que seu código seja thread-safe. Isso não é verdade. O GIL reduz a probabilidade de uma race condition, mas não significa que não irá acontecer.
A piece of code is thread-safe if it only manipulates shared data structures in a manner that guarantees safe execution by multiple threads at the same time.
Isso pode ser comprovado no seguinte exemplo:
O código acima faz uma requisição HTTP e incrementa um contador. O resultado esperado deste programa é 5, uma vez que o contador será incrementado 5 vezes. No entanto, não é isso que acontece.
$ ruby thread-safety-ruby.rb
1Vamos analisar o fluxo desse programa em mais detalhes:
- A primeira Thread recupera o valor do contador (zero), armazena em uma variável temporária e faz uma requisição HTTP. O thread scheduler interrompe a Thread atual e executa a Thread seguinte.
- A segunda Thread passa pelo mesmo fluxo: recupera o valor do contador (ainda zero), armazena em uma variável temporária e faz uma requisição HTTP, o que interrompe a Thread atual e executa a Thread seguinte.
- Este fluxo continua até que as demais Threads sejam executadas e as requisições HTTP sejam finalizadas.
- O contador é então incrementado com
temp + 1. - O valor do contador é impresso no
STDOUT.
Porque o valor de temp em cada Thread é zero, a saída do programa é 1.
Na verdade, a saída deste programa não é garantida. Dependendo de como o thread scheduler muda de contexto entre as Threads e a velocidade da rede, é possível que o programa imprima diferentes valores em diferentes execuções.
Este é um problema bastante comum em aplicações multi-threaded.
Para tornar o código anterior thread-safe, podemos usar um mutex na parte crítica do código, que altera o valor do contador:
Com um mutex, garantimos que enquanto a Thread atual não atualizar o contador, nenhuma outra Thread deverá executar o mesmo trecho de código.
$ ruby thread-safety-ruby-mutex.rb
5Conclusão
Neste post, mostramos como a Ruby MRI garante que uma única Thread seja executada por vez devido ao Global Interpreter Lock. Além disso, apresentamos que concorrência não deve ser usada em todos os casos e os problemas que surgem ao escrever o código multi-threaded.
Código concorrente é naturalmente complexo e é importante que a complexidade adicional venha com ganhos de desempenho. Portanto, lembre-se de sempre medir a sua aplicação pois, como vimos anteriormente, código concorrente não é necessariamente mais rápido.
