Mudamos para virtual threads do Java 21 e tivemos um deadlock em TPC-C para Postgres

As novas virtual threads do Java 21 estão expondo riscos sutis de deadlock em código Java existente, especialmente quando primitivas de sincronização antigas como `synchronized` e `Object.wait()` são usadas dentro de bibliotecas como pools de conexão JDBC. Comentários observam que esses construtos podem “pin”ar threads carrier e esgotar o pool limitado de threads subjacentes, quebrando programas que eram seguros com muitas OS threads. O consenso é que as virtual threads são promissoras para workloads intensivos em I/O, mas ainda não são uma substituição direta em todos os casos, e que os desenvolvedores devem auditar bibliotecas, preferir utilitários modernos de concorrência (locks, semáforos, coleções concorrentes) e ajustar ou aguardar correções do runtime antes de uma adoção ampla.

Natureza do deadlock

  • Há consenso de que as virtual threads não criaram “magicamente” uma nova classe de deadlock; o benchmark expôs um problema de projeto existente sob novas restrições.
  • O deadlock surgiu porque virtual threads ficaram bloqueadas dentro do código do pool de conexões c3p0, usando synchronized + Object.wait, esgotando as carrier threads.
  • A correção no artigo — proteger as conexões com o banco com um Semaphore — impede que as virtual threads entrem no c3p0 até que uma conexão esteja disponível, então elas bloqueiam em código ciente de VT em vez de dentro do pool.
  • Vários comentaristas enfatizam que este é um problema genérico de JDBC/pool de conexões, não específico do PostgreSQL.

Virtual threads, pinning e progresso à frente

  • Virtual threads não podem ser desmontadas quando:
    • Estão executando dentro de blocos/métodos synchronized, ou
    • Estão executando código nativo/estrangeiro, ou certas chamadas bloqueantes legadas como Object.wait.
  • Nesses casos, a thread “carrier” (OS) fica “pinada”; se muitas chamadas assim ocorrerem, o pool de carriers do agendador de VT (máximo padrão 256, configurável via jdk.virtualThreadScheduler.maxPoolSize) pode ser esgotado.
  • Alguns esclarecem que Object.wait tanto libera o monitor quanto é implementado em código nativo, então o problema não é apenas synchronized, mas também o fato de o próprio wait não cooperar com o agendamento de VT.
  • A preocupação é menos com a correção dos locks e mais com a perda de “progresso à frente” quando todos os carriers ficam bloqueados.

“Substituição direta” vs. novas semânticas

  • A documentação e parte da comunicação apresentaram as virtual threads como, em grande parte, substituição direta; outros apontam o guia de adoção, que adverte explicitamente contra trocar cegamente todas as platform threads por VTs.
  • Um lado: se seu design exige mais threads executáveis simultaneamente do que carriers, o design era frágil; VT apenas expõe isso.
  • Outro lado: o código anterior estava correto sob a suposição de que o SO sempre poderia criar mais threads; introduzir um limite rígido de carriers muda silenciosamente o comportamento do sistema e pode quebrar esse código.
  • Há discordância sobre se a documentação alertava adequadamente sobre deadlocks ou apenas sobre questões de “escalabilidade”.

Workarounds e boas práticas discutidas

  • Substituir padrões synchronized/wait/notify por:
    • Locks, semáforos e conditions de java.util.concurrent.
    • Filas concorrentes para padrões produtor–consumidor.
  • Evitar operações longas ou bloqueantes dentro de synchronized; isso já é má prática mesmo sem VTs.
  • Usar pools de carrier threads e configuração:
    • Aumentar jdk.virtualThreadScheduler.maxPoolSize quando apropriado.
    • Descarregar chamadas CPU-bound ou nativas para executores com platform threads.
  • Usar ferramentas de diagnóstico como -Djdk.tracePinnedThreads para localizar áreas problemáticas.
  • Alguns sugerem trocar pools antigos como c3p0 por opções mantidas ativamente (por exemplo, HikariCP), embora até pools modernos ainda estejam se ajustando para VTs.

Maturidade das virtual threads e correções futuras

  • Muitos veem as virtual threads como poderosas, mas ainda não “configure e esqueça”; elas são melhores para workloads de IO, código moderno e que evita primitivas legadas.
  • Vários observam que o comportamento de synchronized/wait é uma limitação de implementação conhecida e temporária, e que a equipe do JDK está trabalhando para torná-las amigáveis a VT.
  • Alguns recomendam esperar por um futuro LTS em que synchronized e Object.wait cooperem totalmente com virtual threads antes de adotá-las amplamente, especialmente em codebases com muitas bibliotecas de terceiros.

Comparações e meta-discussão

  • Foram feitas comparações com Go, Erlang, Node.js, Python async e o thread pool/async da .NET: todos tiveram peculiaridades iniciais ao misturar modelos bloqueantes e não bloqueantes.
  • Discussões paralelas questionam se o green threading/M:N pode realmente ser totalmente transparente em ecossistemas construídos em torno de threads do SO.
  • Outros desvios menores tratam da editorialização do título no HN e do desagrado generalizado com imagens heroicas obviamente geradas por IA em posts técnicos.