Fazendo Duas Coisas ao Mesmo Tempo — Threads

Fazendo Duas Coisas ao Mesmo Tempo — Threads

O que é uma thread e como criar, esperar e interromper várias delas: start e join, o erro de chamar run, um cálculo de primos dividido entre núcleos, a interrupção cooperativa, threads daemon, exceções que matam só uma thread, os estados de uma thread e a primeira condição de corrida.
Java

• • 16 min de leitura

A primeira aula da Fase 7: o que é uma thread, como criar, esperar, interromper e observar várias delas, e o primeiro problema que aparece quando duas threads mexem no mesmo dado.

"Ponte para o Nativo" terminou com uma pergunta: por que a arena confinada só pode ser usada pela thread que a criou? A resposta começa aqui. Quase todos os programas do curso até agora executaram uma instrução depois da outra, numa única linha de execução. Mas o seu computador tem vários núcleos de processamento, um servidor precisa atender muitos usuários ao mesmo tempo, e um programa com interface gráfica não pode congelar a tela enquanto baixa um arquivo. Para isso existem as threads. Nesta aula você vai criar threads, esperar por elas, interrompê-las de forma educada, dividir um cálculo entre elas e encontrar o primeiro problema da programação concorrente: o resultado errado que aparece só às vezes.

O que é uma thread

Uma thread é uma linha de execução independente dentro de um programa. Cada thread tem a sua própria pilha de chamadas, com os seus quadros e variáveis locais, como vimos em "A Máquina por Trás da Máquina". Todas as threads de um programa compartilham o mesmo heap: os mesmos objetos, os mesmos campos estáticos. Essa combinação é o que torna as threads poderosas, porque elas cooperam sobre os mesmos dados sem copiá-los, e também perigosas, pelo mesmo motivo.

Todo programa Java já começa com várias threads. O main roda numa thread chamada "main", e a própria JVM mantém outras para o JIT, o coletor de lixo e o JFR, como o Thread.print do jcmd mostrou. Quando o seu programa cria threads, o sistema operacional as distribui entre os núcleos do processador. Com mais threads do que núcleos, ele alterna entre elas várias vezes por segundo, e isso dá a impressão de que todas rodam ao mesmo tempo.

Criando threads

Uma thread executa uma tarefa, representada por um Runnable, a interface funcional de "Comportamento como Valor":

void main() throws InterruptedException {
    Runnable tarefa = () -> {
        for (int i = 1; i <= 3; i++) {
            IO.println(Thread.currentThread().getName() + ": passo " + i);
        }
    };

    Thread a = new Thread(tarefa, "trabalhadora-A");
    Thread b = new Thread(tarefa, "trabalhadora-B");
    a.start();
    b.start();
    IO.println(Thread.currentThread().getName() + ": iniciei as duas");
    a.join();
    b.join();
    IO.println(Thread.currentThread().getName() + ": as duas terminaram");
}

Três execuções do mesmo programa:

--- execução 1                 --- execução 3
main: iniciei as duas          main: iniciei as duas
trabalhadora-B: passo 1        trabalhadora-A: passo 1
trabalhadora-B: passo 2        trabalhadora-A: passo 2
trabalhadora-B: passo 3        trabalhadora-A: passo 3
trabalhadora-A: passo 1        trabalhadora-B: passo 1
trabalhadora-A: passo 2        trabalhadora-B: passo 2
trabalhadora-A: passo 3        trabalhadora-B: passo 3
main: as duas terminaram       main: as duas terminaram

A ordem mudou entre as execuções, e isso não é um defeito: é a natureza das threads. Depois do start(), cada thread segue no seu próprio ritmo, e quem decide quando cada uma executa é o sistema operacional. Nenhum programa concorrente pode depender de uma ordem entre threads que ele mesmo não tenha garantido. Com tarefas mais longas, as linhas das duas threads se misturariam.

