Ano passado, falei na Rubyconf 2011 sobre tópicos avançados de threading. O que me surpreendeu foi a pouca experiência que as pessoas tinham com threads, então eu decidi escrever este artigo para dar um pouco mais de bakground para as pessoas sobre esse assunto.
O Matz na verdade não recomenda a utilização de threads (veja abaixo o porquê), e eu acredito que esse é o grande motivo pelo qual os rubyistas tendem a não compreender o threading.
Threading simples
Toda vez que você executa ruby, rails ou irb, você está criando um processo. Dentro de cada processo, você tem algo que está executando o código no seu processo. Isso é chamado de thread.
Seu sistema operacional começa todo processo com uma thread “principal”. O Ruby permite que você crie quantas threads adicionais você desejar, chamando Thread.new com um bloco de código a ser executado. Assim que o bloco de código acabar de ser executado, a thread é considerada morta. Se a thread principal existir, o processo morre.
t1 = Thread.new do
i = 0
1_000_000.times do
i += 1
end
end
t2 = Thread.new do
j = 0
1_000_000.times do
j += 1
end
end
t1.join
t2.join
Acima, temos duas threads contando independentemente até um milhão, enquanto a thread principal espera que elas terminem chamando join em cada thread. Essas duas threads serão executadas ao mesmo tempo (“operando ou ocorrendo ao mesmo tempo”) com a thread principal do seu processo. Não é tão difícil, é?
Condições de corrida
Geralmente, seu computador é capaz de executar uma thread por core. Eu tenho uma CPU dual core no meu laptop, o que significa que eu consigo executar duas threads ao mesmo tempo. Agora imagine que eu quero paralelizar minha contagem acima. Em vez de ter uma thread contando até dois milhões, eu terei duas threads contando, cada uma, até um milhão. Isso deve rodar duas vezes mais rápido, porque estarei usando duas threads e, assim, ambos os cores:
i = 0
t1 = Thread.new do
1_000_000.times do
i += 1
end
end
t2 = Thread.new do
1_000_000.times do
i += 1
end
end
t1.join
t2.join
puts i
Você espera que o resultado seja “2000000?, certo? Bela tentativa.
> jruby threading.rb
1330864
Sempre que threads múltiplas tentam mudar as mesmas variáveis, elas têm o potencial para condições de corrida. Por que isso?
A condição de corrida é fundamentalmente devido ao processo de vários passos para mudar uma variável. Até um simples incremento na maior parte das linguagens é um processo de vários passos:
register = i # read the current value from RAM into a register
register = register + 1 # increment it by one
i = register # write the value back to the variable in RAM
Um dos recursos das threads é que elas são controladas pelo sistema operacional; o SO pode decidir parar a Thread 1 e começar a executar a Thread 2 a qualquer momento. Isso significa que o SO pode parar sua thread depois de ter lido o valor de i para um registrador. Imagine esta sequência de eventos:
i = 0
# OS is running Thread 1
register = i # 0
register = register + 1 # 1
# OS switches to Thread 2
register = i # 0
register = register + 1 # 1
i = register # 1
# Now OS switches back to Thread 1
i = register # 1
Agora, tecnicamente ambas as threads incrementaram i. O valor resultante será 2? Não, porque o incremento da Thread 2 foi perdido quando a última operação da Thread 1 sobrescreveu a memória. Esse é o motivo de termos visto 1330864 em vez de 2000000; perdemos muitos dos incrementos devido à essa condição de corrida. Para evitar condições de corrida, qualquer mudança de variáveis (elegante terminologia CS: “mutação de estados compartilhados”) devem ser feitas de forma atômica, de modo que outras threads não possam ver a mudança no meio do caminho desse processo.
Segurança de thread
Agora você sabe o requisito fundamental para a thread ficar segura no código: a mutação de estado compartilhado deve ser feita de forma atômica. Sempre que você mudar uma variável que for compartilhada por várias threads, isso precisa ser feito de forma atômica. Infelizmente, o Ruby e a maior parte das linguagens mais importantes somente fornecem uma ferramenta para fazer isso: o lock, também conhecido como mutex.
Mutex é a abreviação de “mutual exclusion”, o que significa que “somente uma thread pode executar esse código de cada vez”.
Seu uso é simples:
@mutex = Mutex.new
@mutex.synchronize do
i += 1
end
Lembre-se de que o incremento é um processo de três passos, mas como somente uma thread pode estar no bloco sincronizado por vez, não iremos ter problemas com as condições de corrida; o Mutex, de maneira eficaz, torna o incremento atômico.
Aqui está o segredo que tudo mundo que usa threads aprende eventualmente: Threads têm uma reputação muito ruim, porque os locks são muito difíceis de usar na prática.
Threading moderno
Quais são as alternativas? Existem muitas.
- Instruções Atômicas – transforme operações de vários passos em uma única operação atômica
- Transactional Memory (STM) – certifique-se de que as mudanças são feitas como parte de uma transação que garanta a atomicidade.
- Actors – Refatore nosso código de modo que somente uma thread possa mudar uma variável.
Acredito que os locks aumentam exponencialmente a complexidade da sua base de código, e essa é a principal razão pela qual Matz aconselhou os rubystas a usar Processes em vez de Threads para concorrência. Minha palestra na Rubyconf sobre Threads discutiu essas opções. A linguagem Clojure obriga a memória transacional para todas as mudanças de variáveis. Scala e Erlang oferecem Actors. Usar threads simples e antigas e locks é parecido com escrever em Assembly: existem maneiras melhores agora.
Na minha opinião, a última opção é a preferida, uma vez que você evita a condição de corrida: “Não comunique por compartilhamento de estado; compartilhe o estado por comunicação”. A ideia fundamental por trás dos actors é dar a cada thread uma responsabilidade separada e passar a mensagem entre as threads de acordo com essas responsabilidades.
Meu primeiro conselho para os Rubistas: evite Thread.new. Isso é exatamente o que Matz diz. Procure por infraestrutura que possa abstrair o uso de threads para um modelo mais seguro de concorrência. Veja Celluloid e girl_friday, por exemplo. Claro, o MRI não é particularmente feito para aplicações de alta concorrência. O JRuby é uma escolha melhor. Outras linguagens como Clojure ou Erlang foram criadas tendo a concorrência como um recurso de linguagem desde o início.
Não estou dizendo que threads e locks deveriam ser completamente removidos de todos os softwares. Em vez disso, deveríamos tratá-los como são: abstrações de baixo nível que os desenvolvedores não deveriam usar diretamente. Como threads e locks, vejo uma necessidade de uma linguagem Assemby, mas ela deveria ser usada com moderação.
Compreender e saber como usar abstrações de concorrência de alto nível como actors e STM irá deixar as peças de concorrência da sua aplicação mais fáceis de escrever e de manter. Infelizmente, nem todas essas opções estão disponíveis para MRI, mas todas estão disponíveis para JRuby através das bibliotecas Java.
?
Texto original disponível em http://blog.carbonfive.com/2011/10/11/a-modern-guide-to-threads/








