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: ExecutorService, Callable e Future, exceções guardadas até o get, prazos e cancelamento, invokeAll para dividir um cálculo, desligamento em duas etapas, ThreadPoolExecutor com fila limitada e o ScheduledExecutorService.
Java

• • 26 min de leitura

Como separar o trabalho a fazer das threads que o executam, receber resultados e exceções das tarefas, dividir um cálculo grande em pedaços e desligar um pool de threads sem perder nada.

Em "Fazendo Duas Coisas ao Mesmo Tempo", cada trabalho ganhou sua própria Thread, criada com new ou com Thread.ofPlatform(), iniciada com start() e esperada com join(). Isso funciona para exemplos pequenos, mas deixa três problemas sem solução. O primeiro é o custo: cada thread de plataforma ocupa uma thread do sistema operacional e uma pilha própria, e criar mil delas para mil trabalhos curtos gasta mais tempo criando threads do que trabalhando. O segundo é o limite: nada impede um programa de criar threads sem parar até a memória acabar. O terceiro é a comunicação: o método run() de um Runnable não devolve valor nem lança exceções verificadas, e uma exceção que escapa dele só aparece impressa no console. Esta aula apresenta a resposta do Java a esses três problemas, o framework de executores do pacote java.util.concurrent, criado por Doug Lea e incorporado ao Java 5 em 2004.

A ideia: tarefa de um lado, execução do outro

A mudança de pensamento é simples. Em vez de dizer "crie uma thread para fazer isto", o programa diz "isto precisa ser feito" e entrega a tarefa a um executor, que decide quando e em qual thread ela vai rodar. O executor mais comum mantém um pool de threads, um grupo fixo de threads trabalhadoras que ficam vivas e pegam tarefas de uma fila, uma depois da outra:

import java.util.concurrent.*;

void main() throws Exception {
    // Um pool com duas threads trabalhadoras
    try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
        for (int i = 1; i <= 5; i++) {
            int numero = i;
            pool.submit(() -> IO.println("tarefa " + numero + " em " + Thread.currentThread().getName()));
        }
    } // close() espera todas as tarefas terminarem
    IO.println("todas as tarefas terminaram");
}
tarefa 1 em pool-1-thread-1
tarefa 2 em pool-1-thread-2
tarefa 3 em pool-1-thread-2
tarefa 4 em pool-1-thread-2
tarefa 5 em pool-1-thread-2
todas as tarefas terminaram

Cinco tarefas, duas threads. Assim que uma thread termina uma tarefa, ela pega a próxima da fila, e a distribuição entre as duas depende de quem fica livre primeiro, por isso muda de uma execução para outra. As threads são criadas uma vez e reaproveitadas, e o número delas nunca passa de dois, não importa quantas tarefas cheguem.

A interface ExecutorService representa esse serviço de execução, e a classe utilitária Executors fabrica as configurações mais comuns. Desde o Java 19, ExecutorService estende AutoCloseable, então pode ser usado num try-with-resources, como os arquivos de "Recursos que se Fecham Sozinhos". O close() para de aceitar tarefas novas, espera as que já foram enviadas terminarem e só então libera as threads. Em código anterior ao Java 19, que você vai encontrar muito, o mesmo efeito exige chamar shutdown() e awaitTermination() à mão, como mostra a seção sobre desligamento.

Repare na variável numero. A lambda não pode usar i diretamente, porque i muda a cada volta do laço, e lambdas só capturam variáveis efetivamente finais, a regra de "Comportamento como Valor". A cópia numero é uma variável nova a cada volta e nunca muda.

Callable e Future: tarefas que devolvem resultado

Um Runnable não devolve nada. Para tarefas que calculam um valor, existe a interface funcional Callable<V>, cujo único método, call(), devolve um V e pode lançar qualquer exceção, inclusive as verificadas. Ao receber um Callable, o submit devolve imediatamente um Future<V>, um recibo do resultado que ainda vai existir:

import java.util.concurrent.*;

// Simula uma consulta lenta que devolve um valor
static int consultarPreco(String produto) throws InterruptedException {
    Thread.sleep(500);
    return produto.length() * 10;
}

