Capítulo 13. Programação Concorrente

O tempo é a substância da qual sou feito. O tempo é um rio que me arrasta, mas eu sou o rio; é um tigre que me devora, mas eu sou o tigre; é um fogo que me consome, mas eu sou o fogo.

— Jorge Luis Borges

13.1. Introdução

Até agora, nos concentramos principalmente em escrever programas sequenciais. A execução sequencial significa que as instruções do programa são executadas uma de cada vez em uma sequência determinada pela lógica do programa e pelos dados de entrada. No entanto, mais de uma instrução de programa pode ser executada independentemente por um processador multicore. Embora seja comum os programadores escreverem programas sequenciais, a disponibilidade generalizada de processadores de vários núcleos em um único computador levou a um aumento na demanda por programadores que possam escrever programas concorrentes.

Um programa concorrente é aquele em que várias instruções podem ser executadas simultaneamente por dois ou mais núcleos. Neste capítulo, mostramos como escrever programas concorrentes simples em Java que exploram o poder de um computador com vários núcleos. Começamos com um problema em que o destino do planeta está em grave perigo!

13.2. Problema: Vírus mortal

Um vírus mortal capaz de exterminar a população mundial está prestes a ser liberado por um gênio do mal. Somente ele sabe o código de segurança que pode interromper a contagem regressiva. O mundo está condenado. A única esperança de salvação está em você e em suas habilidades de programação em Java. Por meio das investigações de uma rede de espionagem ultrassecreta e financiada pelo governo, foi revelado que o código de segurança está vinculado ao número 59.984.005.171.248.659. Esse grande número é o produto de dois números primos, e o código de segurança é a soma deles. Tudo o que você precisa fazer é fatorar o número 59.984.005.171.248.659 em seus dois fatores primos e somá-los.

É claro que há uma pegadinha. O vírus mortal será lançado em breve, tão em breve que talvez não haja tempo suficiente para seu computador pesquisar todos os números um a um. Em vez disso, você deve usar a concorrência para verificar mais de um número por vez.

Esse problema parece artificial? Para manter a privacidade das informações enviadas pela Internet, muitos tipos de criptografia de chave pública dependem da dificuldade de fatorar números grandes. Embora a fatoração do número nesse problema não seja difícil, os números usados para criptografia de chave pública, geralmente com mais de 300 dígitos decimais, resistem à fatoração até mesmo pelos computadores mais rápidos.

13.3. Conceitos: Dividindo para conquistar

O problema do vírus mortal tem uma grande tarefa (fatorar um número) a ser executada. Como devemos dividir essa tarefa para que possamos realizá-la mais rapidamente? Dividir o trabalho a ser feito é o cerne de qualquer solução concorrente para um problema.

Em um processador com vários núcleos, cada núcleo é um trabalhador independente. É preciso algum cuidado para coordenar esses trabalhadores. Antes de tudo, ainda precisamos obter a resposta correta. Uma solução concorrente não tem valor se estiver incorreta e, ao ler e gravar na mesma memória compartilhada, as respostas encontradas por um núcleo podem corromper as respostas encontradas por outros núcleos. A prevenção desse problema será abordada no Capítulo 14. Quando pudermos garantir que a solução concorrente está correta, também vamos querer melhorar o desempenho. Talvez queiramos que a tarefa seja concluída mais rapidamente. Talvez tenhamos um sistema interativo que deva continuar a lidar com as solicitações dos usuários mesmo que esteja trabalhando em uma solução em segundo plano. Novamente, se a sobrecarga de coordenar nossos trabalhadores levar mais tempo do que uma solução sequencial ou tornar o sistema menos responsivo, isso não será útil.

Há duas maneiras principais de dividir o trabalho. A primeira é chamada de decomposição de tarefas. Nessa abordagem, cada funcionário recebe uma tarefa diferente para fazer. A segunda é chamada de decomposição de domínio. Nessa abordagem, os funcionários fazem o mesmo trabalho, mas com dados diferentes.

É possível usar tanto a decomposição de tarefas quanto a de domínios para resolver o mesmo problema. Com os dois tipos de decomposição, geralmente é necessário coordenar os trabalhadores para que possam compartilhar informações. Nas próximas duas subseções, descreveremos com mais detalhes a decomposição de tarefas e a decomposição de domínios. Em seguida, discutiremos o mapeamento de tarefas para threads de execução e as diferentes arquiteturas de memória que podem ser usadas para programação simultânea.

13.3.1. Decomposição de tarefas

A ideia de dividir uma tarefa em subtarefas menores é natural. Imagine que você está planejando um jantar. Você precisa comprar suprimentos, preparar o jantar, limpar a casa e arrumar a mesa. Se quatro de vocês estivessem planejando a festa, cada um poderia realizar uma atividade separada. Os preparativos poderiam ser muito mais rápidos do que se uma única pessoa estivesse fazendo o trabalho, mas a coordenação ainda é importante. Talvez a pessoa que estiver preparando o jantar não possa terminar até que certos suprimentos sejam comprados.

A decomposição de tarefas geralmente é mais fácil do que a decomposição de domínios porque muitas tarefas têm divisões naturais. Infelizmente, essa nem sempre é uma maneira eficaz de usar vários núcleos em um computador. Se uma tarefa for concluída muito antes das outras, um núcleo poderá ficar ocioso.

Os dois exemplos a seguir mostram ilustrações simples do processo de divisão de uma tarefa em subtarefas menores no contexto da programação multicore.

Exemplo 13.1 Tarefas de videogame

Considere um videogame simples que consiste nas seguintes tarefas

  1. Iniciar o jogo

  2. Processar a jogada

  3. Atualizar pontuação

  4. Repintar a tela

  5. Encerrar o jogo

video game tasks
Figura 13.1 Execução de tarefas em um vídeogame. (a) Execução sequencial em um núcleo. (b) Execução concorrente em dois núcleos. As setas indicam o fluxo de execução e de transferência de dados.

Suponha que as tarefas B e D sejam independentes e possam ser executadas simultaneamente se houver dois núcleos disponíveis. A tarefa D atualiza continuamente a tela com os dados antigos até que a tarefa C atualize as informações.

Figura 13.1(a) e (b) mostram como as tarefas desse videogame podem ser sequenciadas, respectivamente, em um único núcleo ou em dois núcleos. Todas as tarefas são executadas sequencialmente em um processador de núcleo único. Em um processador de núcleo duplo, as tarefas B e C podem ser executadas em um núcleo enquanto a tarefa D é executada simultaneamente em outro. Observe na figura que a tarefa C envia a pontuação e qualquer outros dados para a tarefa D, que atualiza continuamente a tela. Ter dois núcleos pode permitir uma atualização mais rápida da tela, pois o processador não precisa esperar que as tarefas B ou C sejam concluídas.

Exemplo 13.2 Tarefas de expressão matemática

Suponha que precisemos avaliar a expressão matemática 2Kate-⁠at² com os parâmetros a e K em um determinado valor de t. Podemos dividir a expressão em dois termos: 2Kat e e-⁠at². Cada um desses termos pode ser atribuído a uma tarefa diferente para avaliação. Em um processador de dois núcleos, essas duas tarefas podem ser executadas em núcleos separados e os resultados de cada uma delas podem ser combinados para encontrar o valor da expressão para a tarefa principal.

mathematical expression evaluation
Figura 13.2 Avaliação de uma expressão matemática (a) sequencialmente em um único núcleo e (b) simultaneamente em dois núcleos. As setas mostram o fluxo de execução e a transferência de dados. A fonte em destaque indica a operação que está sendo executada.

Figura 13.2 mostra como essa expressão pode ser avaliada em processadores de um núcleo e de dois núcleos. Às vezes, o uso de vários núcleos para avaliar uma expressão como essa levará menos tempo do que um único núcleo. No entanto, não há garantia de que o uso de vários núcleos sempre será mais rápido, pois as tarefas levam tempo para serem configuradas e para se comunicarem entre si.

Esses exemplos ilustram como uma tarefa pode ser dividida em duas ou mais subtarefas executadas por diferentes núcleos de um processador. Usamos um processador dual-core em nossos exemplos, mas as mesmas ideias podem ser expandidas para um número maior de núcleos.

13.3.2. Decomposição de domínio

Em um programa de computador, cada tarefa executa operações em dados. Esses dados são chamados de domínio dessa tarefa. Na decomposição do domínio, os dados são divididos em partes menores, em que cada parte é atribuída a um núcleo diferente, em vez de dividir uma tarefa em subtarefas. Assim, cada núcleo executa a mesma tarefa, mas em dados diferentes.

No exemplo do jantar, poderíamos ter usado a decomposição de domínio em vez de (ou além de) decomposição de tarefa. Se quiser cozinhar uma grande quantidade de purê de batatas, você mesmo pode descascar 24 batatas. Entretanto, se houver quatro pessoas (e cada uma delas tiver um descascador de batatas), cada pessoa precisará descascar apenas 6 batatas.

A estratégia de decomposição de domínio é muito útil e é um dos principais focos da simultaneidade neste livro. Os problemas da computação moderna geralmente usam dados maciços, que incluem milhões de valores ou milhares de registros de bancos de dados. Escrever programas que possam dividir os dados de modo que vários núcleos possam processar seções menores pode acelerar muito o tempo necessário para concluir o cálculo.

