Seus programas não são single-threaded

Outras línguas: English Español Français Deutsch 日本語 한국어 中文

Ao escrever código, tendemos a pensar de forma síncrona. Escrevemos uma instrução após a outra, e o runtime as executa em ordem. Esse modelo mental funciona muito bem para scripts e ferramentas de linha de comando, mas pode ser perigosamente enganoso ao trabalhar com frameworks e aplicações de servidor.

Mesmo que você nunca crie threads explicitamente ou use primitivas de concorrência, sua aplicação web quase certamente está executando código em paralelo. O framework cuida disso para você, o que é conveniente, desde que você mantenha isso em mente e use corretamente o modelo de threading e os blocos de construção que o framework fornece.

Neste artigo, veremos exatamente o tipo de bug que pode acontecer quando alguém segue erroneamente um modelo mental single-threaded em código aparentemente síncrono. Encontraremos a causa, reproduziremos usando o debugger e veremos várias soluções possíveis.

O problema

Imagine que você tem uma aplicação Spring Boot que está funcionando bem em desenvolvimento e produção. Por muito tempo, tudo funcionou perfeitamente, mas então a equipe de contabilidade reporta um problema sério: várias faturas têm o mesmo número. Isso é ruim. Números de fatura devem ser únicos por razões legais e contábeis. As duplicatas apareceram apenas uma vez, e você não consegue reproduzir o problema localmente.

Tip icon

Para tornar este exercício mais interessante, você pode tentar encontrar o problema por conta própria antes de continuar lendo. O projeto está disponível no GitHub.

Você verifica o banco de dados, e ele mostra registros distintos com números de fatura idênticos. A configuração parece ok, mas claramente algo está errado. Ao rastrear o fluxo de criação de faturas, você encontra o serviço responsável por gerar números de fatura.

Veja como o código se parece:

@Service
public class InvoiceService {
    private InvoiceNumberGenerator generator;

    public Invoice createInvoice(Order order) {
        if (generator == null) {
            generator = createGenerator();
        }
        String invoiceNumber = generator.nextNumber();
        return new Invoice(invoiceNumber, order);
    }

    private InvoiceNumberGenerator createGenerator() {
        // Reads last used number from database, initializes counter
        int lastNumber = invoiceRepository.findMaxInvoiceNumber();
        return new InvoiceNumberGenerator(lastNumber);
    }
}

Não é assim que você escreveria esse código. Consegue identificar o problema?

O que deu errado?

O modelo mental do autor provavelmente era: “O Spring gerencia meus beans, então estou seguro.” E isso é verdade: o Spring realmente cuida do ciclo de vida dos beans e garante que InvoiceService seja criado uma vez e compartilhado corretamente. Mas o Spring não sabe sobre campos que você inicializa por conta própria. O campo generator é sua responsabilidade, e é aqui que o problema se esconde.

A sequência de eventos

Beans do Spring são singletons por padrão. Isso significa que há apenas uma instância de InvoiceService compartilhada entre todas as requisições. Quando múltiplas requisições HTTP chegam simultaneamente, elas são tratadas por threads diferentes, todas acessando a mesma instância do serviço.

Agora, vamos rastrear o que acontece se o gerador ainda não foi inicializado, e duas threads chamam createInvoice() aproximadamente ao mesmo tempo:

  1. Thread A verifica generator == null , que é true
  2. Thread A entra no bloco if e começa a criar o gerador
  3. Thread B verifica generator == null , que ainda é true (A ainda não criou o gerador)
  4. Thread B também entra no bloco if
  5. Ambas as threads consultam o banco de dados para findMaxInvoiceNumber() e obtêm o mesmo valor, por exemplo, 1000
  6. Ambas as threads criam seu próprio InvoiceNumberGenerator começando em 1000
  7. Ambas chamam nextNumber() e obtêm 1001
  8. Duas faturas são criadas com o mesmo número

A inicialização lazy não é thread-safe, e sob acesso concorrente, ambas as threads podem ler o mesmo “último número de fatura” do banco de dados e gerar o mesmo próximo número.

Reproduzindo com o debugger

