DEV Community

Cover image for Goroutines vs. Java Virtual Threads: um guia de sobrevivência
Carolina Vila-Nova
Carolina Vila-Nova

Posted on

Goroutines vs. Java Virtual Threads: um guia de sobrevivência

A evolução da concorrência em sistemas modernos atingiu um ponto de convergência muito interessante para nós, desenvolvedores de software.

De um lado, temos Go, que nasceu com uma filosofia de processos simples e leves e colocou a concorrência no centro da linguagem.

Do outro, temos Java, que com o Project Loom somado ao Spring Boot 3.2+, reinventou a infraestrutura de execução da concorrência, permitindo que o modelo imperativo pudesse ser escalável ao rodar sobre threads virtuais leves.

Antes, uma Platform Thread em Java era mapeada 1:1 para uma Thread do Sistema Operacional, a um custo altíssimo: cada thread consumia cerca de 1MB ou mais de memória apenas para existir. Ou seja, se você precisasse de 100.000 threads simultâneas, seu servidor travaria por falta de memória (OOM).

Java passou 20 anos tentando resolver isso com Reactive Programming (WebFlux), mas o preço foi abandonar o estilo imperativo e adotar um estilo declarativo/funcional complexo.

Com as virtual threads, ela retornou ao modelo imperativo de código sequencial (fazerA(), depois fazerB(); fácil de ler e debugar) e mudou a implementação da concorrência, tornando-a tão eficiente quanto as goroutines. Mas com uma grande sacada: a retrocompatibilidade.

Vale ressaltar que as Virtual Threads consomem apenas alguns KB, assim como as goroutines.

Para ilustrar melhor essa comparação, vamos nos inspirar em "Fallout" e utilizar o sistema de monitoramento da Vault-Tec: um Pip-Boy (emissor) enviando dados de radiação para um Terminal de Vault (receptor).

Canais e controle explícito em Go

Go não tenta esconder que você está lidando com processos paralelos; ela apenas torna isso indolor através do modelo CSP (Communicating Sequential Processes). A filosofia é: "Não comunique compartilhando memória; compartilhe memória comunicando".

O desenvolvedor detém o controle do ciclo de vida da goroutine ao chamar go func(). Canais (chan) são a forma padrão de comunicação, um cidadão de primeira classe. A conexão entre os canais e a sincronização também se dão explicitamente.

O tratamento de erro é onipresente: ele faz parte do fluxo de controle. Se um canal fecha ou uma leitura falha, você decide exatamente o que fazer no próximo passo. Para garantir que o terminal não feche antes de o Pip-Boy terminar, você pode usar o sync.WaitGroup ou simplesmente o fechamento do canal como sinalizador para o loop range.

No exemplo abaixo, criamos um retry em caso de erro, mas caso as falhas ultrapassem determinado percentual, o processo é abortado.

const terminalErrorMsg = "❌ [Terminal] Erro fatal de hardware"
func VaultMonitor() {
    // Canal de dados para comunicação entre o Pip-Boy e o Terminal
    dataStream := make(chan string)

    // Goroutine do Pip-Boy (Emissor)
    go func() {
        defer close(dataStream)
        for i := 0; i < 10; i++ {
            msg, err := lerSensorPipBoy()

            if err != nil {
                // Tratamento de erro explícito com log estruturado
                slog.Warn("⚠️ [Pip-Boy] Falha na leitura", "erro", err)
                time.Sleep(500 * time.Millisecond)
                continue
            }

            slog.Info("📱 [Pip-Boy] Sinal enviado")
            dataStream <- msg
            time.Sleep(time.Second)
        }
    }()

    // Loop principal (Receptor/Terminal)
    for msg := range dataStream {
        err := processarNoTerminal(msg)
        if err != nil {
            slog.Error(terminalErrorMsg, "erro", err)
            return
        }
        slog.Info("🖥️ [Terminal Log]", "status", msg)
    }

    slog.Info("✅ Monitoramento finalizado com sucesso.")
}

func lerSensorPipBoy() (string, error) {
    // Simula 20% de chance de erro de leitura
    if rand.Float32() < 0.2 {
        return "", errors.New("sensor obstruído por poeira atômica")
    }
    slog.Error(terminalErrorMsg, "erro", errors.New("sensor obstruído por poeira atômica"))
    return fmt.Sprintf("%d RADs", rand.Intn(100)), nil
}

