Java 8: Conheça os recursos para a programação concorrente
Aprenda com esse artigo a utilizar as melhorias da biblioteca de concorrência do Java 8 e aprimore o desempenho e a qualidade de suas aplicações.
A programação concorrente sempre foi sinônimo de desafio para grande parte dos desenvolvedores. Nesse contexto, muitos consideram que a programação orientada a objetos e a programação concorrente, depois de muito estudo e prática, deixam de ser algo tão complicado. Para a primeira, certamente a afirmação é coerente, mas como veremos ao longo do artigo, para a segunda existem controvérsias.
Quando um desenvolvedor precisa de uma nova linha de execução concorrente (thread), o máximo que ele pode fazer é criar e solicitar a execução da thread. Essa solicitação é então enviada ao escalonador, agente de baixo nível, geralmente do sistema operacional, que é responsável por coordenar a execução das threads. Tal agente é o único que tem o poder de determinar quando uma thread irá para a CPU para executar suas tarefas e por quanto tempo permanecerá lá. Portanto, a execução de threads, por natureza, é algo imprevisível; não há como determinar de antemão quando uma thread irá executar e por quanto tempo. Essa imprevisibilidade aumenta consideravelmente a complexidade de sistemas que utilizam threads, tornando difícil seu teste e a assertividade de seu processamento.
Além disso, problemas como deadlocks (vide BOX 1), que fazem com que duas ou mais threads fiquem bloqueadas “eternamente”, e o aumento de contenção, situação em que muitas threads tentam acessar uma mesma área de memória simultaneamente e apenas uma delas consegue o acesso, podem levar a inconsistências ou queda de desempenho, se não evitados ou tratados adequadamente.
Condição em que uma thread bloqueia um recurso X e precisa de um recurso Y para finalizar. Contudo, o recurso Y também está bloqueado por outra thread, que aguarda pelo recurso X para executar seu processamento.
Assim, algo que parece benéfico pode, subitamente, se tornar prejudicial. No entanto, independentemente disso, threads são inevitáveis. Imagine um servidor web que atenda apenas a uma requisição por vez (monothread). Em termos de eficiência, o desempenho de tal servidor seria pobre para o usuário, pois em situações em que o servidor receber vários acessos simultâneos pode se formar uma fila de espera na qual apenas uma requisição será atendida por vez, pela única thread que ele possui.
Já um servidor multithreading possibilita o atendimento a diversas requisições simultaneamente, o que é muito interessante em termos de experiência para o usuário. Esse e outros benefícios de um software multithreading se estendem tanto aos usuários quanto ao próprio sistema. Como exemplo, considere um programa leitor e transmissor de e-mails, no qual é possível receber e-mails enquanto outro está sendo redigido pelo usuário, sem que uma ação precise esperar a outra para acontecer. Junto a isso, há também um melhor uso dos recursos de hardware, pois com mais threads é possível deixar o sistema realizando alguma tarefa que independe de uma ação do usuário, mas que seja útil para o mesmo, como apagar e-mails que estejam na lixeira por mais de um mês.
O uso de threads leva a um modelo de programação conhecido como programação concorrente. Esse modelo é diferente de programas monothread, pois visa lidar com situações que, claro, não acontecem quando se tem apenas uma thread em execução. Por exemplo, considere uma situação em que uma variável precisa ter seu valor somado a 1 por cada thread ativa em um programa a cada um segundo. Se as threads ativas fizerem essa soma simultaneamente, o valor dessa variável poderá ficar inconsistente, pois, como dito, não há certeza de quando uma thread irá executar e nem por quanto tempo. Assim, há chances de uma thread ler o valor da variável e ser interrompida pelo escalonador logo após essa leitura, a thread seguinte ler o mesmo valor da thread anterior e, consequentemente, as duas somarem 1 ao mesmo valor da variável, o que tornaria o resultado inconsistente. Note que em um programa monothread esse problema não aconteceria.
Para evitar o problema de inconsistência na soma do exemplo citado, alguma abordagem especial deve ser aplicada, como bloquear o acesso à variável que mantém a soma e conceder a apenas uma thread por vez o acesso. No entanto, as diferentes abordagens necessárias em ambientes multithreading não são simples de aplicar. O bloqueio à variável, por exemplo, é algo bem complicado de construir. Para se ter uma ideia, imagine: como evitar que duas thread obtenham o bloqueio simultaneamente?
Devido a isso, muitas linguagens oferecem recursos nativos e bibliotecas complementares a fim de facilitar a vida do desenvolvedor quando o mesmo precisa lidar com esses tipos de situações. Em Java, essa biblioteca é formada por todas as interfaces, classes, enums e demais itens contidos no pacote java.util.concurrent. Esse pacote contempla a implementação da JSR 166 – Concurrency Utilities, especificação da API para funcionalidades como bloqueios de acessos simultâneos (locks), sincronizadores de threads, controlador de pool de threads, coordenador de tarefas assíncronas, coleções concorrentes, variáveis atômicas, entre outras.
Adicionada na versão 5 do Java, essa biblioteca vem evoluindo bastante, e agora, com a versão 8, novas melhorias foram feitas. Entre as mais significativas estão as novas propostas para situações já atendidas na API, como um novo modelo de lock (bloqueio), uma nova forma de coordenar tarefas assíncronas e uma nova abordagem para variáveis atômicas.
A partir do que foi comentado, ao longo deste artigo serão apresentadas quais são essas novas abordagens oferecidas pela API e como elas se relacionam com o que já existia; isso tudo através de exemplos.
O problema com áreas de memória compartilhadas
Quando uma área de dados de um programa pode ser acessada por mais de uma thread ao mesmo tempo, essa área é chamada de região crítica. E quando muitas threads querem alterar simultaneamente uma região crítica, resultados imprevisíveis podem acontecer, o que não é algo desejável. Para evitar essa situação é necessário aplicar uma técnica chamada de exclusão mútua, solução que restringe o acesso à região crítica a apenas uma thread por vez, o que impede que dados sejam alterados ao mesmo tempo por diversas linhas de processamento e garante a consistência dos mesmos.
A linguagem Java, desde seu início, oferece um meio de viabilizar a exclusão mútua: a palavra reservada synchronized. Com a sintaxe synchronized(instance){…}, essa instrução bloqueia o parâmetro recebido através de instance para a thread que realizou a execução. Dessa maneira, o objeto fica preso à thread, ou seja, ela obtém o bloqueio (lock) do objeto, e enquanto não liberar esse lock (sair do bloco synchronized), todas as outras que desejarem acessar o objeto terão que esperar, gerando um efeito exclusivo e bloqueante. Exclusivo porque somente uma thread pode pegar para si o lock de um objeto e bloqueante porque as outras terão que esperar a liberação do objeto para ter acesso ao mesmo. Bloqueios que são exclusivos e bloqueantes são conhecidos como bloqueios pessimistas.
Contudo, o lock de um objeto obtido através da instrução synchronized é um recurso de baixo nível que não oferece muita flexibilidade ao desenvolvedor. Desse modo, se uma thread entra em um bloco synchronized, não há como permitir que outras também entrem em tal bloco baseado em alguma condição, ou seja, a primeira thread fará todas as outras esperarem. Ademais, não há como determinar o tempo máximo de espera para as threads que estão nessa condição, a fim de evitar que elas fiquem esperando indefinidamente.
Devido a essa “rigidez” da instrução synchronized que a API de concorrência do Java trouxe abstrações de alto nível para os bloqueios. Inicialmente representadas pela interface Lock e as implementações ReadWriteLock, ReentrantLock e ReentrantReadWriteLock, essas novas opções possuem um objetivo semelhante ao da instrução "
[...] continue lendo...Artigos relacionados
-
Artigo
-
Artigo
-
Artigo
-
Artigo
-
Artigo