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.
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 :
- Le thread A vérifie
generator == null, qui esttrue - Le thread A entre dans le bloc
ifet commence à créer le générateur - Le thread B vérifie
generator == null, qui est toujourstrue(A n’a pas encore créé le générateur) - Le thread B entre également dans le bloc
if - Les deux threads interrogent la base de données pour
findMaxInvoiceNumber()et obtiennent la même valeur, par exemple1000 - Les deux threads créent leur propre
InvoiceNumberGeneratorcommençant à1000 - Les deux appellent
nextNumber()et obtiennent1001 - 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.
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 :
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 :
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 :
@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);
}
}
@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é :
@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);
}
}
@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 !