De certa forma, a decomposição de domínios pode ser mais difícil do que a decomposição de tarefas. Os dados devem ser divididos de maneira uniforme e justa. Depois que cada seção de dados tiver sido processada, os resultados deverão ser combinados. Empresas como o Google, que processam grandes quantidades de informações, desenvolveram uma terminologia para descrever esse processo. Dividir os dados e atribuí-los aos funcionários é chamado de etapa do mapa (map). A combinação das respostas parciais na resposta final é chamada de etapa de redução (reduce).

Ilustramos a estratégia de decomposição de domínio nos dois exemplos a seguir.

Exemplo 13.3 Visualização da soma de matrizes

Suponha que queiramos aplicar a função f a cada elemento de uma matriz a e somar os resultados. Matematicamente, queremos calcular a seguinte soma.

oneCoreSum

Nessa fórmula, ai é o iésimo elemento da matriz a. Vamos supor que temos um processador dual-core disponível para calcular a soma. Dividimos a matriz de modo que cada núcleo execute a tarefa em metade da matriz. Sejam S1 e S2 denotem as somas calculadas pelo núcleo 1 e pelo núcleo 2, respectivamente.

twoCoreSum

Supondo que N seja uniforme, os dois núcleos processam exatamente a mesma quantidade de dados. Para N ímpar, um dos núcleos processa um item de dados a mais do que o outro.

arrayDecomposition
Figura 13.3 Calculando a soma de uma função de cada elemento de uma matriz.

Depois que S1 e S2 tiverem sido computados, um dos núcleos pode somar esses dois números para obter S. Essa estratégia é ilustrada em Figura 13.3. Depois que os dois núcleos tiverem concluído seu trabalho em cada metade da matriz, as somas individuais são então somadas para produzir o resultado final.

Exemplo 13.4 Visualização da multiplicação de matrizes

A necessidade de multiplicar matrizes surge em muitas aplicações matemáticas, científicas e de engenharia. Suponha que nos peçam para escrever um programa para multiplicar duas matrizes quadradas A e B, que são ambas matrizes n × n. A matriz do produto C também será n × n. Um programa sequencial calculará cada elemento da matriz C um de cada vez. vez. Entretanto, um programa simultâneo pode calcular mais de um elemento de C simultaneamente usando vários núcleos.

matrixDecomposition
Figura 13.4 Decomposição de dados para multiplicar duas matrizes 4 × 4. Os dois núcleos executam as mesmas tarefas de multiplicação, mas em dados diferentes da matriz A.Os dois núcleos calculam as duas linhas superiores e as duas linhas inferiores de C, respectivamente.

Neste problema, a tarefa é multiplicar as matrizes A e B. Por meio da decomposição de domínio, podemos replicar essa tarefa em cada núcleo. Conforme mostrado na Figura 13.4, cada núcleo calcula apenas uma parte de C. Por exemplo, se A e B forem 4 × 4, podemos pedir a um núcleo que calcule o produto das duas primeiras linhas de A com todas as quatro colunas de B para gerar as duas primeiras linhas de C. O segundo núcleo calcula as duas linhas restantes de C. Ambos os núcleos podem acessar as matrizes A e B.

13.3.3. Tarefas e threads

É responsabilidade do programador dividir sua solução em uma série de tarefas e subtarefas que serão executadas em um ou mais núcleos de um processador. Nas seções anteriores, descrevemos programas concorrentes como se tarefas específicas pudessem ser atribuídas a núcleos específicos, mas o Java não fornece uma maneira direta de fazer isso.

Em vez disso, um programador Java deve agrupar um conjunto de tarefas e subtarefas em uma thread. Uma thread é muito parecido com um programa sequencial. De fato, todos os programas sequenciais são compostos de uma única thread. Uma thread é um segmento de execução de código que percorre suas instruções passo a passo. Cada thread pode ser executado de forma independente. Se você tiver um processador de núcleo único apenas uma thread pode ser executada por vez, e todas as threads se revezarão. Se você tiver um processador com vários núcleos, tantas threads quantos forem os núcleos núcleos podem ser executados ao mesmo tempo. Não é possível escolher em qual núcleo uma determinada thread será executada. Na maioria dos casos, você não conseguirá nem mesmo saber qual núcleo uma determinada thread está usando.

Ele tem o cuidado de empacotar o conjunto certo de tarefas em uma única thread de execução. Lembre-se dos exemplos anteriores de programação concorrente neste capítulo.

Considere a possibilidade de dividir as tarefas em Exemplo 13.1 em duas threads. As tarefas B e C podem ser agrupadas na thread 1, e a tarefa D pode ser empacotada na thread 2.Essa divisão é mostrada em Figura 13.5(a).

As tarefas para avaliar diferentes subexpressões em Exemplo 13.2 também podem ser divididas em duas threads, conforme mostrado em Figura 13.5 (b). Em muitos problemas, há várias maneiras razoáveis de dividir um conjunto de subtarefas em threads.

task thread packaging
Figura 13.5 (a) Tarefas em um videogame mostradas agrupadas em duas threads. (b) Tarefas para avaliar uma expressão matemática mostrada empacotada em duas threads. Cada thread pode ou não ser executado no mesmo núcleo que o outro.

Observe que essas figuras são exatamente iguais às figuras anteriores, exceto que as tarefas são agrupadas como threads em vez de núcleos. Esse agrupamento corresponde melhor à realidade, pois podemos controlar como as tarefas são agrupadas em threads, mas não como elas são atribuídas aos núcleos.

Em ambos os exemplos, temos duas threads. É possível que alguma outra thread tenha iniciado a execução dessas threads. Todo programa Java, simultâneo ou sequencial, começa com uma thread. Vamos nos referir a essa thread como a thread main, pois ela contém o método main().

Exemplo 13.3 e Exemplo 13.4 usam várias tarefas idênticas, mas essas tarefas operam em dados diferentes. Em Exemplo 13.3, as duas tarefas podem ser atribuídas a duas threads que operam em diferentes partes da matriz de entrada. A tarefa de somar os resultados das duas threads pode ser uma thread separada ou uma subtarefa incluída em uma das outras threads. Em Exemplo 13.4, as duas tarefas podem novamente ser atribuídas a duas threads distintas que operam em diferentes partes diferentes da matriz de entrada A para gerar as partes correspondentes partes correspondentes da matriz de saída C.

Pode haver muitas maneiras de empacotar tarefas em threads. Também pode haver muitas maneiras de decompor os dados em pedaços menores. As melhores maneiras de executar essas subdivisões de tarefas ou dados dependem do problema em questão e da arquitetura do processador em que o programa será executado.

13.3.4. Arquitecturas de memória e concorrência

Os dois paradigmas mais importantes da programação concorrente são a passagem de mensagens e os sistemas de memória compartilhada. Cada paradigma lida com a comunicação entre as várias unidades de código executadas em paralelo de uma maneira diferente. Os sistemas de passagem de mensagens, como o MPI (Message Passing Interface), abordam esse problema enviando mensagens entre unidades de código independentes, chamadas de processos. Um processo que está executando uma tarefa pode ter de esperar até receber uma mensagem de outro processo para saber como proceder. As mensagens podem ser enviadas de um único processo para outro ou transmitidas para vários. Os sistemas de passagem de mensagens são especialmente úteis quando os processadores que executam o trabalho não compartilham memória.

Por outro lado, o sistema interno de concorrência em Java usa o paradigma de memória compartilhada. compartilhada. Em Java, um programador pode criar várias threads que compartilham o mesmo espaço de memória. Cada thread é um objeto que pode executar um trabalho. Descrevemos as threads como uma como uma forma de empacotar um grupo de tarefas, e os processos são outra forma. As pessoas usam o termo processos para descrever unidades de código em execução com memória separada e threads para descrever unidades de código em execução com memória compartilhada.

Quando você aprendeu a programar pela primeira vez, um dos maiores desafios foi provavelmente aprender a resolver um problema passo a passo. Cada linha do programa tinha de ser executada uma de cada vez, de forma lógica e deterministica. Os seres humanos não pensam naturalmente dessa forma. Temos a tendência de pular de uma coisa para outra, fazendo inferências e suposições, pensando em duas coisas não relacionadas ao mesmo tempo, e assim por diante. Como você sabe agora, só é possível escrever e depurar programas por causa da maneira metódica como eles funcionam.

Você pode imaginar a execução de um programa como uma seta que aponta para uma linha de código, depois a próxima, depois a próxima e assim por diante. Podemos pensar no movimento dessa seta como a thread de execução do programa. O código faz o trabalho real, mas a seta mantém o controle de onde a execução do programa está no momento. O código pode mover a seta para frente, pode fazer aritmética básica, pode decidir entre escolhas com instruções pode fazer coisas repetidamente com loops, pode pular para um método e depois voltar. Uma única thread de execução pode fazer todas essas coisas, mas sua seta não pode estar em dois lugares ao mesmo tempo. Ela não pode dividir dois números em uma parte do programa e avaliar uma declaração if em outra. No entanto, há uma maneira de dividir essa thread de execução de modo que duas ou mais threads estejam executando partes diferentes do programa, e a próxima seção mostrará como isso é feito em Java.