Os métodos essenciais:

  • start() inicia a thread, que passa a executar o run() da tarefa em paralelo com quem a iniciou. Uma thread só pode ser iniciada uma vez.
  • join() faz a thread atual esperar até a outra terminar. Sem os join, o main poderia imprimir "as duas terminaram" antes delas.
  • Thread.currentThread() devolve a thread que está executando o código naquele momento.
  • Dar nome às threads, como "trabalhadora-A", não é enfeite: os nomes aparecem nos stack traces, no jcmd Thread.print e no JFR, e sem eles tudo se chama "Thread-0", "Thread-1".

Desde o Java 21, também há uma forma de construção mais explícita: Thread.ofPlatform().name("construida").start(tarefa). O ofPlatform indica uma thread de plataforma, a thread tradicional, ligada a uma thread do sistema operacional. A alternativa, ofVirtual, é o assunto de "Um Milhão de Threads".

start, e não run

Um erro frequente é chamar run() em vez de start():

Thread t = new Thread(() -> IO.println("executando em: " + Thread.currentThread().getName()),
                      "minha-thread");
t.run();
t.start();
executando em: main
executando em: minha-thread

run() é só um método comum: chamá-lo executa a tarefa na thread atual, sem criar nada. Quem cria a nova linha de execução é o start(), que por sua vez chama o run() dentro dela. O programa com run() funciona, só que sem nenhum paralelismo, e o erro passa despercebido.

Dividindo um cálculo

O primeiro motivo para usar threads é aproveitar os vários núcleos. Contar os números primos abaixo de 20 milhões, com o teste de "O Programa que Escolhe e Repete":

long contarPrimos(int inicio, int fim) {
    long total = 0;
    for (int n = inicio; n < fim; n++) {
        if (ehPrimo(n)) {
            total++;
        }
    }
    return total;
}

void main() throws InterruptedException {
    int limite = 20_000_000;

    long t0 = System.nanoTime();
    long sequencial = contarPrimos(0, limite);
    long t1 = System.nanoTime();

    long[] parciais = new long[2];
    Thread metade1 = new Thread(() -> parciais[0] = contarPrimos(0, limite / 2));
    Thread metade2 = new Thread(() -> parciais[1] = contarPrimos(limite / 2, limite));
    metade1.start();
    metade2.start();
    metade1.join();
    metade2.join();
    long t2 = System.nanoTime();

    IO.println("sequencial: " + sequencial + " primos em " + (t1 - t0) / 1_000_000 + " ms");
    IO.println("duas threads: " + (parciais[0] + parciais[1]) + " primos em "
               + (t2 - t1) / 1_000_000 + " ms (" + parciais[0] + " + " + parciais[1] + ")");
}

Numa máquina de dois núcleos:

sequencial: 1270607 primos em 10201 ms
duas threads: 1270607 primos em 6295 ms (664579 + 606028)

O mesmo resultado, em cerca de 60% do tempo. Cada thread grava o seu resultado numa posição diferente do array, e o main só lê o array depois dos dois join, então não há conflito. O array é usado porque, como vimos em "Comportamento como Valor", uma lambda não pode alterar uma variável local. Nas próximas aulas, as tarefas vão devolver resultados de formas mais adequadas.

Por que não caiu pela metade, com dois núcleos? Porque o trabalho não foi dividido por igual. Testar se um número grande é primo custa mais do que testar um pequeno, e a segunda metade, com os números maiores, demorou mais. A primeira thread terminou antes e ficou parada, e o tempo total foi o da thread mais lenta. Dividir bem o trabalho é uma das artes da programação paralela, e o exercício 2 propõe uma divisão melhor.

Interrompendo uma thread

Não existe uma forma segura de forçar uma thread a parar no meio do que está fazendo: ela poderia deixar os dados pela metade. O antigo método Thread.stop() foi desativado e depois removido justamente por isso. O Java adota um modelo cooperativo: outra thread pede a interrupção, e a thread interrompida verifica o pedido e decide como encerrar.

void main() throws InterruptedException {
    Thread vigia = new Thread(() -> {
        int voltas = 0;
        while (!Thread.currentThread().isInterrupted()) {
            voltas++;
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                IO.println("acordada no meio do sono");
                Thread.currentThread().interrupt();
            }
        }
        IO.println("vigia encerrando depois de " + voltas + " voltas");
    }, "vigia");

    vigia.start();
    Thread.sleep(350);
    vigia.interrupt();
    vigia.join();
    IO.println("estado final: " + vigia.getState());
}
acordada no meio do sono
vigia encerrando depois de 4 voltas
estado final: TERMINATED

