Vos programmes ne sont pas mono-thread

Autres langues : English Español Deutsch 日本語 한국어 Português 中文

Lorsqu’on écrit du code, on a tendance à penser de manière synchrone. On écrit une instruction après l’autre, et le runtime les exécute dans l’ordre. Ce modèle mental fonctionne très bien pour les scripts et les outils en ligne de commande, mais peut être dangereusement trompeur lorsqu’on travaille avec des frameworks et des applications serveur.

Même si vous ne créez jamais explicitement de threads ou n’utilisez pas de primitives de concurrence, votre application web exécute presque certainement du code en parallèle. Le framework gère cela pour vous, ce qui est pratique, tant que vous gardez cela à l’esprit et utilisez correctement le modèle de threading et les blocs de construction que le framework fournit.

Dans cet article, nous examinerons exactement le type de bug qui peut survenir quand on suit par erreur un modèle mental mono-thread dans du code apparemment synchrone. Nous trouverons la cause, la reproduirons avec le débogueur et examinerons plusieurs solutions possibles.

Le problème

Imaginez que vous avez une application Spring Boot qui fonctionne bien en développement et en production. Pendant longtemps, tout a fonctionné sans problème, mais ensuite l’équipe comptable signale un problème sérieux : plusieurs factures ont le même numéro. C’est grave. Les numéros de facture doivent être uniques pour des raisons légales et comptables. Les doublons ne sont apparus qu’une seule fois, et vous ne pouvez pas reproduire le problème localement.

Tip icon

Pour rendre cet exercice plus intéressant, vous pouvez essayer de trouver le problème vous-même avant de continuer à lire. Le projet est disponible sur GitHub.

Vous vérifiez la base de données, et elle affiche des enregistrements distincts avec des numéros de facture identiques. La configuration semble correcte, pourtant quelque chose ne va clairement pas. En traçant le flux de création de factures, vous trouvez le service responsable de la génération des numéros de facture.

Voici à quoi ressemble le code :

@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);
    }
}

Ce n’est pas comme ça que vous écririez ce code. Pouvez-vous repérer le problème ?

Qu’est-ce qui s’est mal passé ?

Le modèle mental de l’auteur était probablement : “Spring gère mes beans, donc je suis en sécurité.” Et c’est vrai : Spring gère effectivement le cycle de vie des beans et s’assure que InvoiceService est créé une fois et partagé correctement. Mais Spring ne connaît pas les champs que vous initialisez vous-même. Le champ generator est de votre responsabilité, et c’est là que le problème se cache.

La séquence des événements

Les beans Spring sont des singletons par défaut. Cela signifie qu’il n’y a qu’une seule instance de InvoiceService partagée entre toutes les requêtes. Lorsque plusieurs requêtes HTTP arrivent simultanément, elles sont gérées par différents threads, tous accédant à la même instance de service.

Maintenant, traçons ce qui se passe si le générateur n’a pas encore été initialisé, et que deux threads appellent createInvoice() à peu près en même temps :

  1. Le thread A vérifie generator == null , qui est true
  2. Le thread A entre dans le bloc if et commence à créer le générateur
  3. Le thread B vérifie generator == null , qui est toujours true (A n’a pas encore créé le générateur)
  4. Le thread B entre également dans le bloc if
  5. Les deux threads interrogent la base de données pour findMaxInvoiceNumber() et obtiennent la même valeur, par exemple 1000
  6. Les deux threads créent leur propre InvoiceNumberGenerator commençant à 1000
  7. Les deux appellent nextNumber() et obtiennent 1001
  8. Deux factures sont créées avec le même numéro

L’initialisation paresseuse n’est pas thread-safe, et sous accès concurrent, les deux threads peuvent lire le même “dernier numéro de facture” de la base de données et générer le même numéro suivant.

Reproduire avec le débogueur

Le code problématique que nous venons de trouver est simple, donc vous pourriez comprendre la séquence des événements juste en le regardant. Dans des cas plus complexes, vous voudrez peut-être reproduire le bug étape par étape pour comprendre exactement ce qui se passe. D’autres fois, vous voudrez peut-être simplement simuler le timing malheureux et tester si le programme ne casse pas sous charge.