13.4. Sintaxe: Threads em Java

13.4.1. A classe Thread

O Java, como muitas linguagens de programação, inclui os recursos necessários para para empacotar tarefas e subtarefas em threads. A classe Thread e suas subclasses fornecem as ferramentas para criar e gerenciar threads. Por exemplo, a definição de classe a seguir permite que objetos do tipo ThreadedTask sejam criados. Esse objeto pode ser executado como uma thread separada.

public class ThreadedTask extends Thread {
    // Adicionar construtor e corpo da classe
}

O construtor é escrito como qualquer outro construtor, mas há um método especial run() em Thread que pode ser substituído por qualquer uma das suas subclasses. Esse método é o ponto de partida para a thread de execução associada a uma instância da classe. A maioria dos aplicativos Java começa com uma única thread principal que é iniciado em um método main(). As threads adicionais devem começar em algum lugar, e esse lugar é o método run(). Um aplicativo Java continuará a ser executado enquanto houver pelo menos uma thread ativa. O exemplo a seguir mostra duas threads, cada uma avaliando uma subexpressão separada, como em Figura 13.5(b).

Exemplo 13.5 Exemplos de Threads

Vamos criar as classes Thread1 e Thread2. As threads de execução criadas por instâncias destas classes calculam, respetivamente, as duas subexpressões da Figura 13.5(b) e guardam os valores calculados.

public class Thread1 extends Thread {
    private double K, a, t, value;
 
    public Thread1(double K, double a, double t) {
        this.K = K;
        this.a = a;
        this.t = t;
    } 
    public void run() { value = 2*K*a*t; }
    public double getValue() { return value; }
}
public class Thread2 extends Thread {
    private double a, t, value;
    
    public  Thread2(double a, double t) {
        this.a = a;
        this.t = t;
    }
    public void run() { value = Math.exp(-a*t*t); }
    public double getValue() { return value; }
}

O método run() em cada thread acima calcula uma subexpressão e salva seu valor. Mostramos como essas threads podem ser executados para resolver o problema da expressão matemática em Exemplo 13.6.

13.4.2. Criando um objeto thread

Criar um objeto a partir de uma subclasse Thread é o mesmo que criar qualquer outro objeto em Java. Por exemplo, podemos instanciar a classe Thread1 acima para criar um objeto chamado thread1.

Thread1 thread1 = new Thread1(15.1, 2.8, 7.53);

O uso da palavra-chave new para invocar o construtor cria um objeto Thread1, mas não começa a executá-lo como uma nova thread. Como em todas as outras classes, o construtor inicializa os valores dentro do novo objeto objeto. Uma subclasse de Thread pode ter muitos construtores diferentes com qualquer parâmetros que seu projetista considere apropriados.

13.4.3. Iniciando uma thread

Para iniciar a execução do objeto thread, seu método start() deve ser chamado. Por exemplo, o objeto thread1 criado acima pode ser iniciado da seguinte forma.

thread1.start();

Uma vez iniciado, uma thread é executada de forma independente. A chamada do método start() chama automaticamente o método run() do objeto nos bastidores. Quando uma thread precisa compartilhar dados com outra thread, ela pode ter que esperar.

13.4.4. Aguardando por uma thread

Muitas vezes, alguma thread, principal ou não, precisa esperar por outra thread antes de prosseguir com sua execução. O método join() é usado para esperar que uma thread termine a execução. Por exemplo, qualquer thread que executar o código a seguir aguardará a conclusão da thread1.

thread1.join();

A chamada join() é uma chamada bloqueante, o que significa que o código que chama esse método aguardará até que ele retorne. Como ele pode lançar uma InterruptedException verificada enquanto o código estiver aguardando, o método join() é geralmente usado em um bloco try-catch. Podemos adicionar um bloco try-catch ao exemplo thread1 para que possamos nos recuperar de sermos interrompidos enquanto aguardamos a conclusão do thread1.

try {
	System.out.println("Aguardando pela thread 1...");
	thread1.join();
	System.out.println("Thread 1 finalizada!");
}
catch (InterruptedException e) {
	System.out.println("Thread 1 não acabou!");
}

Observe que a InterruptedException é lançada porque a thread principal foi interrompida enquanto aguardava a conclusão da thread1. Se a chamada join() retornar, então thread1 deve ter terminado e informaremos o usuário. Se uma InterruptedException for lançada, alguma thread externa deve ter interrompido a thread principal, forçando-a a parar de esperar pela thread1.

In earlier versions of Java, there was a stop() method which would stop an executing thread. Although this method still exists, it’s been deprecated and shouldn’t be used because it can make a program behave in an unexpected way.

Exemplo 13.6 Cálculos matemáticos com threads

Agora que temos a sintaxe para iniciar threads e esperar que eles terminem, podemos usar as threads definidos em Exemplo 13.5 com uma thread principal para criar nosso primeiro programa concorrente completo. A thread principal na classe MathExpression cria e inicia as threads de trabalho thread1 e thread2 e aguarda sua conclusão. Quando as duas threads concluem sua execução, podemos solicitar a cada uma delas o valor calculado. A thread principal imprime o produto desses valores, que é o resultado da expressão que queremos avaliar.

public class MathExpression { 
    public static void main (String[] args) {
        double K = 120, a = 1.2, t = 2;
        Thread1 thread1 = new Thread1(K, a, t);
        Thread2 thread2 = new Thread2(a, t);
        thread1.start(); // Start thread1
        thread2.start(); // Start thread2
        try { // Wait for both threads to complete
            thread1.join();
            thread2.join();
            System.out.println("Value of expression: " +
                    thread1.getValue()*thread2.getValue());
        }
        catch (InterruptedException e) {
            System.out.println("A thread didn't finish!");
        }        
    }
}

Queremos deixar absolutamente claro quando as threads são criadas, começam a execução e terminam. Esses detalhes são cruciais para os pontos mais finos da programação Java simultânea. Na Figura 13.5, parece que a execução da avaliação da expressão matemática começa com a Thread 1, que gera a Thread 2. Embora essa figura explique bem os conceitos básicos da decomposição de tarefas, os detalhes são mais confusos para o código Java real.

No código acima, a execução começa com o método main() em MathExpression. Ele cria os objetos Thread1 e Thread2 e aguarda que eles terminem. Em seguida, ele lê os valores dos objetos depois que eles pararam de ser executados. Poderíamos ter colocado o método main() na classe Thread1, omitindo totalmente a classe MathExpression. Desta forma isso faria com que a execução correspondesse mais de perto à Figura 13.5 mais próxima, mas tornaria as duas subclasses Thread menos simétricas: a thread principal e a thread1 executariam independentemente o código dentro da Thread1. executariam independentemente o código dentro da classe Thread1, enquanto somente thread2 executaria executaria código dentro da classe Thread2.

thread lifecycle
Figura 13.6 Criando, iniciando, e mesclando as threads em MathExpression, Thread1, and Thread2.

Figura 13.6 mostra a execução de thread1 e thread2 e a thread principal. Observe que a JVM cria e inicia implicitamente o thread principal, que cria e inicia explicitamente a thread1 e a thread2. Mesmo depois que as threads associados a thread1 e thread2 pararem de ser executados, os objetos associados a eles continuam a existir. Seus métodos e campos ainda podem ser acessados.

13.4.5. A interface Runnable

Embora seja possível criar threads em Java herdando diretamente da classe Thread diretamente, a API Java permite que o programador use uma interface em vez disso.

Como exemplo, a classe Summer pega uma matriz de valores int e os soma dentro de um determinado intervalo. Se várias instâncias dessa classe forem executadas como threads separadas, cada uma delas poderá somar diferentes partes de uma matriz.

public class Summer implements Runnable {
    int[] array;
    int lower;
    int upper;
    int sum = 0;
    
    public Summer(int[] array, int lower, int upper) {
        this.array = array;
        this.lower = lower;
        this.upper = upper;
    }
    
    public void run() {
        for(int i = lower; i < upper; i++)
            sum += array[i];
    }
    
    public int getSum() { return sum; }
}

Essa classe é muito semelhante a uma classe que herda de Thread. Imagine por um momento que o código que segue Summer é extends Thread em vez de implements Runnable. A principal coisa que uma classe derivada de Thread precisa é de um método run() sobrescrito. Como somente o método run() é importante, os projetistas do Java forneceram uma maneira de criar uma thread usando a interface Runnable. Para implementar essa interface, é necessário apenas um método public void run().

Ao criar uma nova thread, há algumas diferenças de sintaxe entre os dois estilos. A maneira conhecida de criar e executar um thread a partir de uma subclasse Thread é a seguinte.

Summer summer = new Summer(array, lower, upper);
summer.start();

Como o Summer não herda de Thread, ele não tem um método start(), e esse código não será compilado. Quando uma classe apenas implementa Runnable, ainda é necessário criar um objeto Thread e chamar seu método start(). Portanto, é necessária uma etapa extra.

Summer summer = new Summer(array, lower, upper);
Thread thread = new Thread(summer);
thread.start();