void main() throws Exception {
    try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
        long inicio = System.nanoTime();
        Future<Integer> cafe = pool.submit(() -> consultarPreco("café"));
        Future<Integer> pao = pool.submit(() -> consultarPreco("pão"));
        Future<Integer> queijo = pool.submit(() -> consultarPreco("queijo"));
        IO.println("tarefas enviadas; cafe.isDone() = " + cafe.isDone());
        int total = cafe.get() + pao.get() + queijo.get();   // get() espera cada resultado
        long ms = (System.nanoTime() - inicio) / 1_000_000;
        IO.println("total = " + total + " em " + ms + " ms");
    }
}
tarefas enviadas; cafe.isDone() = false
total = 130 em 503 ms

As três consultas de meio segundo rodam ao mesmo tempo, e o total fica pronto em pouco mais de meio segundo, e não em um segundo e meio. O compilador sabe que a lambda é um Callable, e não um Runnable, porque ela devolve um valor. É isso que permite chamar consultarPreco, que lança a exceção verificada InterruptedException, sem nenhum try dentro da lambda.

O método get() é o ponto de encontro: se o resultado já existe, ele o devolve na hora; se não, a thread que chamou fica bloqueada até ele ficar pronto. Enviar tudo primeiro e só depois chamar os get() é o que permite o paralelismo. Se o código fizesse pool.submit(...).get() para cada produto, cada consulta esperaria a anterior terminar, e o pool não serviria para nada.

Exceções que chegam a quem pediu

Esta é a promessa que ficou em aberto em "Fazendo Duas Coisas ao Mesmo Tempo": com threads soltas, uma exceção morre dentro da thread que a lançou. Com um Future, ela é guardada e entregue a quem pedir o resultado:

import java.util.concurrent.*;

void main() throws InterruptedException {
    try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
        Future<Integer> f = pool.submit(() -> Integer.parseInt("doze"));
        try {
            IO.println(f.get());
        } catch (ExecutionException e) {
            IO.println("a tarefa falhou: " + e.getCause());
        }
        // execute() com Runnable: a exceção vai para o tratador da thread
        pool.execute(() -> { throw new IllegalStateException("perdida?"); });
        Thread.sleep(100);
        // submit: a exceção fica guardada no Future e ninguém vê
        pool.submit(() -> { throw new IllegalStateException("silenciosa"); });
        Thread.sleep(100);
        IO.println("fim");
    }
}
a tarefa falhou: java.lang.NumberFormatException: For input string: "doze"
Exception in thread "pool-1-thread-2" java.lang.IllegalStateException: perdida?
	at Excecao.lambda$main$1(Excecao.java:12)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
	at java.base/java.lang.Thread.run(Thread.java:1474)
fim

A saída mostra três destinos diferentes para uma exceção. No primeiro caso, o get() lança uma ExecutionException, uma exceção verificada que embrulha a original, e getCause() devolve a NumberFormatException verdadeira. É o mesmo encadeamento de causas de "Quando o Contrato se Quebra": a exceção externa diz "a tarefa falhou", a interna diz por quê.

No segundo caso, execute, o método mais simples da interface Executor, recebe um Runnable e não devolve recibo. A exceção escapa da tarefa, mata a thread trabalhadora (o pool cria outra para substituí-la) e é impressa pelo tratador padrão, como numa thread solta.

O terceiro caso é a armadilha: a tarefa enviada com submit falhou, a exceção foi guardada num Future que ninguém guardou numa variável, e nada aparece. Um erro em produção pode desaparecer assim, sem deixar rastro. A regra prática: se você usa submit, alguém precisa chamar get() no Future; se ninguém vai olhar o resultado, use execute, ou trate a exceção dentro da própria tarefa.

Prazos, cancelamento e o estado de um Future

Esperar para sempre raramente é aceitável. O get tem uma versão com prazo, que lança TimeoutException se o resultado não chegar a tempo, e o Future pode ser cancelado:

import java.util.*;
import java.util.concurrent.*;

static String buscar(String servidor, int ms) throws InterruptedException {
    Thread.sleep(ms);
    return "resposta de " + servidor;
}