O código problemático que acabamos de encontrar é simples, então você pode entender a sequência de eventos apenas olhando para ele. Em casos mais complexos, você pode querer reproduzir o bug passo a passo para entender exatamente o que está acontecendo. Outras vezes, você pode simplesmente querer simular o timing infeliz e testar se o programa não quebra sob carga.

Usaremos o debugger do IntelliJ IDEA para isso. Ele pode controlar threads individuais – exatamente o que precisamos.

Primeiro, defina um breakpoint na linha generator = createGenerator(); , clique com o botão direito nele, e configure Suspend para Thread. Isso diz ao debugger para suspender apenas a thread que atingiu o breakpoint em vez de todas.

Diálogo de configurações de ponto de interrupção com política de suspensão definida como Thread Diálogo de configurações de ponto de interrupção com política de suspensão definida como Thread

Execute a aplicação em modo debug e envie duas requisições de terminais diferentes usando o seguinte comando curl:

curl -X POST http://localhost:8080/api/diagnostics/create-order

Uma das requisições atingirá o breakpoint: isso é esperado. Quando a segunda requisição também atingir o breakpoint, você acabou de provar que múltiplas threads podem entrar na seção crítica simultaneamente – a definição do bug em questão.

Você pode inspecionar a aba Threads para ver ambas as threads esperando no mesmo local:

Aba Threads mostrando dois threads parados no mesmo local de ponto de interrupção Aba Threads mostrando dois threads parados no mesmo local de ponto de interrupção

Ambas as threads passaram pela verificação null , e quando você liberá-las, elas criarão suas próprias instâncias do gerador.

Alternativamente, você pode usar um breakpoint de log sem suspensão com um pequeno delay para simular execução mais lenta:

Ponto de interrupção com expressão de avaliação e log contendo Thread.sleep para simular execução lenta Ponto de interrupção com expressão de avaliação e log contendo Thread.sleep para simular execução lenta

Este tipo de breakpoint é projetado para logging de debug, mas como já sabemos, pode ser usado para introduzir praticamente qualquer efeito colateral. Com este delay artificial, não precisamos suspender a aplicação porque as requisições provavelmente vão colidir de qualquer forma.

Corrigindo o problema

Existem várias maneiras de corrigir o problema, dependendo dos seus requisitos:

Opção 1: Usar o banco de dados

Para números de fatura especificamente, a solução correta é deixar o banco de dados lidar com a unicidade:

@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;

    public InvoiceService(InvoiceRepository invoiceRepository) {
        this.invoiceRepository = invoiceRepository;
    }

    public Invoice createInvoice(Order order) {
        // Database generates the number via sequence or auto-increment
        return invoiceRepository.save(new Invoice(order));
    }
}

Isso elimina o contador em memória completamente. O banco de dados garante unicidade mesmo através de múltiplas instâncias da aplicação.

Opção 2: Inicialização eager

Se você precisa de um gerador em memória, torne-o um bean do Spring:

InvoiceNumberGenerator.java:
@Component
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

Esta é a abordagem padrão no Spring. Beans singleton são criados na inicialização e compartilhados com segurança entre todas as requisições.

Opção 3: Usar @Lazy

Se você realmente precisa de inicialização lazy (por exemplo, quando a criação de um bean é custosa porque carrega dados ou estabelece conexões), você pode usar @Lazy para deixar o Spring lidar com isso de forma segura:

InvoiceNumberGenerator.java:
@Component @Lazy
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          @Lazy InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

Como regra geral, se você se encontrar implementando manualmente inicialização lazy, cache, sincronização, ou padrões similares – verifique se o framework já fornece isso. Fazer você mesmo frequentemente significa lutar contra o framework e introduzir overhead de manutenção e bugs sutis.

Resumo

Mesmo quando não criamos threads explicitamente, nossas aplicações frequentemente rodam de forma concorrente. Este padrão não é único do Spring ou mesmo do desenvolvimento web. Em qualquer lugar que você tenha um modelo de threading implícito, você está em risco: frameworks web, toolkits de UI e até mesmo APIs da biblioteca padrão gerenciam threads nos bastidores.

Da próxima vez que você ver comportamento intermitente e inexplicável na sua aplicação, pergunte-se: isso poderia ser um problema de threading? Na maioria das vezes, a resposta é sim.

Bom debugging!

all posts ->