Essa forma alternativa de implementar a interface Runnable parece mais incômoda do que herdar diretamente da Thread, já que é necessário instanciar um objeto Thread separado. Entretanto, a maioria dos desenvolvedores prefere projetar classes que implementem Runnable em vez de herdar de Thread. Por quê? O Java só permite herança única. Se sua classe implementar Runnable, ela estará livre para herdar de outra super classe com os recursos que você desejar.

Exemplo 13.7 Conjunto de threads

Na decomposição de domínios, muitas vezes precisamos criar várias threads, todas a partir da mesma classe. Como exemplo, considere a seguinte declaração de thread.

public class NumberedThread extends Thread {
    private int value;

    public NumberedThread(int input) { value = input; }
    
    public void run() {
        System.out.println("Thread " + value);
    }
}

Agora, suponha que desejemos criar 10 objetos de thread do tipo NumberedThread, então iniciá-los e por fim esperar que sejam concluídos.

NumberedThread[] threads = new NumberedThread[10]; (1)
for(int i = 0; i < threads.length; i++) {
    threads[i] = new NumberedThread(i); (2)
    threads[i].start(); (3)
}
try {
    for(int i = 0; i < threads.length; i++)
        threads[i].join(); (4)
}
catch(InterruptedException e) {
    System.out.println("A thread didn't finish!");
}
1 Primeiro, declaramos uma matriz para manter referências aos objetos NumberedThread. Como qualquer outro tipo, podemos criar uma matriz para armazenar objetos que herdam de Thread.
2 A primeira linha do loop for instancia um novo objeto NumberedThread, invocando o construtor que armazena a iteração atual do loop no campo value. A referência a cada objeto NumberedThread é armazenada na matriz. Lembre-se de que o construtor não inicia a execução de uma nova thread.
3 A segunda linha do loop for faz isso.
4 Também estamos interessados em saber quando as threads param. A chamada do método join() força a thread principal a aguardar a conclusão de cada thread.

Todo o segundo loop for está aninhado dentro de um bloco try. Se a thread principal for interrompida enquanto estiver aguardando a conclusão de qualquer uma das threads terminar, uma InterruptedException será capturada. Como antes, avisamos o usuário que uma thread não foi concluída. Para código com qualidade de produção, o bloco bloco catch deve tratar a exceção de forma que a thread possa se recuperar e fazer um trabalho útil, mesmo que não tenha obtido o resultado esperado.

13.5. Exemplos: Concorrência e aceleração

A aceleração é uma das maiores motivações para escrever programas concorrentes. Para entender o aumento de velocidade, vamos supor que temos um problema para resolver. Escrevemos dois programas para resolver esse problema, um que é sequencial e outro que é concorrente e, portanto, capaz de explorar vários núcleos. Seja ts o tempo médio de execução do o programa sequencial e tc o tempo médio de execução do programa concorrente. Para que a comparação seja significativa, suponha que ambos os programas sejam executados no mesmo computador. O aumento de velocidade obtido com a programação concorrente é definido como ts/tc.

A aceleração mede quanto tempo leva para o programa concorrente ser executado em relação ao programa sequencial. Idealmente, esperamos que tc < ts, tornando a aceleração maior que 1. No entanto, simplesmente escrever um programa concorrente não garante que ele seja mais rápido do que a versão sequencial.

Para determinar o aumento de velocidade, precisamos medir ts e tc. O tempo em um programa Java pode ser facilmente medido com os dois métodos estáticos a seguir na classe System.

public static long currentTimeMillis()
public static long nanoTime()

O primeiro desses métodos retorna a hora atual em milissegundos (ms). Um millisecond equivale a 0,001 segundos. Esse método fornece a diferença entre a hora atual no relógio de seu computador e a meia-noite de 1º de janeiro de 1970, horário universal coordenado (UTC). Esse ponto no tempo é usado para muitos recursos de tempo em muitas plataformas de computador e é chamado de epoch Unix. O outro método retorna a hora atual em nanossegundos (ns). Um nanossegundo equivale a 0,000000001 ou 10-9 segundos. Esse método também fornece a diferença entre a hora atual e uma hora fixa, que depende do sistema e não necessariamente do epoch. O método System.nanoTime() pode ser usado quando você quiser uma precisão de tempo mais fina do que milissegundos; entretanto, o nível de precisão que ele retorna é novamente dependente do sistema. O próximo exemplo mostra como usar esses métodos para medir o tempo de execução.

Exemplo 13.8 Medindo o tempo de execução

Suponha que desejemos medir o tempo de execução de um trecho de código Java. Por conveniência, podemos supor que esse código esteja contido no método work(). O trecho de código a seguir mede o tempo de execução de work().

long start = System.currentTimeMillis();
work();
long end = System.currentTimeMillis();
System.out.println("Tempo decorrido: " + (end - start) + " ms");

A saída fornecerá o tempo de execução de work() medido em milissegundos. Para obter o tempo de execução em nanossegundos, use o método System.nanoTime() em vez de System.currentTimeMillis().

Agora que temos as ferramentas para medir o tempo de execução, podemos medir o aumento de velocidade. Os próximos exemplos mostram o aumento de velocidade (ou a falta dele) que podemos podemos obter usando uma solução simultânea para alguns problemas simples.

Exemplo 13.9 Acelerando o cálculo matemático

Lembre-se do programa concorrente em Exemplo 13.6 para avaliar uma expressão matemática simples. Esse programa usa duas threads. Executamos esse programa multithread em um computador iMac com um Intel Core 2 Duo rodando a 2,16 Ghz. O tempo de execução O tempo de execução foi medido em 1.660.000 nanossegundos. Também escrevemos um programa programa sequencial simples para avaliar a mesma expressão. Foram necessários 4.100 nanossegundos para executar esse programa no mesmo computador. Ao inserir esses valores para tc e ts, podemos encontrar a aceleração.

speedup

Essa aceleração é muito menor que 1. Embora esse resultado possa ser surpreendente, o programa concorrente com duas threads é executado mais lentamente do que o programa sequencial. Nesse exemplo, o custo de criar, executar e processar as threads supera os benefícios do cálculo simultâneo em dois núcleos.

Exemplo 13.10 Somatório de matrizes

Em Exemplo 13.3, apresentamos o problema de aplicar uma função a cada valor em uma matriz e depois somar os resultados. Digamos que queiramos aplicar a função seno a cada valor. Para resolver esse problema de forma concorrente, particionamos a matriz uniformemente entre várias threads, usando a estratégia de decomposição de domínio. Cada thread encontra a soma dos senos dos valores em sua parte da matriz. Um fator que determina se conseguimos ou não velocidade é a complexidade da função, nesse caso o seno, que aplicamos. aplicamos. Embora possamos obter um aumento de velocidade com o seno, uma função mais simples como dobrar o valor pode não gerar trabalho suficiente para justificar a sobrecarga do uso de threads.

Criamos a classe SumThread cujo método run() soma os senos dos elementos da matriz em sua partição atribuída.

import java.util.Random;

public class SumThread extends Thread {
    private double sum = 0.0; (1)
    private int lower;
    private int upper;
	private double[] array; (2)
    public static final int SIZE = 1000000; (3)
    public static final int THREADS = 8;
    
    public SumThread(double[] array, int lower, int upper) { (4)
		this.array = array;
        this.lower = lower;
        this.upper = upper;     
    }
1 Primeiro, configuramos todos os campos de que a classe precisará.
2 Observe que cada objeto SumThread terá sua própria referência à matriz de dados.
3 Fixamos o tamanho da matriz em 1.000.000 e o número de threads em 8, mas esses valores podem ser facilmente alterados ou lidos como entrada.
4 Em seu construtor, um SumThread recebe uma referência à matriz de dados e os limites inferior e superior de sua partição. Como a maioria dos intervalos que discutimos, o limite inferior é inclusivo, embora o limite superior seja exclusivo.
    public void run() {
        for(int i = lower; i < upper; i++)
            sum += Math.sin(array[i]); (1)
    }

    public double getSum() { return sum; } (2)
1 No loop for do método run(), a SumThread encontra o seno de cada número em sua partição da matriz e adiciona esse valor à sua soma.
2 O método getSum() é um acessório que permite que a soma em execução seja seja recuperada.
    public static void main(String[] args) {  
        double[] data = new double[SIZE]; (1)
        Random random = new Random();
        int start = 0;  
        for(int i = 0; i < SIZE; i++) (2)
            data[i] = random.nextDouble();  
        SumThread[] threads = new SumThread[THREADS];
        int quotient = data.length / THREADS;
        int remainder = data.length % THREADS;          
        for(int i = 0; i < THREADS; i++) {
            int work = quotient;
            if(i < remainder)
                work++;
            threads[i] = new SumThread(data, start, start + work); (3)
            threads[i].start(); (4)
            start += work;
        }   
1 O método main() começa instanciando a matriz.
2 Ele o preenche com valores aleatórios.
3 Em seguida, cada thread é criado passando uma referência para o vetor e os limites inferior e superior que marcam a partição da thread na matriz. Se o processo que usa o comprimento da matriz e o número de threads para determinar os limites superior e inferior não fizer sentido, consulte Seção 6.11 que descreve a divisão justa do trabalho para as threads. Se o comprimento da matriz não for divisível pelo número de threads, a divisão simples não será suficiente.
4 Depois que cada thread é criada, seu método start() é chamado.
        double sum = 0.0; (1)
        try { 
            for(int i = 0; i < THREADS; i++) {
                threads[i].join(); (2)
                sum += threads[i].getSum(); (3)
            }
            System.out.println("Sum: " + threads[0].getSum()); (4)
        }
        catch(InterruptedException e) {
            e.printStackTrace(); (5)
        }
    }   
}
1 Depois que as threads começam a trabalhar, a thread principal cria seu próprio total em execução.
2 Ele itera através de cada thread esperando que seja concluído.
3 Quando cada thread é concluída, seu valor é adicionado ao ao total em execução.
4 Finalmente, a soma é impressa.
5 Se a thread principal for interrompida enquanto estiver aguardando a conclusão de uma thread, o rastreamento da pilha será impresso.
Exemplo 13.11 Multiplicação de matrizes

