Threads e Virtual Threads: entendendo de uma forma simples
Threads e Virtual Threads são assuntos que, à primeira vista, podem parecer complexos e desafiadores durante a carreira no desenvolvimento de software.
Muitas vezes, esses conceitos acabam sendo deixados de lado porque simplesmente "funcionam no background". Criamos uma aplicação, fazemos algumas requisições e, na maioria das vezes, não precisamos pensar muito sobre quem está executando aquilo por trás.
Porém, quando começamos a estudar mais sobre como nossas aplicações funcionam, alguns termos começam a aparecer:
- Concorrência
- Paralelismo
- Escalonamento
- Gerenciamento de recursos
- Threads
- Virtual Threads
E é aí que o assunto começa a parecer um pouco mais complicado.
Por isso decidi escrever este artigo de uma forma um pouco diferente. Além de compartilhar conhecimento, este material também é uma forma de consolidar e fixar aquilo que venho aprendendo.
Acredito muito na ideia de que, quando conseguimos explicar algo de maneira simples, é porque começamos a entender o assunto de verdade.
A ideia aqui não é criar um conteúdo extremamente aprofundado ou entrar em todos os detalhes internos da JVM. O objetivo é revisitar os conceitos fundamentais e, principalmente, tentar explicar tudo de uma forma simples e visual.
Ao final, a ideia é que você não apenas saiba responder o que é uma Thread ou uma Virtual Thread, mas consiga visualizar mentalmente o que está acontecendo por trás do código.
Threads
Para começarmos essa jornada, gosto bastante de utilizar uma analogia.
Imagine que estamos em um restaurante.
Chegam cinco pedidos na cozinha:
Pedido 1
Pedido 2
Pedido 3
Pedido 4
Pedido 5
Mas temos apenas três funcionários:
João
Maria
Pedro
Cada funcionário começa a trabalhar em um pedido:
João -> Pedido 1
Maria -> Pedido 2
Pedro -> Pedido 3
Os outros dois pedidos precisam esperar.
Quando João termina seu pedido, por exemplo, ele pode pegar o próximo:
João termina o Pedido 1
|
v
João começa o Pedido 4
Podemos utilizar essa ideia para começar a entender o conceito de Thread.
De forma bastante simplificada:
Pedido
|
v
Thread
|
v
Execução
O pedido representa uma tarefa que precisa ser realizada, enquanto a Thread representa uma unidade de execução responsável por realizar esse trabalho.
Essa analogia não representa todos os detalhes técnicos de uma Thread, mas é uma boa forma de começar a construir uma imagem mental sobre o assunto.
O problema começa quando temos muitas tarefas
Até aqui tudo parece simples.
Temos algumas tarefas e alguns funcionários.
Agora imagine que nosso restaurante recebeu 10.000 pedidos.
Seguindo a mesma lógica, poderíamos pensar:
10.000 tarefas
|
v
10.000 Threads
E aqui começamos a encontrar um problema.
Threads tradicionais possuem um custo.
Criar e manter milhares de Threads significa utilizar memória e outros recursos do sistema operacional. Além disso, essas Threads precisam ser gerenciadas e escalonadas.
E existe outro detalhe importante.
Nem todas essas Threads estão realmente executando alguma coisa.
Imagine uma requisição que precisa consultar um banco de dados:
Requisição
|
v
Banco de dados
|
v
Esperando resposta...
Durante esse período, a aplicação está esperando uma resposta externa.
Esse tipo de operação é conhecido como I/O, ou Input/Output.
E esse cenário é extremamente comum em aplicações backend.
Uma requisição pode passar boa parte do seu tempo esperando:
- Banco de dados
- APIs externas
- Arquivos
- Rede
- Outros serviços
Ou seja, podemos ter milhares de Threads existentes, mas muitas delas podem estar simplesmente esperando alguma operação terminar.
O que existe dentro de uma Thread?
Para entender por que uma Thread possui um custo, precisamos olhar um pouco para o que ela precisa manter durante sua execução.
Imagine este código:
void processarPedido() {
var pedido = buscarPedido();
var pagamento = processarPagamento(pedido);
salvar(pagamento);
}
Enquanto esse código está sendo executado, precisamos manter informações sobre o estado daquela execução.
Podemos imaginar conceitualmente algo assim:
Thread
├── Estado da execução
├── Stack
├── Variáveis locais
├── Chamadas de métodos
└── Informações necessárias para continuar
Um dos elementos mais importantes para entendermos aqui é a Stack.
Entendendo a Stack
Imagine que temos as seguintes chamadas:
processarPedido()
|
v
buscarPedido()
|
v
consultarBanco()
Enquanto consultarBanco() está sendo executado, precisamos manter o contexto das chamadas anteriores.
Podemos visualizar isso como uma pilha:
+----------------------+
| consultarBanco() |
+----------------------+
| buscarPedido() |
+----------------------+
| processarPedido() |
+----------------------+
Quando consultarBanco() termina, ela sai da Stack.
Depois buscarPedido() termina.
E finalmente processarPedido() termina.
A Stack é apenas uma parte do que uma Thread precisa manter, mas ela já ajuda a entender que uma Thread não é simplesmente uma abstração sem custo.
Podemos pensar, de forma simplificada:
1 Thread
|
v
1 contexto de execução
10 Threads
|
v
10 contextos de execução
1.000 Threads
|
v
1.000 contextos de execução
Não significa que o consumo de memória seja simplesmente proporcional dessa forma ou que o único custo de uma Thread seja sua Stack. A ideia aqui é apenas entender que manter uma grande quantidade de Threads tradicionais possui um custo.
E não é apenas memória
Talvez você esteja pensando:
"Então o problema é apenas a memória?"
Não.
Existe também o gerenciamento dessas Threads.
Imagine que temos 10.000 Threads, mas nossa máquina possui apenas alguns núcleos de CPU.
Todas essas Threads não podem executar fisicamente ao mesmo tempo.
O sistema operacional precisa decidir quais Threads vão executar e quando.
De forma simplificada:
Thread A -> executando
Thread B -> esperando
Thread C -> executando
Thread D -> esperando
Thread E -> esperando
Esse processo envolve escalonamento e, quando necessário, troca de contexto entre diferentes Threads.
Esse é um assunto que merece uma explicação própria, então não vou entrar em detalhes aqui.
Por enquanto, basta guardar a seguinte ideia:
Threads tradicionais possuem custos de memória, criação, gerenciamento e escalonamento.
E é justamente nesse ponto que as Virtual Threads começam a fazer sentido.
Virtual Threads
As Virtual Threads surgiram no Java com o objetivo de tornar muito mais leve a criação e o gerenciamento de grandes quantidades de tarefas concorrentes.
Em vez de imaginar:
1 tarefa
|
v
1 Thread do sistema operacional
podemos começar a imaginar algo diferente:
Muitas tarefas
Thread Thread Thread Thread Thread
Thread Thread Thread Thread Thread
Thread Thread Thread Thread Thread
|
v
Poucas Threads
do sistema operacional
No modelo de Virtual Threads, temos as Virtual Threads sendo executadas sobre Threads do sistema operacional, chamadas de Carrier Threads. (As carrier threads são como os caixas do supermercado, que atendem e processam os clientes (threads virtuais) à medida que eles chegam para pagar.).
Podemos visualizar assim:
Virtual Threads
Thread Thread Thread Thread
Thread Thread Thread Thread
Thread Thread Thread Thread
|
v
Carrier Threads
Thread Thread Thread
|
v
CPU
A JVM é responsável por gerenciar esse relacionamento.
O que acontece quando uma Virtual Thread precisa esperar?
Agora chegamos em uma das partes mais interessantes.
Imagine que uma Virtual Thread esteja executando uma operação que precisa esperar uma resposta do banco:
Virtual Thread
|
v
Consulta ao banco
|
v
esperando...
Em operações bloqueantes suportadas pelo modelo de Virtual Threads, a JVM pode retirar essa Virtual Thread da Carrier Thread enquanto ela está aguardando.
Isso permite que a Carrier Thread seja utilizada para executar outra Virtual Thread.
Podemos imaginar:
Antes:
Virtual Thread A
|
v
Carrier Thread
|
v
CPU
A Virtual Thread A precisa esperar:
Virtual Thread A
|
v
esperando
A Carrier Thread pode então executar outra Virtual Thread:
Virtual Thread B
|
v
Carrier Thread
|
v
CPU
Quando a operação da Virtual Thread A estiver pronta para continuar, ela poderá voltar a ser executada.
Essa é uma das ideias fundamentais por trás das Virtual Threads.
Thread tradicional vs Virtual Thread
Agora podemos visualizar melhor a diferença.
Thread tradicional
De forma simplificada:
Tarefa
|
v
Thread
|
v
CPU
Existe uma relação mais direta entre a unidade de execução e a Thread do sistema operacional.
Virtual Thread
Agora temos uma camada adicional:
Tarefa
|
v
Virtual Thread
|
v
Carrier Thread
|
v
CPU
Isso permite trabalhar com uma quantidade muito maior de unidades de execução leves sem precisar criar uma Thread do sistema operacional para cada uma delas.
Virtual Thread não é uma Thread mais rápida
Essa é uma confusão bastante comum.
Quando ouvimos que Virtual Threads são "leves", podemos acabar pensando:
"Então elas são simplesmente Threads tradicionais mais rápidas."
Não é exatamente isso.
Virtual Threads não criam mais CPU.
Elas também não fazem uma operação que demora 10 segundos terminar em 1 segundo.
O principal benefício está na capacidade de lidar com grandes quantidades de tarefas concorrentes, principalmente quando essas tarefas passam bastante tempo esperando operações de I/O.
Por exemplo:
Requisição
|
v
Banco de dados
|
v
Esperando...
Enquanto uma tarefa está esperando, a capacidade de execução disponível pode ser utilizada para trabalhar em outra tarefa.
É nesse tipo de cenário que Virtual Threads se tornam especialmente interessantes.
Voltando para a nossa cozinha
Vamos voltar para o restaurante.
Antes tínhamos:
Pedidos:
Pedido 1
Pedido 2
Pedido 3
Pedido 4
Pedido 5
Funcionários:
João
Maria
Pedro
Se os três funcionários estivessem ocupados, os outros pedidos precisariam esperar.
Agora imagine que conseguimos representar cada pedido com uma unidade de execução muito mais leve:
Pedido 1 -> Virtual Thread
Pedido 2 -> Virtual Thread
Pedido 3 -> Virtual Thread
Pedido 4 -> Virtual Thread
Pedido 5 -> Virtual Thread
...
E temos alguns funcionários físicos:
Carrier Thread 1
Carrier Thread 2
Carrier Thread 3
Se uma tarefa precisar esperar alguma coisa:
Pedido 1
|
v
Esperando banco
o funcionário não precisa necessariamente ficar parado esperando aquele pedido.
Ele pode trabalhar em outro:
Pedido 2
|
v
Carrier Thread
|
v
Execução
Essa é a imagem mental que eu gostaria que você levasse deste artigo.
A ideia principal
Se você lembrar apenas de uma coisa deste artigo, lembre-se disso.
Uma Thread tradicional pode ser representada de forma simplificada assim:
Tarefa
|
v
Thread do sistema operacional
|
v
CPU
Já com Virtual Threads temos:
Tarefa
|
v
Virtual Thread
|
v
Carrier Thread
|
v
CPU
A grande ideia não é simplesmente ter uma Thread mais rápida.
É poder trabalhar com muitas unidades leves de execução, permitindo que a JVM gerencie melhor a utilização das Threads do sistema operacional.
Isso é especialmente interessante em aplicações que lidam com muitas operações de I/O.
Conclusão
Threads são fundamentais para entender como nossas aplicações executam tarefas de forma concorrente.
Porém, quando começamos a trabalhar com milhares de tarefas simultâneas, o custo de criar e gerenciar milhares de Threads tradicionais pode se tornar um problema.
As Virtual Threads apresentam uma abordagem diferente.
Elas permitem representar muitas tarefas concorrentes de maneira muito mais leve e deixam a JVM cuidar do gerenciamento dessas execuções sobre as Carrier Threads.
E talvez a melhor forma de guardar tudo isso seja voltando para a nossa cozinha.
Não estamos criando milhares de funcionários.
Estamos criando uma forma melhor de organizar os pedidos para que os funcionários disponíveis não fiquem parados enquanto um pedido está esperando alguma coisa.
Essa é a ideia central das Virtual Threads.
Nos próximos artigos, podemos sair da analogia e começar a olhar para o Java de verdade: como criar uma Virtual Thread, como funciona o Executors.newVirtualThreadPerTaskExecutor(), o que acontece com a Stack e como a JVM gerencia as Carrier Threads.

Top comments (0)