void main() throws Exception {
    try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
        // 1. get com prazo e cancelamento
        Future<String> lento = pool.submit(() -> buscar("lento", 5_000));
        try {
            IO.println(lento.get(300, TimeUnit.MILLISECONDS));
        } catch (TimeoutException e) {
            boolean cancelou = lento.cancel(true);   // true: interrompe a thread
            IO.println("demorou demais; cancelado = " + cancelou + ", estado = " + lento.state());
        }

        // 2. estados de um Future
        Future<String> ok = pool.submit(() -> buscar("rápido", 50));
        Future<String> falha = pool.submit(() -> { throw new IllegalStateException("fora do ar"); });
        Thread.sleep(200);
        IO.println(ok.state() + ": " + ok.resultNow());
        IO.println(falha.state() + ": " + falha.exceptionNow());

        // 3. invokeAny: o primeiro que terminar com sucesso vence
        long t0 = System.nanoTime();
        String vencedor = pool.invokeAny(List.of(
                () -> buscar("espelho-a", 400),
                () -> buscar("espelho-b", 100),
                () -> buscar("espelho-c", 250)));
        IO.println(vencedor + " em " + (System.nanoTime() - t0) / 1_000_000 + " ms");
    }
}
demorou demais; cancelado = true, estado = CANCELLED
SUCCESS: resposta de rápido
FAILED: java.lang.IllegalStateException: fora do ar
resposta de espelho-b em 102 ms

O cancel(true) marca o Future como cancelado e interrompe a thread que executa a tarefa. Aqui entra tudo o que "Fazendo Duas Coisas ao Mesmo Tempo" ensinou sobre interrupção: o cancelamento é cooperativo. O Thread.sleep dentro de buscar reage à interrupção lançando InterruptedException, e a tarefa termina. Uma tarefa num laço de cálculo que nunca verifica Thread.currentThread().isInterrupted() continuaria rodando até o fim, ocupando uma thread do pool, mesmo "cancelada". Com cancel(false), uma tarefa que ainda está na fila nunca começa, mas uma que já está rodando segue até terminar.

Os métodos state(), resultNow() e exceptionNow() chegaram no Java 19. O state() devolve um enum com quatro valores, RUNNING, SUCCESS, FAILED e CANCELLED, e os outros dois leem o resultado ou a exceção sem esperar, lançando IllegalStateException se a tarefa não estiver no estado certo. São úteis quando você já sabe que a tarefa terminou e quer evitar o try/catch de ExecutionException.

O invokeAny recebe uma coleção de tarefas, devolve o resultado da primeira que terminar com sucesso e cancela as outras. É o padrão para consultar vários servidores equivalentes e ficar com a resposta mais rápida. O seu irmão, invokeAll, espera todas e devolve a lista de Futures na mesma ordem das tarefas, e é a ferramenta da próxima seção.

Dividindo um trabalho grande

O uso mais direto de um pool é dividir um cálculo pesado em partes independentes. Contar quantos números primos existem abaixo de dez milhões é um bom exemplo: cada intervalo pode ser contado sem saber nada dos outros, e os resultados parciais só precisam ser somados no fim.

import java.util.*;
import java.util.concurrent.*;

static boolean ehPrimo(int n) {
    if (n < 2) return false;
    for (int d = 2; (long) d * d <= n; d++)
        if (n % d == 0) return false;
    return true;
}

// Conta os primos no intervalo [de, ate)
static long contarPrimos(int de, int ate) {
    long total = 0;
    for (int n = de; n < ate; n++)
        if (ehPrimo(n)) total++;
    return total;
}

static long emParalelo(int limite, int partes, int threads) throws Exception {
    try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
        List<Callable<Long>> tarefas = new ArrayList<>();
        int tamanho = limite / partes;
        for (int p = 0; p < partes; p++) {
            int de = p * tamanho;
            int ate = (p == partes - 1) ? limite : de + tamanho;  // a última parte leva o resto
            tarefas.add(() -> contarPrimos(de, ate));
        }
        long total = 0;
        for (Future<Long> f : pool.invokeAll(tarefas))  // espera todas
            total += f.get();
        return total;
    }
}