Em Exemplo 13.4, discutimos a a importância das operações de matriz em muitos aplicativos. Agora que conhecemos a sintaxe Java necessária, podemos escrever um programa concorrente para multiplicar duas matrizes quadradas A e B e calcular a matriz resultante C. Se essas matrizes tiverem n linhas e n colunas, o valor na iésima linha e j^^ coluna de C é

matrixValue

Em Java, é natural armazenarmos as matrizes como matrizes bidimensionais. Para fazer essa multiplicação sequencialmente, a abordagem mais simples usa três loops for aninhados. O código abaixo é uma tradução direta da notação matemática, mas temos de ter cuidado com a contagem. Observe que a notação matemática geralmente usa letras maiúsculas para para representar matrizes, embora a convenção Java seja iniciar todos os nomes de variáveis com letras minúsculas.

for(int i = 0; i < c.length; i++)
    for(int j = 0; j < c[i].length; j++)
        for(int k = 0; k < b.length; k++)
            c[i][j] += a[i][k] * b[k][j];

A primeira etapa para criar uma solução concorrente para esse problema é criar uma subclasse Thread que fará alguma parte da multiplicação da matriz. Abaixo está a classe MatrixThread que calculará um número de linhas na matriz de resposta c.

public class MatrixThread extends Thread {
    private double[][] a;
    private double[][] b;
    private double[][] c;
    private int lower;
    private int upper;  
    
    public MatrixThread(double[][] a, double[][] b, (1)
        double[][] c, int lower, int upper) {      
        this.a = a;
        this.b = b;
        this.c = c;
        this.lower = lower;
        this.upper = upper;
    }
    
    public void run() { (2)
        for(int i = lower; i < upper; i++)
            for(int j = 0; j < c[i].length; j++)              
                for(int k = 0; k < b.length; k++)
                    c[i][j] += a[i][k] * b[k][j];
    }
}
1 O construtor de MatrixThread armazena referências às matrizes correspondentes às matrizes A, B e C, bem como os limites inferior e superior das linhas de C a serem computadas.
2 O corpo do método run() é idêntico ao da solução sequencial, exceto pelo fato de que seu loop mais externo é executado somente de lower a upper em vez de percorrer todas as linhas do resultado. É fundamental que a cada thread seja atribuído um conjunto de linhas que não se se sobreponha às linhas que outra thread possui. Não só o fato de várias threads calcularem a mesma linha seria ineficiente, mas muito provavelmente levaria a um resultado incorreto, como veremos em Capítulo 14.

O código cliente a seguir usa uma matriz de objetos MatrixThread para realizar uma multiplicação de matriz. Supomos que uma constante int denominada THREADS tenha sido definida, o que dá o número de threads que queremos criar.

MatrixThread[] threads = new MatrixThread[THREADS];
int quotient = c.length / THREADS;
int remainder = c.length % THREADS;
int start = 0;
for(int i = 0; i < THREADS; i++) {
    int rows = quotient;
    if(i < remainder)
        rows++;
    threads[i] = new MatrixThread(a, b, c, start, start + rows); (1)
    threads[i].start(); (2)
    start += rows;
}
try {
    for(int i = 0; i < THREADS; i++) (3)
        threads[i].join();
}
catch(InterruptedException e) {
    e.printStackTrace();
}
1 Fazemos um loop pela matriz, criando um objeto MatrixThread para cada posição. Como no exemplo anterior, usamos a abordagem descrita em Seção 6.11 para alocar linhas para cada thread de forma justa. Cada novo objeto MatrixThread recebe uma referência a cada uma das três matrizes, bem como uma linha inicial inclusiva e uma linha final exclusiva.
2 Depois que os objetos MatrixThread são criados, começamos a executá-los com a próxima linha de código.
3 Em seguida, há um loop for familiar com as chamadas join() que forçam a thread principal a esperar que as outras threads terminem.

Presumivelmente, o código após esse trecho imprimirá os valores da matriz de resultados ou a usará para outros cálculos. Se não tivermos usado as chamadas join() para garantir que as threads tenham terminado, poderíamos imprimir uma matriz de resultados que foi preenchida apenas parcialmente.

Concluímos o código da multiplicação de matrizes com threads e o executamos em um computador iMac com um Intel Core 2 Duo rodando a 2,16 Ghz. O programa foi executado para matrizes de diferentes tamanhos (n × n). Para cada tamanho, os tempos de execução sequencial e concorrente em segundos e o tempo de execucação correspondente estão listados na tabela a seguir.

Size (n) ts (s) tc (s) Speedup

100

0.013

0.9

0.014

500

1.75

4.5

0.39

1,000

15.6

10.7

1.45*

Somente com matrizes de 1.000 × 1.000 observamos um melhor desempenho ao usar duas threads. Nesse caso, obtivemos um aumento de velocidade de 1,45, marcado com um asterisco. Nos outros dois casos, o desempenho piorou.

13.6. Conteitos: Agendamento de thread

Agora que vimos como várias threads podem ser usadas juntas, várias perguntas surgem: Quem decide quando essas threads são executadas? Como o tempo do processador é compartilhado entre as threads? Podemos fazer alguma suposição sobre a ordem de execução das threads? Podemos afetar essa ordem?

Essas perguntas se concentram no agendamento de threads. Como diferentes sistemas concorrente lidam com o agendamento de forma diferente, descreveremos apenas o agendamento em Java. Embora a programação sequencial tenha tudo a ver com o controle preciso sobre o que acontece depois, a concorrência tira grande parte desse controle do programador. Quando as threads são agendadas e em qual processador eles são executados, isso é feito por uma combinação da JVM e do sistema operacional. Com as JVMs normais, não há uma maneira explícita de acessar o agendamento e alterá-lo a seu seu agrado.

Obviamente, há várias maneiras implícitas pelas quais um programador pode manipular a a programação. Em Java, como em várias outras linguagens e sistemas de programação, as threads têm prioridades. As threads de prioridade mais alta são executadas com mais frequência do que as de prioridade mais baixa. Algumas threads estão executando operações de missão crítica que devem ser executadas o mais rápido possível, e algumas threads estão apenas realizando tarefas periódicas em segundo plano. Um programador pode definir as prioridades das threads de acordo com essas prioridades.

A definição de prioridades oferece apenas uma maneira muito geral de controlar qual thread será executada. As próprias threads podem ter informações mais específicas sobre quando precisarão ou não de tempo de processador. A thread pode precisar esperar por um evento específico e não precisará ser executada até lá. O Java permite que as threads interajam com o agendador por meio dos métodos Thread.sleep() e Thread.yield(), que discutiremos em Seção 13.7 e por meio do método wait(), que discutiremos em Capítulo 14.

13.6.1. Não determinismo

Em Java, o mapeamento de uma thread dentro da JVM para uma thread no sistema operacional varia. Algumas implementações dão a cada thread Java uma thread do sistema operacional, outras colocam todas as threads Java em uma única thread do sistema operacional (com o efeito colateral de impedir a execução paralela), e algumas permitem a possibilidade de alterar qual thread do sistema operacional uma thread Java usa. Assim, o desempenho e, em alguns casos, a correção de seu programa podem variar, dependendo do sistema que você estiver executando. Esse é, mais uma vez, um daqueles momentos em que o Java é independente de plataforma…​ mas não totalmente.

Infelizmente, a situação é ainda mais complicada. Tornar as threads parte de seu programa significa que o mesmo programa pode ser executado de forma diferente no mesmo sistema. A JVM e o sistema operacional precisam cooperar para agendar as threads e ambos os programas são montanhas complexas de código que tentam equilibrar muitos fatores. Se você criar três threads, não há garantia que o primeiro será executado primeiro, o segundo segundo e o terceiro terceiro, mesmo que isso aconteça nas primeiras 10 vezes em que você executar o programa. O Exercício 13.18 mostra que o padrão de execução das threads pode variar muito.

Em todos os programas anteriores a este capítulo, a mesma sequência de entrada sempre produziria a mesma sequência de saída. Talvez o maior obstáculo criado por esse não determinismo seja o fato de os programadores terem de mudar seu paradigma consideravelmente. O processador pode alternar entre execuções de de threads a qualquer momento, mesmo no meio das operações. Todas as possíveis intercalações de execução de thread pode surgir em algum momento. A menos que você tenha certeza de que seu programa se comporta adequadamente em todos eles, talvez você nunca conseguirá depurar seu código completamente. O que é tão insidioso nos bugs não determinísticos é que eles podem ocorrer raramente e ser quase impossíveis de reproduzir. Neste capítulo, apresentamos como criar e executar threads, mas fazer com que essas threads interajam adequadamente é um problema importante que abordaremos nos capítulos seguintes.

