Este post nasce de uma preocupação que tenho levado cada vez mais a sério: entender como cada parte do código realmente impacta o uso de recursos.
Também tenho usado IA como ferramenta de estudo, principalmente para conseguir acompanhar tanta coisa nova sendo lançada sem perder o foco no que realmente quero aprender.
Este e outros textos que venho escrevendo também servem como um compromisso comigo mesmo: continuar estudando, escrevendo e tentando entender as coisas de verdade, em vez de apenas consumir informação de forma passiva. No fim, é uma tentativa de não perder esse contato racional com o aprendizado em um mundo onde tudo está fácil demais de acessar e onde é muito fácil começar o dia já sobrecarregando o córtex pré-frontal com dezenas de assuntos diferentes.
Então vou aqui apenas fomentar o assunto, e você se dispõe a continuar procurando caso queira, variações de implementação, interpretação ou até mesmo contrapor o que eu estou propondo aqui.
Então vamos lá, o primeiro ponto é entender como o projeto Loom no Java trouxe maturidade pro ciclo de vida de recursos de unidades de processamento, traduzindo, colocou threads controladas pela JVM muito mais leves onde você não precisa passar o controle para o kernel em IO's e deixar a thread dormindo esperando que a volta no socket de rede acorde sua thread para continuar rodando.
Como esse é um estudo deliberado que acontece após o expediente de trabalho e praticamente no último mês dedico meu tempo a entender todas as motivações e decisões de design da linguagem Java, tenho flertado muito com a programação funcional dado os estudos de números primos e logaritmo discreto, e algumas coisas que me preocuparam foi a dificuldade de criar vínculos de tarefas paralelas no Java, muitos só criam um pool e executam em paralelo, mas e se eu precisar vincular uma tarefa a outra de forma paralela, e se eu precisar cancelar e parar a outra também e não onerar recursos ou segurar recursos, como objetos parados na heap sem coleta, pool de conexão de banco segurando mais que o necessário, chamadas para outros serviços que poderiam ser abortadas (aqui vale estudar RPC, remote procedure call, para cancelamento de tarefas em sistemas distribuídos), exemplo de um código rudimentar:
var a = pool.submit(() -> antifraude(transacao));
var b = pool.submit(() -> limite(conta));
return new Autorizacao(a.get(), b.get());
A falhou? B continua rodando.
Agora imagine que você quer executar algumas tarefas em paralelo e que elas formem uma unidade só, ou todas servem ou nenhuma serve, que é exatamente o caso de uma autorização de cartão, onde antes de responder aprovado ou negado você precisa do score de antifraude, da checagem de limite na conta e da consulta de token na bandeira, cada uma vindo de um lugar diferente e todas dentro de uma janela de resposta apertada, porque a bandeira não vai esperar você para sempre. Para não ficar no achismo eu montei um cenário e medi, com o antifraude levando 300ms e retornando certo, a bandeira levando 250ms e retornando certo, e o autorizador de limite falhando aos 50ms. Aos 50ms a resposta já está condenada, essa transação vai ser negada de qualquer jeito, e a pergunta que interessa é o que acontece nos 250ms seguintes:
[ 50 ms] LIMITE falha (a exceção fica guardada calada dentro do Future)
[300 ms] ANTIFRAUDE termina, trabalho jogado fora
[300 ms] BANDEIRA termina, trabalho jogado fora
[300 ms] só agora o get() lança
Deu 600 conexão·ms consumidos para produzir uma negativa que já era certa aos 50ms. E o motivo não é o get() ser lento, é que ele bloqueia na ordem em que você escreveu e não na ordem em que as coisas acontecem, mas indo mais fundo o problema real é que o pool é um saco de tarefas anônimas, quando você faz submit aquilo vira um Runnable numa fila e o pool não sabe que as três tarefas são irmãs, não sabe que uma depende da outra fazer sentido, não existe a informação de parentesco em lugar nenhum, então não existe ninguém para avisar ninguém.
E sob carga isso não degrada de forma linear, porque quando as conexões acabam as autorizações começam a enfileirar, a latência sobe, o adquirente faz retry, o que gera mais carga ainda, e um serviço degradado acaba derrubando o autorizador inteiro não por causa dele mas porque você continuou pagando pedágio por trabalho que já estava perdido. Em pagamento isso tem um agravante que eu acho pouco discutido, porque cada chamada que você deixa correr depois da decisão já tomada é uma chamada que pode ter efeito do outro lado, e aí você entra no território de idempotência e reversão.
O que a StructuredTaskScope adiciona
É exatamente esse parentesco que a StructuredTaskScope traz, e sinceramente é só isso que ela traz, o resto é consequência:
try (var scope = StructuredTaskScope.open(
Joiner.<Object>allSuccessfulOrThrow(),
cfg -> cfg.withTimeout(Duration.ofSeconds(2)))) {
var fraude = scope.fork(() -> antifraude(transacao));
var limite = scope.fork(() -> limite(conta));
var portador = scope.fork(() -> bandeira(transacao));
scope.join();
return new Autorizacao(fraude.get(), limite.get(), portador.get());
}
O escopo guarda a lista das subtarefas, e quando uma delas termina ele consulta a política, se falhou ele interrompe as irmãs na própria thread que acabou de falhar, no instante da falha, sem polling e sem espera. E o fechamento de chave faz o close(), que manda o cancelamento e ainda espera todas encerrarem de fato antes de deixar você sair do bloco. Aquele withTimeout de 2 segundos também é do conjunto inteiro e não de cada chamada, que é o que você quer quando existe uma janela de autorização a respeitar.
A analogia que me fez entender de vez foi pensar que concorrência com pool é goto, você lança a tarefa e ela some, a estrutura do código deixa de corresponder à estrutura da execução. O try-with-resources já tinha resolvido isso para recursos, amarrando o tempo de vida ao escopo léxico, e a structured concurrency faz a mesma coisa com threads. O teste que eu passei a aplicar é perguntar quando aquela thread morre, e com pool a resposta honesta é que eu não sei, tenho que ler o código todo e rezar, enquanto com escopo eu aponto com o dedo para o fechamento de chave.
O que ela não faz, que é onde eu mais aprendi
Aqui está a parte que mudou como eu penso o assunto, porque cancelar uma tarefa não significa parar a thread. ninguem mata thread, em linguagem nenhuma.
O Thread.stop() existiu, foi depreciado em 1998 e desativado de vez no Java 20, e o motivo é bem fundamental, porque uma exceção assíncrona pode nascer entre duas instruções quaisquer:
contaOrigem.saldo -= 100;
// morreu aqui
contaDestino.saldo += 100;
Cem reais deixaram de existir e nenhum código consegue se proteger disso, porque toda linha que você escreve assume que a próxima vai rodar, e essa é a suposição mais básica que existe na programação. Não é limitação do Java, o Go tem context.Context, o C# tem CancellationToken, e todos são cooperativos.
O que existe de verdade é o interrupt(), que liga um booleano e dá um empurrão em quem está dormindo, ou seja é um bilhete deixado na mesa. Quem está esperando IO acorda, lê o bilhete e se mata sozinho lançando a InterruptedException, e quem está num laço de CPU sem checar nada simplesmente ignora e roda para sempre.
E aí mora a pegadinha que me pegou. Esse código aqui parece virtuoso:
for (int tentativa = 1; tentativa <= 3; tentativa++) {
try {
return consultarBandeira(transacao);
} catch (Exception e) { // InterruptedException também é uma Exception
log.warn("falhou, tentando de novo");
}
}
É retry, todo mundo faz, e em integração com bandeira ou adquirente é praticamente obrigatório, ninguém olha isso e vê bug. Só que a JVM limpa a flag antes de lançar a InterruptedException, então a tentativa número 2 começa com a thread limpa, abre uma conexão nova e trabalha o tempo inteiro, porque não sobrou nenhum vestígio de que alguém pediu cancelamento. O sinal foi destruído dentro do catch.
Medi isso mantendo a coordenação idêntica e trocando só o serviço:
[ 55 ms] LIMITE falha, cancelamento disparado
[ 57 ms] MÉTODO SAIU em 56 ms, do meu lado tudo parecia certo
[ 59 ms] BANDEIRA engoliu InterruptedException, vai tentar de novo
[ 60 ms] ANTIFRAUDE engoliu InterruptedException, vai tentar de novo
[309 ms] conexão BANDEIRA-retry#2 devolvida (250 ms)
[361 ms] conexão ANTIFRAUDE-retry#2 devolvida (301 ms)
Foram 708 conexão·ms contra 243, e repare que o método retornou aos 56ms com toda a aparência de sucesso, sem log de erro, sem exceção, sem métrica nenhuma. Você só vai descobrir isso quando o pool esgotar sob carga, e vai passar o dia procurando no lugar errado.
Com o escopo, esse mesmo bug faz o método travar até os 366ms. Parece pior, e é justamente esse o ponto. O bug não sumiu, nada faz um catch mal escrito desaparecer, mas ele deixou de ser uma thread fantasma que ninguém enxerga e virou uma latência com endereço, dentro de um bloco que eu consigo apontar, num thread dump que mostra a árvore de pai e filho. A StructuredTaskScope não te dá poder de matar nada, ela te dá detecção.
Onde isso me deixou
A regra que ficou pra mim é que quando você captura uma InterruptedException você tomou posse do único pedido de cancelamento que existia no sistema inteiro, então ou você age ou você devolve com Thread.currentThread().interrupt(), e um catch vazio não é ignorar, é destruir.
E vale uma ressalva, a structured concurrency ainda é preview, está no sexto no Java 26 com finalização esperada pro 27, e ela não substitui nada da defesa em camadas que já existia, timeout no recurso com setQueryTimeout e timeout de HTTP, bulkhead separando o pool de cada parceiro, circuit breaker, propagação de deadline. Se o seu autorizador esgota conexão em produção a primeira pergunta certa não é se você usou escopo, é se tem timeout no recurso.
e obrigado por quem leu até aqui.
Top comments (1)
Your exploration of task management in Java, especially with Loom's lightweight threads, is a crucial topic for optimizing resource usage. The challenge you identified with task dependencies and cancellation is significant, as it can lead to wasted resources and suboptimal performance. One approach might be to implement a custom orchestration mechanism that groups related tasks and manages their execution based on the completion of dependencies, effectively reducing unnecessary processing. If you’re looking for help in developing a solution for this orchestration aspect, I’d be happy to discuss a paid collaboration. What strategies have you considered for handling task dependencies more efficiently?