void main() throws Exception {
    int limite = 10_000_000;
    IO.println("processadores: " + Runtime.getRuntime().availableProcessors());
    for (int rodada = 0; rodada < 2; rodada++) {
        long t0 = System.nanoTime();
        long seq = contarPrimos(0, limite);
        long t1 = System.nanoTime();
        long par2 = emParalelo(limite, 2, 2);
        long t2 = System.nanoTime();
        long par16 = emParalelo(limite, 16, 2);
        long t3 = System.nanoTime();
        IO.println("sequencial:           " + seq + " em " + (t1 - t0) / 1_000_000 + " ms");
        IO.println("2 partes, 2 threads:  " + par2 + " em " + (t2 - t1) / 1_000_000 + " ms");
        IO.println("16 partes, 2 threads: " + par16 + " em " + (t3 - t2) / 1_000_000 + " ms");
    }
}

Numa máquina com dois núcleos:

processadores: 2
sequencial:           664579 em 3343 ms
2 partes, 2 threads:  664579 em 2056 ms
16 partes, 2 threads: 664579 em 1759 ms
sequencial:           664579 em 3254 ms
2 partes, 2 threads:  664579 em 2092 ms
16 partes, 2 threads: 664579 em 1784 ms

O resultado é sempre 664579, e as tarefas não compartilham nenhum dado mutável: cada uma tem seu próprio total, e a soma acontece na thread principal, depois do invokeAll. Não há trava nenhuma, e nenhuma é necessária. Esse é o desenho mais seguro para concorrência: dividir sem compartilhar, e juntar no fim.

O detalhe interessante é a diferença entre duas e dezesseis partes. Com duas threads e duas partes, era de esperar a metade do tempo sequencial, algo perto de 1650 ms, mas o resultado foi 2056. O motivo é que as partes não custam o mesmo: testar se um número grande é primo exige mais divisões do que testar um pequeno, então a metade de cima do intervalo dá mais trabalho. A thread que pega a metade de baixo termina cedo e fica parada, esperando a outra. Com dezesseis partes menores, uma thread que termina a sua pega a próxima da fila, e as duas ficam ocupadas até quase o fim. Dividir em mais partes do que threads equilibra a carga automaticamente, ao preço de um pouco mais de coordenação. Essa é a mesma ideia do work stealing do ForkJoinPool, o pool que os streams paralelos de "Agrupar, Achatar, Reduzir" usam por baixo dos panos.

Os tempos seguem a regra de "Medir Antes de Otimizar": a primeira rodada inclui o aquecimento do JIT, e só a repetição mostra se a diferença é estável. Aqui ela é.

Desligando com ordem

Um pool de threads não para sozinho: as threads trabalhadoras ficam esperando novas tarefas, e como não são daemon, mantêm a JVM viva. O try-with-resources resolve isso na maioria dos casos, mas o close() espera sem prazo. Quando é preciso um limite, o desligamento é feito em duas etapas:

import java.util.*;
import java.util.concurrent.*;

void main() throws InterruptedException {
    ExecutorService pool = Executors.newFixedThreadPool(2);
    for (int i = 1; i <= 6; i++) {
        int n = i;
        pool.submit(() -> {
            try {
                Thread.sleep(300);
                IO.println("tarefa " + n + " concluída");
            } catch (InterruptedException e) {
                IO.println("tarefa " + n + " interrompida");
            }
        });
    }
    pool.shutdown();                       // não aceita mais tarefas; as enviadas continuam
    try {
        pool.submit(() -> IO.println("tarde demais"));
    } catch (RejectedExecutionException e) {
        IO.println("recusada: o pool está desligando");
    }
    if (!pool.awaitTermination(400, TimeUnit.MILLISECONDS)) {
        List<Runnable> nuncaComecaram = pool.shutdownNow();   // interrompe as que rodam
        IO.println("nunca começaram: " + nuncaComecaram.size());
        pool.awaitTermination(1, TimeUnit.SECONDS);
    }
    IO.println("terminado = " + pool.isTerminated());
}
recusada: o pool está desligando
tarefa 1 concluída
tarefa 2 concluída
nunca começaram: 2
tarefa 3 interrompida
tarefa 4 interrompida
terminado = true