Depois dessas terríveis palavras de advertência, gostaríamos de lembrá-lo de que o não determinismo não é em si uma coisa ruim. Muitos aplicativos com threads com muitas entradas e saídas, como os servidores de aplicação, necessariamente existem em um mundo não determinístico. Para esses programas, muitas diferentes sequências de threads em execução podem ser perfeitamente válidas. Cada programa individual pode ter uma definição diferente de correção. Por exemplo, se um servidor do mercado de ações receber duas solicitações para comprar a última ação de uma determinada empresa quase ao mesmo tempo de duas threads correspondentes a dois clientes diferentes, pode ser correto que qualquer um deles obtenha a última ação. No entanto, nunca seria correto que ambos a recebam.

13.6.2. Votação

Até agora, o único mecanismo que introduzimos para coordenar diferentes threads é usar o método join() para aguardar o término de uma thread. Outra técnica é polling, ou busy waiting. A ideia é continuar verificando o estado de uma thread até que ela mude.

Há vários problemas com essa abordagem. O primeiro é que ela desperdiça ciclos da CPU. Os ciclos gastos pela thread em espera continuamente poderiam ter sido usados de forma produtiva por alguma outra thread no sistema. O segundo problema é que precisamos ter certeza de que o estado da thread que estamos esperando não voltará ao estado original ou para algum outro estado. Devido à imprevisibilidade do agendamento, não há garantia de que a thread em espera lerá o estado da outra thread quando ela tiver o valor correto.

Mencionamos a votação em parte porque ela tem uma importância histórica para a programação paralela, em parte porque pode ser útil na solução de alguns problemas deste capítulo e, em parte, porque queremos que você entenda os motivos pelos quais precisamos de técnicas melhores para a comunicação entre threads.

13.7. Sintaxe: Estados da thread

Uma ferramenta Java amplamente utilizada para manipular o agendamento é o método Thread.sleep(). Esse método pode ser chamado sempre que você quiser que uma thread não faça nada por um determinado período de tempo. Até que o temporizador sleep expire, a thread não será agendada para nenhum tempo de CPU, a menos que seja interrompida. Para fazer uma thread de execução dormir, chame Thread.sleep() nessa thread de execução com um número de milissegundos como parâmetro. Por exemplo, chamar Thread.sleep(2000) fará com que a thread chamada dormirá por dois segundos completos.

Outra ferramenta útil é o método Thread.yield(). Ele abre mão do uso da CPU para que a próxima thread em espera possa ser executada. Para usá-lo, uma thread chama Thread.yield(). Esse método é útil na prática, mas de acordo com a documentação oficial, a JVM não tem que fazer nada quando um Thread.yield() é executado. A especificação Java não exige uma implementação específica. Uma JVM poderia ignorar uma chamada chamada Thread.yield() completamente, mas a maioria das JVMs passará para a próxima thread no cronograma.

thread states
Figura 13.7 Estados e transições da thread.

Figura 13.7 mostra o ciclo de vida de uma thread. A thread começa sua vida no estado New Thread, depois que o construtor é chamado. Quando o método start() é chamado, a thread começa a ser executada e faz a transição para o estado Runnable. Ser executável não significa necessariamente que a thread está sendo executada em um determinado momento, mas que ela está pronta para ser executada a qualquer momento. Quando estiver no estado Runnable, uma thread pode chamar Thread.yield(), deixando de usar o processador, mas ainda permanecerá Runnable.

No entanto, se uma thread for dormir com uma chamada Thread.sleep(), aguardará para que uma condição seja verdadeira usando uma chamada wait() ou executar uma operação de de E/S bloqueante, a thread fará a transição para o estado Not Runnable. As threads não executáveis não podem ser agendadas para o tempo de processador até que acordem, terminem de esperar ou concluam sua E/S. O estado final é Terminated. Uma thread se torna terminada quando seu método run() termina. Uma thread terminada não pode se tornar executável novamente e não é mais uma thread de execução separado.

Qualquer objeto com um tipo que seja uma subclasse de Thread pode informar seu estado atual usando o método getState(). Esse método retorna um tipo enum, cujo valor deve vir de uma lista fixa de objetos constantes. Esses objetos são Thread.State.NEW, Thread.State.RUNNABLE, Thread.State.BLOCKED, Thread.State.WAITING, Thread.State.TIMED_WAITING e Thread.State.TERMINATED. Embora os outros sejam autoexplicativos, agrupamos o Thread.State.BLOCKED, Thread.State.WAITING e Thread.State.TIMED_WAITING no estado Not Runnable, já que a distinção entre os três não é importante para nós.

As threads também têm prioridades em Java. Quando um objeto que é uma subclasse de Thread é criado em Java, sua prioridade é inicialmente a mesma da thread que o cria. Normalmente, essa prioridade é Thread.NORM_PRIORITY, mas há alguns casos especiais em que é uma boa ideia aumentar ou diminuir essa prioridade. Evite alterar as prioridades de thread porque isso aumenta a dependência da plataforma e porque os efeitos nem sempre são previsíveis. Esteja ciente de que as prioridades existem, mas não as use a menos que você tenha um bom motivo.

Exemplo 13.12 Marcha militar

Vamos aplicar as ideias discutidas acima a um exemplo leve. Você talvez esteja familiarizado com o som de soldados marchando: “Esquerda, esquerda, esquerda, direita, esquerda!” Podemos criar uma thread que imprima "Esquerda" e outra que imprima "Direita". Podemos combinar as duas para imprimir a sequência correta para marchar e repetir tudo 10 vezes para que possamos ver com que precisão podemos posicionar as palavras. Queremos usar as ferramentas de agendamento discutidas acima para obter o tempo correto. Vamos tentar Thread.sleep() first.

public class LeftThread extends Thread {
    public void run() {
        for(int i = 0; i < 10; i++) {
            System.out.print("Left ");  (1)
            System.out.print("Left ");
            System.out.print("Left ");
            try { Thread.sleep(10); }  (2)
            catch(InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Left"); (3)
        }
    }
}
1 Dentro do loop for, essa thread imprime Left três vezes.
2 Em seguida, ele aguarda 10 milissegundos.
3 Finalmente, ele imprime Esquerda novamente e repete o loop.
public class RightThread extends Thread {
    public void run() { 
        try {
			Thread.sleep(5); (1)
			for(int i = 0; i < 10; i++) { 
				System.out.print("Right "); (2)
				Thread.sleep(10); (3)
			}
		}
        catch(InterruptedException e) {
            e.printStackTrace();
        }
    }
}
1 Essa thread aguarda 5 milissegundos para ser sincronizada.
2 Dentro de seu loop for, ele imprime Right.
3 Em seguida, ela aguarda 10 milissegundos e repete o loop.

O programa de driver abaixo cria uma thread para cada uma dessas classes e, em seguida, as inicia. Se você executar esse programa, verá 10 linhas de Left Left Left Right Left, mas há alguns problemas.

public class MilitaryMarching {
    public static void main(String[] args) {
        LeftThread left = new LeftThread();
        RightThread right = new RightThread();
        left.start();
        right.start();
        try {
            left.join();
            right.join();
        }
        catch(InterruptedException e) {
            e.printStackTrace();
        }       
    }
}

O primeiro problema é que temos que esperar algum tempo entre as chamadas. Poderíamos encurtar as chamadas Thread.sleep(), mas há limites na resolução do cronômetro. O maior problema é que as duas threads podem, às vezes, ficar fora de sincronia. Se você executar o programa muitas vezes, você poderá ver um Right fora do lugar de vez em quando. Se você aumentar as repetições dos loops for para um número maior, os erros se tornarão mais prováveis. O fato de você ver ou não os erros depende um depende do sistema. Podemos tentar Thread.yield() em vez de Thread.sleep().

public class LeftYieldThread extends Thread {
    public void run() {
        for(int i = 0; i < 10; i++) {
            System.out.print("Left ");
            System.out.print("Left ");
            System.out.print("Left ");              
            Thread.yield();         
            System.out.println("Left");                 
        }
    }
}
public class RightYieldThread extends Thread {
    public void run() {         
        for(int i = 0; i < 10; i++) { 
            System.out.print("Right ");         
            Thread.yield();
        }
    }
}

Essas novas versões das duas classes basicamente substituíram as chamadas para Thread.sleep() por chamadas para Thread.yield(). Sem a necessidade de tratamento de exceções, o código é mais simples, mas trocamos um conjunto de problemas por outro. Se houver outras threads operando na mesma aplicação, eles serão agendadas de forma a interferir no padrão de rendimento. Se você estiver executando esse código em uma máquina com um único processador e um único núcleo, você terá uma boa chance de ver algo que corresponda ao resultado esperado. No entanto, se estiver executando em vários núcleos, tudo ficará confuso. É provável que a LeftYieldThread esteja sendo executada em um processador com a RightYieldThread em outro. Nesse caso, nenhuma delas tem concorrência para ceder.

Por fim, vamos dar uma olhada em uma solução de pesquisa de opinião que ainda não é suficiente. Para fazer isso, precisamos de variáveis de estado dentro de cada classe para para saber se ela foi concluída ou não. Cada thread precisa de uma referência para a outra thread para fazer consultas, e o programa controlador deve ser atualizado para adicioná-las antes de iniciar as threads.

public class LeftPollingThread extends Thread {
    private RightPollingThread right;
    private boolean done = false;
    
    public void setRight(RightPollingThread right) {
        this.right = right;
    }

    public void run() {
        for(int i = 0; i < 10; i++) {
            System.out.print("Left ");
            System.out.print("Left ");
            System.out.print("Left ");          
            done = true;            
            while(!right.isDone());           
            right.setDone(false);                     
            System.out.println("Left");                 
        }
    }
    
    public boolean isDone() { return done; }    
    public void setDone(boolean value) { done = value; }
}
public class RightPollingThread extends Thread {
    private LeftPollingThread left;
    private boolean done = false;   
    
    public void setLeft(LeftPollingThread left) {
        this.left = left;
    }
    
    public void run() { 
        for(int i = 0; i < 10; i++) {             
            while(!left.isDone());            
            left.setDone(false);            
            System.out.print("Right ");         
            done = true;
        }
    }
    
    public boolean isDone() { return done; }    
    public void setDone(boolean value) { done = value; }
}

Seja com um único núcleo ou com vários núcleos, essa solução sempre fornecerá a saída correta. Ou deveria. Os especialistas em Java apontarão que estamos violando um aspecto técnico do modelo de memória Java. Como não estamos usando ferramentas de sincronização, não temos garantia de que a alteração da variável done será sequer visível de uma thread para outra. Na prática, esse problema raramente o afetará, mas, por segurança, ambas as variáveis as variáveis done devem ser declaradas com a palavra-chave volatile. Essa palavra-chave faz com que o Java saiba que o valor pode ser acessado a qualquer momento por threads arbitrárias.

Outro problema é que não há nenhuma execução paralela. Cada thread deve esperar para que o outro seja concluído.É claro que esse problema não se beneficia de paralelismo, mas aplicar essa solução a problemas que podem se beneficiar do paralelismo pode causar problemas de desempenho. Cada thread desperdiça tempo ocupado esperando em um loop while que o outro seja concluído, consumindo ciclos de CPU enquanto faz isso. Você perceberá que o código ainda deve ser escrito com cuidado. Cada thread deve definir o valor`done` da outra thread para false. Se as threads fossem responsáveis por definir seus próprios valores done para false, uma thread poderia imprimir suas informações e voltar para o topo da tabela for antes que a outra thread tivesse redefinido seu próprio done para false.

Em resumo, a coordenação de duas ou mais threads é um problema difícil. Nenhuma das soluções que apresentamos aqui é totalmente aceitável. Nós apresentamos ferramentas melhores para coordenação e sincronização em Capítulo 14.

13.8. Solução: Vírus mortal

Por fim, apresentamos a solução para o problema do vírus mortal. A essa altura, a parte da thread desse problema não deve parecer muito difícil. Ela é mais simples do que alguns dos exemplos, como a multiplicação de matrizes. Começamos com a classe de trabalho FactorThread que pode ser gerada como uma thread.

Programa 13.1 Classe de threads usada para encontrar a soma dos dois fatores de um grande composto ímpar.
public class FactorThread extends Thread {  
    private long lower;
    private long upper; 
    
    public FactorThread(long lower, long upper) {     
        this.lower = lower;
        this.upper = upper;     
    }
    
    public void run() { 
        if(lower % 2 == 0) // Only check odd numbers
            lower++;        
        while(lower < upper) {
            if(Factor.NUMBER % lower == 0) {
                System.out.println("Security code: " + (lower + Factor.NUMBER / lower));
                return;
            }
            lower += 2;
        }           
    }
}

O construtor do FactorThread recebe um limite superior e um limite inferior, semelhante ao MatrixThread. Quando um objeto FactorThread tiver esses limites, ele poderá pesquisar entre eles. O número a ser fatorado é armazenado na classe Factor. Se qualquer valor dividir esse número igualmente, ele deverá ser um dos fatores, o que facilita a localização, a soma e a impressão do outro fator, e imprimir. Temos que adicionar algumas linhas extras de código para garantir que vamos procurar apenas os números ímpares no intervalo. Essa solução foi ajustada para eficiência para esse problema específico de segurança. Um programa para encontrar fatores primos em geral teria de ser mais flexível. A seguir, vamos examinar o programa controle Factor.

Programa 13.2 Classe controle que cria threads para reduzir o tempo médio de pesquisa dos fatores de um grande composto ímpar.
public class Factor {
    public static final int THREADS = 4; (1)
    public static final long NUMBER = 59984005171248659L;
    
    public static void main(String[] args) {
        FactorThread[] threads = new FactorThread[THREADS]; (2)
        long root = (long)Math.sqrt(NUMBER); // Go to square root
        long start = 3;  // No need to test 2       
        long quotient = root / THREADS;
        long remainder = root % THREADS;
        
        for(int i = 0; i < THREADS; i++) {
            long work = quotient;
            if(i < remainder)
                work++;
            threads[i] = new FactorThread(start, start + work); (3)
            threads[i].start();
            start += work;
        }   
        try {
            for(int i = 0; i < THREADS; i++)
                threads[i].join(); (4)
        }
        catch(InterruptedException e) {
            e.printStackTrace();
        }
    }
}
1 As constantes estáticas mantêm o número a ser fatorado e o número de threads.
2 No método main(), criamos uma matriz de threads para armazenamento.
3 Em seguida, criamos e iniciamos cada objeto FactorThread, atribuindo limites superior e inferior ao mesmo tempo, usando a técnica padrão de Seção 6.11 para dividir o trabalho de forma justa. Como nós sabemos que o número que estamos dividindo não é par, começamos com 3. Ao chegar à raiz quadrada do número, sabemos que encontraremos apenas o menor dos dois fatores. Dessa forma, podemos evitar que uma thread encontre o menor enquanto outro encontra a maior.
4 Em seguida, temos as chamadas join() usuais para garantir que todas as threads foram concluídas. Nesse problema, essas chamadas são desnecessárias. Uma thread imprimirá o código de segurança correto e as outras procurarão sem sucesso. Se o programa continuar a fazer outro trabalho, talvez seja precisaríamos deixar as outras threads terminarem ou até mesmo interrompê-las. Não se esqueça das chamadas join(), pois elas costumam ser muito importantes.

13.9. Resumo

Neste capítulo, exploramos duas estratégias para obter uma solução concorrente para problemas de programação. Uma estratégia, a decomposição de tarefas, divide uma tarefa em duas ou mais subtarefas. Essas subtarefas podem então ser empacotadas como threads Java e executadas em diferentes núcleos de um processador com vários núcleos. Outra estratégia, decomposição de domínio, divide os dados de entrada em pedaços menores e permite que diferentes threads trabalhem de forma concorrente em cada pedaço de dados.

Uma solução concorrente para um problema de programação pode, às vezes, ser executada mais rapidamente do que uma solução sequencial. A velocidade mede a eficácia de uma solução concorrente é eficaz na exploração da arquitetura de um processador com vários núcleos. Observe que nem todos os programas concorrentes levam a um aumento de velocidade, pois alguns são executados mais lentamente do que os sequenciais. Escrever um programa concorrente é um desafio que nos força a dividir o trabalho e os dados de uma forma que explore melhor os processadores e o sistema operacional disponível.

O Java oferece um rico conjunto de primitivos e elementos sintáticos para escrever programas concorrentes, mas apenas alguns deles foram apresentados neste capítulo. Os capítulos seguintes fornecem ferramentas adicionais para codificar programas concorrentes mais complexos.

13.10. Exercícios

Problemas conceituais

  1. Os métodos start(), run() e join() são partes essenciais do processo de uso de threads em Java. Explique a finalidade de cada método.

  2. Qual é a diferença entre estender a classe Thread e implementar a interface Runnable? Quando você deve usar uma em vez da outra?

  3. Como o método Thread.sleep() e o método Thread.yield() afetam o agendamento de threads?

  4. Considere a expressão em Exemplo 13.2. Suponha que as operações de multiplicação e exponenciação exijam 1 e 10 unidades de tempo, respectivamente. Calcule o número de unidades de tempo necessárias para avaliar a expressão como em Figura 13.2(a) e (b).

  5. Suponha que um computador tenha um processador quad-core. As tarefas em Exemplo 13.1 e Exemplo 13.2 podem ser subdivididas para melhorar o desempenho em quatro núcleos? Por que sim ou por que não?

  6. Considere a definição de velocidade de Seção 13.5. Vamos supor que você tenha um trabalho com 1.000.000 de unidades de tamanho. Uma thread pode processar 10.000 unidades de trabalho a cada segundo. São necessárias 100 unidades adicionais de trabalho para criar uma nova thread. Qual é o aumento de velocidade se você tiver um processador dual-core e criar 2 threads? E se você tiver um processador quad-core e criar 4 threads? Ou um processador de 8 núcleos e criar 8 threads? Você pode presumir que uma thread não precisa se comunicar depois de ter sido criada.