Nous utiliserons le débogueur d’IntelliJ IDEA pour cela. Il peut contrôler les threads individuellement – exactement ce dont nous avons besoin.

D’abord, placez un point d’arrêt à la ligne generator = createGenerator(); , faites un clic droit dessus, et définissez Suspend sur Thread. Cela indique au débogueur de ne suspendre que le thread qui a atteint le point d’arrêt plutôt que tous.

Boîte de dialogue des paramètres du point d'arrêt avec la politique de suspension définie sur Thread Boîte de dialogue des paramètres du point d'arrêt avec la politique de suspension définie sur Thread

Exécutez l’application en mode débogage et envoyez deux requêtes depuis différents terminaux en utilisant la commande curl suivante :

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

L’une des requêtes atteindra le point d’arrêt : c’est attendu. Quand la deuxième requête atteint également le point d’arrêt, vous venez de prouver que plusieurs threads peuvent entrer dans la section critique simultanément – la définition du bug en question.

Vous pouvez inspecter l’onglet Threads pour voir les deux threads en attente au même endroit :

Onglet Threads affichant deux threads arrêtés au même emplacement de point d'arrêt Onglet Threads affichant deux threads arrêtés au même emplacement de point d'arrêt

Les deux threads ont passé la vérification null , et quand vous les libérerez, ils créeront leurs propres instances de générateur.

Alternativement, vous pouvez utiliser un point d’arrêt de journalisation sans suspension avec un petit délai pour simuler une exécution plus lente :

Point d'arrêt avec expression d'évaluation et de journalisation contenant Thread.sleep pour simuler une exécution lente Point d'arrêt avec expression d'évaluation et de journalisation contenant Thread.sleep pour simuler une exécution lente

Ce type de point d’arrêt est conçu pour la journalisation de débogage, mais comme nous le savons déjà, il peut être utilisé pour introduire pratiquement n’importe quel effet de bord. Avec ce délai artificiel, nous n’avons pas besoin de suspendre l’application car les requêtes sont susceptibles de se chevaucher de toute façon.

Corriger le problème

Il existe plusieurs façons de corriger le problème, selon vos besoins :

Option 1 : Utiliser la base de données

Pour les numéros de facture spécifiquement, la solution correcte est de laisser la base de données gérer l’unicité :

@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));
    }
}

Cela élimine entièrement le compteur en mémoire. La base de données garantit l’unicité même à travers plusieurs instances d’application.

Option 2 : Initialisation eager

Si vous avez besoin d’un générateur en mémoire, faites-en un bean 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);
    }
}

C’est l’approche standard en Spring. Les beans singleton sont créés au démarrage et partagés en toute sécurité entre toutes les requêtes.

Option 3 : Utiliser @Lazy

Si vous avez vraiment besoin d’une initialisation paresseuse (par exemple, quand la création d’un bean est coûteuse parce qu’il charge des données ou établit des connexions), vous pouvez utiliser @Lazy pour laisser Spring gérer cela en toute sécurité :

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);
    }
}

En règle générale, si vous vous retrouvez à implémenter manuellement une initialisation paresseuse, du cache, de la synchronisation, ou des patterns similaires – vérifiez si le framework le fournit déjà. Le faire vous-même signifie souvent lutter contre le framework et introduire une surcharge de maintenance et des bugs subtils.

Résumé

Même quand nous ne créons pas explicitement de threads, nos applications s’exécutent souvent de manière concurrente. Ce pattern n’est pas unique à Spring ou même au développement web. Partout où vous avez un modèle de threading implicite, vous êtes à risque : les frameworks web, les toolkits UI et même les APIs de la bibliothèque standard gèrent tous des threads en coulisses.

La prochaine fois que vous verrez un comportement intermittent et inexplicable dans votre application, demandez-vous : cela pourrait-il être un problème de threading ? Le plus souvent, la réponse est oui.

Bon débogage !

all posts ->