Concorrência em Ruby

Fernando Kakimoto
Jul 26, 2017 · 5 min read

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
1

Vamos analisar o fluxo desse programa em mais detalhes:

  1. 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.
  2. 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.
  3. Este fluxo continua até que as demais Threads sejam executadas e as requisições HTTP sejam finalizadas.
  4. O contador é então incrementado com temp + 1.
  5. 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
5

Conclusã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.

bionexo

Fernando Kakimoto

Written by

bionexo

bionexo

Agile Culture, Software Development, Innovation and Health

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade