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