Os três métodos têm papéis distintos. O shutdown() fecha a porta: tarefas novas são recusadas com RejectedExecutionException, mas as que já estão na fila ou rodando continuam. O awaitTermination espera até todas terminarem ou o prazo acabar, e devolve false no segundo caso. O shutdownNow() é o desligamento forçado: interrompe as tarefas em execução e devolve a lista das que estavam na fila e nunca começaram, para que o programa decida o que fazer com elas (registrar, salvar, reenviar depois).

No exemplo, as tarefas 1 e 2 terminaram dentro do prazo, 3 e 4 estavam rodando e foram interrompidas, e 5 e 6 nunca saíram da fila. Repare que as tarefas interrompidas imprimiram uma mensagem e terminaram, como devem. Uma tarefa que ignorasse a interrupção faria o segundo awaitTermination esgotar o prazo também. É por isso que o close() do Java 19 é implementado exatamente como esse padrão, só que sem prazo: shutdown() seguido de awaitTermination em laço.

Por dentro do pool: ThreadPoolExecutor

Os métodos de fábrica de Executors são atalhos para uma única classe muito configurável, ThreadPoolExecutor. Conhecer os parâmetros dela é o que separa usar um pool de entender o que ele faz sob carga:

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

void main() throws InterruptedException {
    AtomicInteger contador = new AtomicInteger();
    ThreadFactory fabrica = r -> new Thread(r, "trabalhador-" + contador.incrementAndGet());
    ThreadPoolExecutor pool = new ThreadPoolExecutor(
            2, 2,                                  // núcleo e máximo de threads
            0, TimeUnit.SECONDS,                   // tempo ocioso antes de encerrar threads extras
            new ArrayBlockingQueue<>(3),           // fila com no máximo 3 tarefas esperando
            fabrica,
            new ThreadPoolExecutor.AbortPolicy()); // fila cheia: lança exceção

    for (int i = 1; i <= 7; i++) {
        int n = i;
        try {
            pool.execute(() -> {
                try { Thread.sleep(200); } catch (InterruptedException e) { return; }
                IO.println("pedido " + n + " atendido por " + Thread.currentThread().getName());
            });
            IO.println("pedido " + n + " aceito; na fila: " + pool.getQueue().size());
        } catch (RejectedExecutionException e) {
            IO.println("pedido " + n + " RECUSADO");
        }
    }
    pool.shutdown();
    pool.awaitTermination(5, TimeUnit.SECONDS);
    IO.println("concluídas: " + pool.getCompletedTaskCount());
}
pedido 1 aceito; na fila: 0
pedido 2 aceito; na fila: 0
pedido 3 aceito; na fila: 1
pedido 4 aceito; na fila: 2
pedido 5 aceito; na fila: 3
pedido 6 RECUSADO
pedido 7 RECUSADO
pedido 1 atendido por trabalhador-1
pedido 2 atendido por trabalhador-2
pedido 3 atendido por trabalhador-1
pedido 4 atendido por trabalhador-2
pedido 5 atendido por trabalhador-1
concluídas: 5

Os pedidos 1 e 2 vão direto para as duas threads, e a fila não cresce. Os pedidos 3, 4 e 5 ficam na fila. Os pedidos 6 e 7 encontram as threads ocupadas e a fila cheia, e a política de rejeição entra em ação. A ThreadFactory permite dar nomes às threads, o que faz muita diferença ao ler um despejo de threads do jcmd, como em "O Cadeado e a Memória".

Os parâmetros, na ordem: o núcleo (corePoolSize) é o número de threads mantidas vivas mesmo sem trabalho; o máximo (maximumPoolSize) é até onde o pool cresce, mas só quando a fila está cheia; o tempo ocioso diz quanto uma thread além do núcleo espera antes de ser encerrada; a fila guarda as tarefas que esperam uma thread; e a política de rejeição decide o que acontece quando não há thread nem espaço na fila. São quatro políticas prontas: AbortPolicy (a padrão) lança a exceção, CallerRunsPolicy executa a tarefa na própria thread que tentou enviá-la, DiscardPolicy descarta em silêncio e DiscardOldestPolicy descarta a tarefa mais antiga da fila para abrir espaço.

A CallerRunsPolicy merece um exemplo, porque tem um efeito elegante:

import java.util.concurrent.*;

