DEV Community

b3o
b3o

Posted on AI-assisted

Threads e Virtual Threads Guia ilustrado

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
Enter fullscreen mode Exit fullscreen mode

Mas temos apenas três funcionários:

João
Maria
Pedro
Enter fullscreen mode Exit fullscreen mode

Cada funcionário começa a trabalhar em um pedido:

João  -> Pedido 1
Maria -> Pedido 2
Pedro -> Pedido 3
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Podemos utilizar essa ideia para começar a entender o conceito de Thread.

De forma bastante simplificada:

Pedido
   |
   v
Thread
   |
   v
Execução
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Um dos elementos mais importantes para entendermos aqui é a Stack.

Entendendo a Stack

Imagine que temos as seguintes chamadas:

processarPedido()
       |
       v
buscarPedido()
       |
       v
consultarBanco()
Enter fullscreen mode Exit fullscreen mode

Enquanto consultarBanco() está sendo executado, precisamos manter o contexto das chamadas anteriores.

Podemos visualizar isso como uma pilha:

+----------------------+
| consultarBanco()     |
+----------------------+
| buscarPedido()       |
+----------------------+
| processarPedido()    |
+----------------------+
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A Virtual Thread A precisa esperar:

Virtual Thread A
       |
       v
   esperando
Enter fullscreen mode Exit fullscreen mode

A Carrier Thread pode então executar outra Virtual Thread:

Virtual Thread B
       |
       v
Carrier Thread
       |
       v
      CPU
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

E temos alguns funcionários físicos:

Carrier Thread 1
Carrier Thread 2
Carrier Thread 3
Enter fullscreen mode Exit fullscreen mode

Se uma tarefa precisar esperar alguma coisa:

Pedido 1
   |
   v
Esperando banco
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Já com Virtual Threads temos:

Tarefa
  |
  v
Virtual Thread
  |
  v
Carrier Thread
  |
  v
CPU
Enter fullscreen mode Exit fullscreen mode

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)