O método interrupt() liga um sinal na thread-alvo, e isInterrupted() permite verificá-lo. Se a thread estiver bloqueada numa espera, como Thread.sleep, a espera é cancelada imediatamente com uma InterruptedException, uma exceção verificada, e o sinal é desligado nesse momento.

Por isso o catch chama Thread.currentThread().interrupt() de novo: para restaurar o sinal, e assim o laço, na próxima verificação, percebe o pedido e termina. Esta é uma das regras mais importantes da programação concorrente em Java: nunca engula uma InterruptedException. Um catch (InterruptedException e) {} vazio faz o pedido de interrupção desaparecer, e a thread continua rodando para sempre, é o "engolir exceções" de "Quando o Contrato se Quebra" na sua forma mais perigosa. As duas respostas corretas são repassar a exceção, declarando throws InterruptedException, ou restaurar o sinal, como acima.

Threads daemon e o fim do programa

A JVM termina quando todas as threads não daemon terminam. O main terminar não basta: se uma thread criada por ele continuar rodando, o programa continua vivo. Threads que não devem impedir o encerramento, como uma thread de fundo que limpa um cache a cada minuto, são marcadas como daemon antes de iniciar:

Thread fundo = new Thread(() -> {
    while (true) {
        try { Thread.sleep(1000); } catch (InterruptedException e) { return; }
    }
});
fundo.setDaemon(true);
fundo.start();
IO.println("main terminando; a thread daemon não impede a JVM de sair");

O programa termina assim que o main acaba. A thread daemon é simplesmente abandonada, sem nenhum aviso e sem executar os seus blocos finally. Por isso, threads daemon nunca devem fazer trabalho que precise ser concluído, como gravar um arquivo.

Exceções dentro de threads

Uma exceção não tratada dentro de uma thread encerra só aquela thread. O stack trace aparece no console, e o resto do programa continua:

Thread t = new Thread(() -> {
    throw new IllegalStateException("algo deu errado na thread");
}, "problemática");
t.start();
t.join();
IO.println("main continua normalmente; estado de t: " + t.getState());
Exception in thread "problemática" java.lang.IllegalStateException: algo deu errado na thread
	at Excecao.lambda$main$0(Excecao.java:3)
	at java.base/java.lang.Thread.run(Thread.java:1474)
main continua normalmente; estado de t: TERMINATED

Isso é traiçoeiro: num servidor, uma thread de processamento pode morrer com uma exceção enquanto todo o resto parece funcionar, e o trabalho dela simplesmente para de acontecer. Thread.setDefaultUncaughtExceptionHandler permite registrar um tratador para todas as exceções não capturadas em threads, por exemplo, para registrá-las num log. As ferramentas de "Não Crie Threads, Entregue Tarefas" resolvem isso de forma melhor: elas capturam a exceção da tarefa e a entregam a quem pediu o resultado.

Os estados de uma thread

Uma thread passa por estados ao longo da vida, que getState() informa e que o jcmd Thread.print mostra:

Estado Significa
NEW criada, ainda não iniciada
RUNNABLE executando, ou pronta para executar quando houver um núcleo livre
BLOCKED esperando para obter uma trava que outra thread segura
WAITING esperando indefinidamente por outra thread, como num join() sem prazo
TIMED_WAITING esperando com prazo, como num sleep
TERMINATED terminou

O estado BLOCKED envolve travas, o assunto da próxima aula. E ela é necessária por causa do problema a seguir.

O primeiro problema: a condição de corrida

Duas threads incrementam o mesmo contador, um milhão de vezes cada:

public class Corrida {
    static int contador = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable incrementar = () -> {
            for (int i = 0; i < 1_000_000; i++) {
                contador++;
            }
        };
        for (int execucao = 1; execucao <= 3; execucao++) {
            contador = 0;
            Thread a = new Thread(incrementar);
            Thread b = new Thread(incrementar);
            a.start();
            b.start();
            a.join();
            b.join();
            IO.println("execução " + execucao + ": " + contador + " (esperado 2000000)");
        }
    }
}
execução 1: 1881869 (esperado 2000000)
execução 2: 1040442 (esperado 2000000)
execução 3: 2000000 (esperado 2000000)

O resultado muda a cada execução, e às vezes, como na terceira, até sai certo. A causa está numa operação que parece indivisível e não é. contador++ são três operações no bytecode:

getstatic  contador     // lê o valor atual
iconst_1
iadd                    // soma 1
putstatic  contador     // grava o resultado

Se as duas threads lerem o valor 100 ao mesmo tempo, as duas calculam 101, e as duas gravam 101. Dois incrementos aconteceram, e o contador só avançou um. Esse incremento perdido é uma condição de corrida (race condition): o resultado depende da ordem exata em que as operações das threads se intercalam, e essa ordem muda a cada execução.

As condições de corrida são o defeito mais difícil da programação concorrente, por vários motivos. Elas podem não aparecer nos testes e aparecer em produção, com mais núcleos ou mais carga. Somem quando se tenta observá-las, porque um println de depuração muda o ritmo das threads. E o programa não quebra: ele produz resultados errados, em silêncio. A terceira execução acima, que deu o resultado certo, é o pior cenário possível: um teste que passa com código errado.

O programa dos números primos não tinha esse problema porque cada thread escrevia no seu pedaço dos dados. O contador tem, porque as duas threads escrevem no mesmo dado. A regra geral: dados compartilhados e alterados por mais de uma thread precisam de coordenação. Ou, melhor ainda, precisam ser evitados, com cada thread trabalhando nos seus dados, ou com dados imutáveis, que "O que Fica Dentro, o que Fica Fora" prometeu que seriam seguros entre threads.

Quando o compartilhamento é inevitável, a forma mais antiga de coordenação do Java é a trava: um mecanismo que garante que só uma thread por vez execute um trecho de código. Ela resolve o incremento perdido e também um segundo problema, ainda mais estranho: uma thread que altera um valor que a outra, simplesmente, nunca enxerga.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Explique a saída do exemplo com run() e start(): por que a primeira linha diz "main" e a segunda, "minha-thread"? O que aconteceria se o programa chamasse t.start() duas vezes?

Ver resposta

✓ Resposta: t.run() é uma chamada de método comum: executa a lambda na thread que chamou, que é a "main", sem criar nenhuma linha de execução nova. t.start() cria a nova linha de execução, e é dentro dela que o run() é chamado, e aí currentThread() devolve "minha-thread". Chamar start() uma segunda vez lança IllegalThreadStateException: uma thread só pode ser iniciada uma vez, mesmo depois de terminar. Para executar a mesma tarefa de novo, cria-se outra Thread com o mesmo Runnable, ou, como se faz na prática, entrega-se a tarefa a um conjunto de threads reutilizáveis, o assunto da próxima aula depois desta.

Exercício 2

Na contagem de primos, as duas threads levaram tempos diferentes, e o ganho foi menor que o dobro. Proponha uma divisão do trabalho mais equilibrada, sem mudar o número de threads.

Ver resposta

✓ Resposta: Em vez de dividir em duas metades contínuas, com os números grandes todos na segunda, intercalam-se os números: uma thread testa os pares e a outra os ímpares, ou, de forma geral, a thread k testa n = k, k + 2, k + 4...:

long contarPrimosIntercalado(int primeiro, int passo, int limite) {
    long total = 0;
    for (int n = primeiro; n < limite; n += passo) {
        if (ehPrimo(n)) {
            total++;
        }
    }
    return total;
}
// thread 0: contarPrimosIntercalado(0, 2, limite)
// thread 1: contarPrimosIntercalado(1, 2, limite)

Com passo 2, a divisão ainda é ruim de outro jeito, porque quase todos os pares são descartados imediatamente, e a thread dos ímpares faz muito mais trabalho. Uma divisão que equilibra melhor é intercalar blocos pequenos, por exemplo, de mil números: a thread 0 fica com os blocos 0, 2, 4..., e a thread 1 com os blocos 1, 3, 5.... Assim cada uma recebe números pequenos e grandes em proporção parecida. A solução mais robusta, que as próximas aulas apresentam, é dividir o trabalho em muitas tarefas pequenas e deixar que threads livres peguem a próxima tarefa da fila, de modo que nenhuma fique parada enquanto houver trabalho.

Exercício 3

No exemplo do contador, qual é o maior valor possível do resultado? E o menor? Descreva uma intercalação que produza um resultado muito menor que 2 milhões.

Ver resposta

✓ Resposta: O maior valor é 2.000.000, quando nenhum incremento se perde, como na terceira execução. O menor possível, em teoria, é 2. Uma intercalação que chega perto disso: a thread A lê o valor 0 e é suspensa antes de gravar. A thread B executa 999.999 incrementos completos, levando o contador a 999.999. A thread A volta e grava 1, apagando todo o trabalho de B. Em seguida B lê esse 1 para o seu último incremento, e é suspensa. A thread A executa os seus 999.999 incrementos restantes. Por fim, B grava 2, apagando o trabalho de A. Na prática, resultados tão extremos quase não acontecem, mas a execução 2, com cerca de metade dos incrementos perdidos, mostra que as perdas podem ser enormes. O ponto é que, sem coordenação, nenhum resultado entre esses extremos está garantido, e um programa correto não pode depender da sorte.

Exercício 4

Uma colega escreveu o laço abaixo numa thread de processamento, e o sistema às vezes não consegue ser desligado. Explique o problema e corrija.

while (!Thread.currentThread().isInterrupted()) {
    processarProximoItem();
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        IO.println("interrompida");
    }
}
Ver resposta

✓ Resposta: Quando a interrupção chega durante o sleep, a InterruptedException é lançada e o sinal de interrupção é desligado. O catch imprime a mensagem e não faz mais nada. O laço volta ao teste, encontra o sinal desligado, e continua rodando, como se o pedido nunca tivesse existido. Como a thread passa quase todo o tempo dentro do sleep, quase todo pedido de interrupção chega ali e se perde, e o desligamento, que depende de a thread terminar, fica esperando para sempre. A correção é restaurar o sinal no catch:

} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

Assim, o próximo teste do while vê o pedido e encerra o laço. Uma alternativa igualmente correta é sair do laço diretamente no catch, com break ou return. O que não pode acontecer é o pedido desaparecer.

Exercício 5

O programa abaixo nunca termina, embora o main imprima "fim". Explique por quê e apresente duas correções.

void main() {
    Thread coletor = new Thread(() -> {
        while (true) {
            limparCache();
            try { Thread.sleep(60_000); } catch (InterruptedException e) { return; }
        }
    });
    coletor.start();
    IO.println("fim");
}
Ver resposta

✓ Resposta: A JVM só termina quando todas as threads não daemon terminaram. A thread coletor é não daemon, porque esse é o padrão, e roda um laço infinito, então o programa continua vivo depois do main. A primeira correção é declarar a thread como daemon com coletor.setDaemon(true) antes do start(): a JVM termina quando o main acaba, e a thread é abandonada. É adequado aqui, porque limpar um cache não é um trabalho que precise ser concluído. A segunda é encerrar a thread explicitamente: no fim do main, chamar coletor.interrupt() e coletor.join(). A interrupção acorda o sleep, o catch executa o return, e a thread termina de forma ordenada. A segunda forma é a correta quando a tarefa precisa terminar o que está fazendo antes do programa sair, como gravar os dados pendentes. Em programas reais, o desligamento ordenado de todas as threads costuma ser coordenado pelas ferramentas da próxima aula depois desta.

Comentários

Mais em Java

Do Geral ao Específico — Herança e a Classe Object
Do Geral ao Específico — Herança e a Classe Object

Herança em Java: extends, a chamada a super e a ordem de construção…

Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy
Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy

As Trilhas da Ilha saem do localhost: imagem Docker em dois estágios com…

Não Crie Threads, Entregue Tarefas — ExecutorService e Futures
Não Crie Threads, Entregue Tarefas — ExecutorService e Futures

Como entregar tarefas a um pool de threads em vez de criar threads à mão…