void main() throws InterruptedException {
    ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(1), new ThreadPoolExecutor.CallerRunsPolicy());
    for (int i = 1; i <= 4; i++) {
        int n = i;
        pool.execute(() -> {
            try { Thread.sleep(100); } catch (InterruptedException e) { return; }
            IO.println("pedido " + n + " em " + Thread.currentThread().getName());
        });
    }
    pool.shutdown();
    pool.awaitTermination(5, TimeUnit.SECONDS);
}
pedido 1 em pool-1-thread-1
pedido 3 em main
pedido 2 em pool-1-thread-1
pedido 4 em pool-1-thread-1

O pedido 3 não coube e foi executado pela própria main. Enquanto a main está ocupada com ele, ela não envia tarefas novas, e o pool tem tempo de esvaziar a fila. Esse freio natural em quem produz trabalho rápido demais se chama contrapressão (backpressure), e evita tanto a perda de tarefas quanto o crescimento sem limite.

Com esse vocabulário, dá para ler o que os atalhos de Executors realmente criam. O newFixedThreadPool(n) usa núcleo e máximo iguais a n e uma LinkedBlockingQueue sem limite: nenhuma tarefa é recusada, mas, se elas chegarem mais depressa do que são processadas, a fila cresce até esgotar o heap. O newCachedThreadPool() faz o contrário: fila de capacidade zero e máximo de threads praticamente infinito, criando uma thread nova sempre que nenhuma estiver livre, o que sob carga pode criar milhares de threads. Os dois são ótimos para programas pequenos e controlados. Num servidor que recebe trabalho de fora, prefira um ThreadPoolExecutor com fila limitada e uma política de rejeição escolhida de propósito.

Quantas threads?

Não existe um número mágico, mas existe um raciocínio. Para tarefas limitadas pela CPU, como a contagem de primos, mais threads que núcleos não ajudam: com dois núcleos, uma terceira thread só disputa tempo de processador com as outras duas. O ponto de partida é Runtime.getRuntime().availableProcessors(), às vezes mais um.

Para tarefas limitadas por entrada e saída, como consultas a bancos de dados ou chamadas de rede, a thread passa a maior parte do tempo esperando, e o processador fica livre para outras. Aí vale a fórmula de Java Concurrency in Practice: número de threads ≈ núcleos × (1 + tempo de espera / tempo de cálculo). Uma tarefa que espera 90 ms e calcula 10 ms, numa máquina de 4 núcleos, justifica cerca de 4 × (1 + 9) = 40 threads. Essa conta é uma estimativa inicial, a ser confirmada medindo, e é exatamente o problema que as threads virtuais de "Um Milhão de Threads" eliminam: quando uma thread que espera custa quase nada, deixa de ser preciso racioná-las.

Tarefas no relógio: ScheduledExecutorService

O último tipo de executor roda tarefas depois de um atraso ou periodicamente, substituindo com vantagem a antiga classe java.util.Timer:

import java.util.concurrent.*;

void main() throws InterruptedException {
    try (ScheduledExecutorService agenda = Executors.newScheduledThreadPool(1)) {
        long inicio = System.nanoTime();
        agenda.schedule(() -> IO.println("lembrete único"), 250, TimeUnit.MILLISECONDS);
        ScheduledFuture<?> batida = agenda.scheduleAtFixedRate(
                () -> IO.println("batida em " + (System.nanoTime() - inicio) / 1_000_000 + " ms"),
                0, 200, TimeUnit.MILLISECONDS);
        Thread.sleep(700);
        batida.cancel(false);   // tarefas periódicas só param quando canceladas
    }
    IO.println("agenda encerrada");
}
batida em 3 ms
batida em 203 ms
lembrete único
batida em 402 ms
batida em 602 ms
agenda encerrada

O scheduleAtFixedRate mantém o ritmo a partir do início de cada execução: uma a cada 200 ms, não importa quanto cada uma demore (se uma execução passar do período, a seguinte começa assim que ela terminar, nunca duas ao mesmo tempo). O scheduleWithFixedDelay conta o intervalo a partir do fim de cada execução, o que é melhor para tarefas de duração variável, como verificar se chegou um arquivo novo. Um detalhe importante: se uma execução periódica lançar uma exceção, as execuções seguintes são canceladas em silêncio. Tarefas periódicas devem tratar as próprias exceções, ou a verificação de lembretes do projeto de tarefas pararia na primeira falha sem que ninguém percebesse.