  7. Em que situações a velocidade pode ser menor do que o número de processadores? É possível que o velocidade seja maior do que o número de processadores?

  8. A Lei de Amdahl é uma descrição matemática do valor máximo que você pode melhorar em um sistema melhorando apenas uma parte dele. Uma forma dessa lei afirma que o aumento máximo de velocidade que pode ser obtido em um programa paralelo é 1/(1 - P), em que P é a fração do programa que pode ser paralelizada em um grau arbitrário. Se 30% do trabalho em um programa puder ser totalmente paralelizado, mas o restante é totalmente serial, qual é o aumento de velocidade com dois processadores? Quatro? Oito? Quais são as implicações da Lei de Amdahl?

  9. Considere a seguinte tabela de tarefas:

    Tarefa Tempo Concorrência Dependência

    Lavar pratos

    30

    3

    Cozinhar o jantar

    45

    3

    Lavar pratos

    Limpar o quarto

    10

    2

    Limpar o banheiro

    30

    2

    Fazer a tarefa

    30

    1

    Limpar o quarto

    Nessa tabela, a coluna Tempo fornece o número de minutos que uma tarefa leva para ser executada com uma única pessoa, a coluna Concorrente o número máximo de pessoas que podem ser atribuídas a uma tarefa, e a coluna Dependência mostra quais tarefas não podem ser iniciadas até que outras tarefas tenham sido concluídas. Suponha que as pessoas atribuídas a uma determinada tarefa possam dividir perfeitamente o trabalho. Em outras palavras, o tempo que uma tarefa leva é o tempo de uma única pessoa dividido pelo número de pessoas designadas. Qual é o mínimo de tempo necessário para executar todas as tarefas com apenas uma pessoa? Qual é o tempo mínimo necessário para executar todas as tarefas com um número ilimitado de pessoas? Qual é o menor número de pessoas necessárias para atingir esse tempo mínimo?

  10. Considere o seguinte trecho de código.

    x = 13;
    x = x * 10;

    Considere também este trecho.

    x = 7;
    x = x + x;

    Se presumirmos que esses dois trechos de código estão sendo executados em threads mas que x é uma variável compartilhada, quais são os valores possíveis que x poderia ter após a execução dos dois trechos? Lembre-se de que a execução desses trechos pode ser intercalada de qualquer maneira.

Práticas de programação

  1. Implemente novamente o problema de soma de matriz de Exemplo 13.10 usando polling em vez das chamadas join(). Seu programa não deve usar uma única chamada para join(). A votação não é a maneira ideal de resolver esse problema, mas vale a pena pensar nessa técnica.

  2. Os compositores geralmente trabalham com várias faixas de música. Uma faixa pode conter vocais solo, outra bateria, uma terceira violinos, e assim por diante. Depois de gravar toda a faixa, um engenheiro de mixagem pode querer aplicar efeitos especiais, como eco, em uma ou mais faixas.

    Para entender como adicionar eco a uma trilha, suponha que a trilha consiste em uma lista de amostras de áudio. Cada amostra em uma trilha mono (não estéreo) pode ser armazenada como um double em uma matriz. Para criar um efeito de co, combinamos o valor atual de uma amostra de áudio com uma amostra de um tempo fixo anterior. Esse tempo é chamado de parâmetro delay. Variando o atraso pode produzir ecos longos e curtos.

    Se as amostras forem armazenadas na matriz in e o parâmetro de atraso for armazenado na variável delay (medido em número de amostras), o seguinte trecho de código pode ser usado para criar a matriz out que contém o som com um eco.

    double[] out = new double[in.length + delay];
    // Sound before echo starts
    for(int i = 0; i < delay; i++)
        out[i] = in[i];
    // Sound with echo
    for(int i = delay; i < in.length; i++)
        out[i] = a*in[i] + b*in[i - delay];
    // Echo after sound is over
    for(int i = in.length; i < out.length; i++)
        out[i] = b*in[i - delay];

    Os parâmetros a e b são usados para controlar a natureza do eco. Quando a é 1 e b é 0, não há eco. Quando a é 0 e b é 1, não há mixagem. Os engenheiros de áudio controlam os valores de a e b para criar o efeito de eco desejado.

    Escreva um programa com threads que calcule os valores em out em paralelo para um número arbitrário de threads.

  3. Escreva um programa que receba um número de minutos e segundos como entrada. Nesse programa, implemente um cronômetro usando chamadas Thread.sleep(). A cada segundo, imprima o tempo restante na tela. Qual é a precisão do seu cronômetro?

  4. Como você sabe, π ≈ 3.1416. Um valor mais preciso pode ser encontrado escrevendo um programa que aproxima a área de um círculo. A área de um círculo pode ser aproximada pela soma da área dos retângulos que preenchem a curva do arco do círculo. À medida que a largura do retângulo chega a zero, a aproximação fica cada vez mais próxima da área real. Se um círculo com raio r estiver centralizado na origem, sua altura y em uma determinada distância x é dada pela seguinte fórmula.

    circleHeight

    Escreva uma implementação paralela desse problema que divida partes do arco do círculo entre várias threads e, em seguida, soma os resultados os resultados depois que todos eles terminarem. Ao definir r = 2, você precisa somar apenas um quadrante de um círculo para obter π. Você precisará usar uma largura de retângulo muito pequena para obter uma resposta precisa. Quando seu programa terminar de ser executado, você poderá comparar seu valor com Math.PI para verificar a precisão.

Experimentos

  1. Use o método currentTimeMillis() para medir o tempo necessário para executar um trecho de código Java de execução relativamente longa que você escreveu. código Java de execução relativamente longa que você escreveu. Execute seu programa várias vezes e compare o tempo de execução obtido durante as diferentes execuções. Por que você acha que os tempos de execução são diferentes?

  2. A sobrecarga de criação de thread é uma consideração importante ao escrever programas paralelos eficientes. Escreva um programa que cria um grande número de threads que não fazem nada. Teste quanto tempo leva para criar e unir vários números de threads. Veja se você pode determinar quanto tempo uma única operação de criação de thread leva em seu sistema, em média.

  3. Crie implementações em série e concorrente da multiplicação de matrizes como as descritas em Exemplo 13.11.

    1. Faça experiências com diferentes tamanhos de matriz e contagens de threads para ver como o desempenho da aceleração muda. Se possível, execute seus testes em máquinas com diferentes números de núcleos ou processadores.

    2. Em uma máquina com k > 1 núcleos, qual é a de velocidade máxima que você pode esperar obter?

  4. Execute repetidamente o código em Exemplo 13.7 que cria vários objetos NumberedThread. Você consegue descobrir algum padrão na ordem em que as threads imprimem? Adicione um loop e alguma instrumentação adicional à classe NumberedThread que permitirá que você meça quanto tempo cada thread é executado antes que a próxima thread tenha uma vez.

  5. Crie implementações seriais e paralelas do problema de soma de matrizes resolvido em Exemplo 13.10. Faça experiências com diferentes tamanhos de matriz e contagens de threads para ver como o desempenho muda. Como o aumento de velocidade difere da multiplicação de matrizes? O que acontece se você simplesmente somar os números em vez de obter o seno primeiro?

  6. A solução para o problema de soma de matrizes em Exemplo 13.10 parece usar a concorrente sem entusiasmo. Depois que todas as threads calcularam suas somas, a thread principal soma as somas parciais sequencialmente.

    Uma abordagem alternativa é somar as somas parciais de forma concorrente. Depois que uma thread tenha calculado a soma dos senos de cada partição, as somas de cada par de partições vizinhas devem ser mescladas em uma única soma. O processo pode ser repetido até que a soma final tenha sido calculada. Em cada etapa, metade das threads restantes não terá mais nada a fazer e parará. O padrão de soma é como uma árvore que começa com k threads trabalhando na primeira etapa, k/2 trabalhando no segundo estágio, k/4 trabalhando no terceiro, e assim por diante, até que uma única thread conclua o processo de soma.

    treesummation
    Figura 13.8 Exemplo de soma concorrente em estilo de árvore com 8 threads.

    Atualize o método run() na classe SumThread para que ele adicione seus elementos atribuídos como antes e, em seguida, adicione a soma de seu vizinho à sua própria soma. Para isso, ele deve usar o método join() para aguardar a thread vizinha. vizinha. Ele deve executar esse processo repetidamente. Após somar seus valores, cada thread de número par deve adicionar a soma parcial de seu vizinho. Na próxima etapa, cada thread com um número divisível por 4 deve adicionar a soma parcial de seu vizinho. Na próxima etapa, cada thread E na etapa seguinte, cada thread com um número divisível por 8 deve adicionar a soma parcial de seu vizinho, e assim por diante. s thread 0 realizará a soma final. Consequentemente, a thread principal só precisa esperar pela thread 0. Para que cada thread possa esperar por outras threads, a matriz threads precisará ser um campo estático. ser um campo estático. A Figura 13.8 ilustra esse processo.

    Depois de implementar esse design, teste-o com a classe SumThread original para ver o seu desempenho. Restrinja o número de threads que você criar a uma potência de 2 para facilitar a determinação de quais threads esperam e quais threads terminam.