func processarNoTerminal(msg string) error {
    // Simula uma falha rara no hardware do terminal
    if rand.Float32() < 0.05 {
        return errors.New("curto-circuito no painel principal")
    }
    slog.Error(terminalErrorMsg, "erro", errors.New("curto-circuito no painel principal"))
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Infraestrutura e abstração em Java

Em vez de criar novos tipos primitivos (como chan), os arquitetos do Project Loom modificaram a JVM para que as estruturas tradicionais de bloqueio (Thread.sleep(), BlockingQueue.take()) e de concorrência (ConcurrentHashMap, AtomicInteger) passassem a ser 'amigáveis' às Virtual Threads.

A diferença é que o bloqueio agora é não-bloqueante para o SO: enquanto o fluxo do código espera de forma sequencial, a thread real do SO é liberada para outras tarefas. A sinalização de término, por sua vez, segue o padrão Java, sendo feita via mensagens "sentinela" ou interrupção de thread.

No lugar de um controle manual de threads, o Spring Boot aplica a Inversão de Controle para gerenciar o paralelismo. Você apenas anota o método que precisa ser assíncrono, e o framework se encarrega de instanciar e escalar as Virtual Threads nos bastidores.

O StructuredTaskScope (Concorrência Estruturada) funciona como os grupos de Go. A diferença é que o Java trata o grupo de threads como uma unidade de trabalho hierárquica: se a tarefa principal (o Overseer) morre, todas as sub-tarefas (os moradores) são canceladas automaticamente. É uma forma de evitar "goroutines leaks".

Enquanto em Go o context.Context é repassado manualmente, no Spring, o contexto é propagado automaticamente através de ThreadLocal (que foi otimizado para Virtual Threads).

Em Java/Spring, o erro geralmente interrompe o fluxo atual e é capturado. O controle é menos uma questão de "o que fazer a cada linha" e mais de "como recuperar o estado da aplicação".

O tratamento de erro em threads assíncronas é centralizado via interceptadores ou blocos try-catch tradicionais. O Spring Boot oferece o AsyncUncaughtExceptionHandler para capturar erros que ocorrem em métodos @Async sem que você precise poluir a lógica.

@Slf4j
@Service
public class VaultCommunicationService {

    private final BlockingQueue<String> dataStream = new LinkedBlockingQueue<>(10);

    @Async
    public void pipBoySender() {
        try {
            while (!Thread.currentThread().isInterrupted()) {
                String msg = "☢️ Nível de Radiação: " + new Random().nextInt(100) + " RADs";
                dataStream.put(msg); 
                log.info("📱 [Pip-Boy] Sinal enviado para a fila.");
                Thread.sleep(1000); 
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("🚨 Conexão com Pip-Boy interrompida!");
        } catch (Exception e) {
            log.error("❌ Erro no sensor: {}", e.getMessage());
        }
    }

    @Async
    public void terminalReceiver() {
        try {
            while (true) {
                String msg = dataStream.take(); 
                log.info("🖥️ [Terminal Log]: {}", msg);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Diferenças em compilação e escalonamento

Ao compilar-se um programa em Go, o compilador não traduz apenas o código, mas injeta um Runtime dentro do executável, incluindo aí o Scheduler (escalonador). Esse escalonador usa o modelo M:P:N: distribui "M" Goroutines em "P" contextos de processadores lógicos, que rodam sobre "N" threads do sistema operacional.

O Runtime é prioritariamente cooperativo, mas possui mecanismos de preempção para evitar que uma única goroutine "sequestre" o processador em loops intensos. Se uma Goroutine é bloqueada, ele a retira de cena e coloca outra no lugar imediatamente, sem interferência do SO. É um controle total dentro do processo.

A Java Virtual Machine (JVM) atua como uma camada intermediária entre o código (o arquivo .jar) e o hardware. A partir do Project Loom, as Virtual Threads são gerenciadas por um componente da JVM chamado ForkJoinPool, que atua como escalonador padrão.

Ele mantém um pool de threads reais do SO, as chamadas Carrier Threads. Quando você inicia uma Virtual Thread, a JVM a "monta" em uma dessas threads reais para execução. Ao deparar com um ponto de bloqueio (como dataStream.take()), a JVM captura o estado atual da Virtual Thread (o stack) e o move para a memória (Heap). A thread real do SO fica livre para "transportar" outra Virtual Thread. Quando o dado chega, a JVM "remonta" o estado e continua a execução. Este processo recebe o nome de Continuation.

Essa distinção explica por que o Java 21+ é uma mudança tão significativa: ele não mudou apenas a biblioteca de Threads, mas sim a forma como a JVM conversa com o processador para permitir que o código imperativo tradicional tenha a performance de um sistema de alta concorrência.

Uma vantagem é que as Virtual Threads são compatíveis com milhões de linhas de código legado, bastando para isso a habilitação de uma chave no application.yml (a configuração spring.threads.virtual.enabledem Java 21 e Spring Boot 3.2+).

Conclusão

A convergência entre Go e Java 21+ na questão da concorrência é uma vitória para os desenvolvedores. Seja qual linguagem atenda melhor suas necessidades de projeto, o objetivo será o mesmo: garantir que o seu sistema sobreviva ao "apocalipse" de tráfego das aplicações modernas. A Wasteland nunca foi tão amigável para quem precisa de performance.

Referências

  1. Repositório no GitHub
  2. Explorando Virtual Threads no Java 21
  3. Difference Between Thread and Virtual Thread in Java
  4. O Fim da Programação Reativa? Como o Project Loom e as Threads Virtuais do Java 21 Estão Transformando o Desenvolvimento em Java
  5. Revolucionando Concorrência em Java: Threads Tradicionais vs. Virtual Threads

Top comments (1)

Collapse
 
lidianycs profile image
lidianycs

Parabéns pelo artigo Carolina. É impressionante como vc escreve bem, mesmo em artigos técnicos vc consegue simplificar o conteúdo. Viu como o Dev é melhor para publicar?
Sobre o Java, é impressionante a evolução da linguagem, os antigos processos realmente não conseguiam dar conta da necessidade de mais processamento e menos memória. Ou a linguagem evoluía ou ia se tornar obsoleta diante de outras com recursos mais poderosos.