O framework de executores resolve o custo, o limite e a comunicação das threads, mas o recibo que ele devolve, o Future, tem um defeito: a única forma de usar o resultado é esperar por ele com get(), bloqueando uma thread. Quando o resultado de uma tarefa alimenta a próxima, e essa alimenta outra, alguma thread acaba parada em cada etapa. E os dados compartilhados entre tarefas continuam pedindo coordenação, que o pacote java.util.concurrent oferece em formas mais eficientes do que o synchronized.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Explique por que a tarefa enviada com pool.submit(() -> { throw new IllegalStateException("silenciosa"); }) não produziu nenhuma saída, enquanto a enviada com execute imprimiu a pilha de chamadas. Dê duas formas de garantir que uma falha como essa não passe despercebida.

Ver resposta

✓ Resposta: O submit embrulha a tarefa num objeto que implementa Future e executa a tarefa dentro de um try que captura qualquer exceção e a guarda no próprio Future, para entregá-la depois, embrulhada numa ExecutionException, a quem chamar get(). Como o Future devolvido foi descartado, ninguém nunca chama get(), e a exceção fica guardada num objeto que o coletor de lixo vai recolher. O execute, por outro lado, roda o Runnable diretamente na thread trabalhadora; a exceção escapa do run(), encerra a thread e chega ao tratador de exceções não capturadas, que imprime a pilha. A primeira forma de não perder a falha é guardar o Future e chamar get() em algum momento, tratando a ExecutionException, como no exemplo de Integer.parseInt("doze"). A segunda é tratar a exceção dentro da própria tarefa, com um try/catch que registra o erro num log, o que é o certo para tarefas do tipo "dispare e esqueça" enviadas com execute, ou para tarefas periódicas de um ScheduledExecutorService, que param em silêncio na primeira exceção.

Exercício 2

Um serviço roda numa máquina com 8 núcleos. (a) Quantas threads você usaria para redimensionar imagens, uma tarefa que só calcula? (b) E para uma tarefa que consulta uma API externa, esperando em média 180 ms pela resposta e gastando 20 ms processando-a? Justifique com a fórmula da aula.

Ver resposta

✓ Resposta: (a) Redimensionar imagens é limitado pela CPU: cada thread usa um núcleo o tempo todo. Mais de 8 threads só faria as threads disputarem os mesmos núcleos, trocando de contexto sem ganho, então o ponto de partida é 8, obtido com availableProcessors() e não escrito fixo no código, para funcionar em qualquer máquina. (b) A fórmula é núcleos × (1 + espera / cálculo) = 8 × (1 + 180/20) = 8 × 10 = 80 threads. A ideia é que cada thread só usa a CPU um décimo do tempo, então são necessárias cerca de dez threads por núcleo para mantê-lo ocupado. Na prática, é preciso verificar também o outro lado: se a API externa aceita 80 conexões simultâneas, ou se o limite real é dela. Os dois números são pontos de partida a confirmar medindo, e o segundo caso é exatamente onde as threads virtuais simplificam tudo.

Exercício 3

O programa abaixo nunca termina. Explique por quê e proponha duas correções.

ExecutorService pool = Executors.newFixedThreadPool(1);
Future<String> externa = pool.submit(() -> {
    Future<String> interna = pool.submit(() -> "interna");
    return "externa + " + interna.get();
});
IO.println(externa.get());
Ver resposta

✓ Resposta: O pool tem uma única thread, e ela está ocupada executando a tarefa externa. A tarefa externa envia a interna, que vai para a fila e só pode rodar quando a única thread ficar livre. Mas a externa chama interna.get() e bloqueia esperando a interna, que espera a externa liberar a thread: um impasse, chamado de impasse por inanição de threads (thread starvation deadlock). Ele não envolve nenhuma trava, só um pool pequeno demais para tarefas que esperam umas pelas outras. Num teste com um get com prazo, a espera termina em TimeoutException: a única thread espera por uma tarefa que está na fila atrás dela. A primeira correção é não esperar dentro da tarefa: a externa pode simplesmente calcular o valor da interna na própria thread, já que vai esperar por ele de qualquer jeito. A segunda é usar pools separados para tarefas de níveis diferentes, ou um pool grande o bastante para que as tarefas dependentes sempre tenham thread livre, o que é frágil. A solução mais elegante é a composição sem bloqueio de "Encadeando o que Ainda Não Aconteceu", em que nenhuma tarefa fica parada esperando outra.

Exercício 4

Escreva um método long somarTamanhos(List<Path> arquivos) que use um pool de 4 threads para obter o tamanho de cada arquivo com Files.size e devolva a soma. Um arquivo que não existe deve ser ignorado, com uma mensagem, sem impedir a soma dos outros.

Ver resposta

✓ Resposta:

import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;

static long somarTamanhos(List<Path> arquivos) throws InterruptedException {
    try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
        List<Callable<Long>> tarefas = new ArrayList<>();
        for (Path arquivo : arquivos) tarefas.add(() -> Files.size(arquivo));
        long total = 0;
        for (Future<Long> f : pool.invokeAll(tarefas)) {
            try {
                total += f.get();
            } catch (ExecutionException e) {
                IO.println("ignorando: " + e.getCause());
            }
        }
        return total;
    }
}

Com três arquivos de 1000, 2000 e 3000 bytes e um caminho inexistente, a saída é ignorando: java.nio.file.NoSuchFileException: dados/nao-existe.bin e total: 6000 bytes. Três pontos importam. O Files.size lança IOException, uma exceção verificada, e isso só é possível porque a lambda é um Callable. O invokeAll espera todas as tarefas, então cada get() devolve na hora, e a ordem dos Futures é a mesma dos arquivos. E o try/catch fica dentro do laço: a falha de um arquivo é tratada individualmente, e a soma continua com os outros. Se o catch envolvesse o laço inteiro, o primeiro arquivo ausente interromperia a soma.

Exercício 5

Explique como um servidor que usa Executors.newFixedThreadPool(50) pode terminar com OutOfMemoryError num pico de acessos, e como um newCachedThreadPool() pode falhar no mesmo pico de outra forma. Escreva a criação de um pool que resista aos dois problemas.

Ver resposta

✓ Resposta: O newFixedThreadPool usa uma LinkedBlockingQueue sem limite de capacidade. Num pico, os pedidos chegam mais depressa do que 50 threads conseguem atendê-los, e todos os excedentes vão para a fila. Cada pedido guardado ocupa memória, com seus dados e a lambda que os captura, e a fila cresce até esgotar o heap. Antes disso, os pedidos no fim da fila esperam tanto que os clientes já desistiram. O newCachedThreadPool não tem fila: se nenhuma thread está livre, ele cria uma nova, sem limite prático. No pico, cria milhares de threads de plataforma, cada uma com sua pilha, e o sistema operacional recusa novas threads (OutOfMemoryError: unable to create native thread) ou a máquina passa mais tempo trocando de thread do que trabalhando. A resistência vem de limitar as duas coisas e escolher a rejeição:

ExecutorService pool = new ThreadPoolExecutor(
        50, 50, 0, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(500),                 // no máximo 500 pedidos esperando
        new ThreadPoolExecutor.CallerRunsPolicy());    // excesso: freia quem envia

Com a fila limitada, a memória usada tem teto. Com CallerRunsPolicy, o excesso desacelera a thread que recebe os pedidos, uma forma de contrapressão. Outra escolha válida é AbortPolicy, respondendo ao cliente com um erro de "serviço ocupado", o que é mais honesto que fazê-lo esperar indefinidamente.

Comentários

Mais em Java

Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway
Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway

Um esquema de banco muda ao longo da vida de um sistema, e os repositórios se…

Do Código ao Bytecode — JDK, javac, JVM e o Primeiro Programa
Do Código ao Bytecode — JDK, javac, JVM e o Primeiro Programa

Instalação do JDK 25, a diferença entre JDK, JRE e JVM, o primeiro programa…

Extensão — Bancos de Verdade nos Testes: Testcontainers
Extensão — Bancos de Verdade nos Testes: Testcontainers

Nesta aula os testes passam a rodar contra o PostgreSQL e o MongoDB de…