Le composant Lock

Gérer les accès concurrents : verrous bloquants, expiration, stores distribués et race conditions.

Créé le 15 juin 2026·Mis à jour le 15 juin 2026
Voir 2 références

Introduction


Dès qu'une application tourne sur plusieurs processus, workers ou serveurs en parallèle, une question surgit : comment garantir qu'une portion de code ne s'exécute pas deux fois en même temps ? Deux requêtes qui débitent le même stock, deux exécutions d'un cron qui se chevauchent, deux workers qui traitent le même message : ce sont des accès concurrents, et sans coordination ils corrompent les données. Le composant Lock fournit cette coordination, à l'échelle d'un serveur comme d'un parc entier.

Le problème : la race condition


Une race condition apparaît quand le résultat dépend de l'ordre, non déterministe, dans lequel des exécutions parallèles s'entrelacent. L'exemple classique est le décrément de stock :

$product = $repository->find($id);   // stock = 1

if ($product->getStock() > 0) {       // deux requêtes lisent 1 en même temps
    $product->decrement();            // les deux décrémentent
    $em->flush();                     // stock = 0, mais deux commandes validées
}

Les deux requêtes ont lu 1 avant que l'une ait écrit 0 : on vend un article qu'on ne possède pas. Le verrou résout ce problème en garantissant qu'une seule exécution à la fois entre dans la section critique. Les autres attendent ou abandonnent.

Un verrou ne ralentit pas le code « pour rien » : il échange un peu de débit contre une garantie de cohérence. La bonne section critique est la plus petite possible, juste autour de l'opération qui ne tolère pas la concurrence.

Acquérir et relâcher un verrou


Le point d'entrée est la LockFactory, autowirable. On crée un verrou nommé par une clé : tous les processus qui veulent la même exclusion mutuelle utilisent la même clé.

final class CheckoutService
{
    public function __construct(private LockFactory $lockFactory) {}

    public function purchase(Product $product): void
    {
        // La clé identifie la ressource protégée, ici un produit précis
        $lock = $this->lockFactory->createLock('product-stock-' . $product->getId());

        if (!$lock->acquire()) {
            throw new ConflictException('Opération déjà en cours, réessayez.');
        }

        try {
            // Section critique : un seul processus à la fois pour ce produit
            $this->decrementStock($product);
        } finally {
            $lock->release(); // toujours relâcher, même en cas d'exception
        }
    }
}

Deux points cruciaux :

  • La libération doit être dans un finally. Un verrou jamais relâché bloque tous les autres processus jusqu'à son expiration.
  • La clé doit identifier la ressource, pas l'action. Verrouiller product-stock-42 permet à deux produits différents d'être traités en parallèle, là où une clé globale checkout sérialiserait inutilement tout le monde.

Bloquant ou non bloquant


acquire() est non bloquant par défaut : il renvoie immédiatement true (verrou obtenu) ou false (déjà pris). C'est le bon choix quand on préfère échouer vite et renvoyer une erreur plutôt que faire patienter l'utilisateur.

Pour attendre que le verrou se libère, on passe en mode bloquant :

$lock->acquire(true); // attend jusqu'à obtenir le verrou

À réserver aux traitements de fond où l'attente est acceptable. Dans un contexte HTTP, le mode non bloquant est presque toujours préférable : mieux vaut répondre « réessayez » que laisser une requête se figer.

L'expiration : le verrou qui se relâche tout seul


Que se passe-t-il si le processus qui détient le verrou plante (segfault, kill, panne réseau) avant d'avoir relâché ? Sans garde-fou, le verrou resterait éternellement, bloquant tout. La parade est le TTL : un verrou peut expirer automatiquement après une durée.

$lock = $this->lockFactory->createLock('import-catalog', ttl: 300); // 5 minutes

Cela introduit un compromis délicat :

  • TTL trop court : le verrou expire alors que le traitement légitime tourne encore, et un second processus démarre en parallèle. La protection saute.
  • TTL trop long : après un crash, la ressource reste bloquée longtemps.

Pour les traitements dont la durée est imprévisible, on rafraîchit le verrou périodiquement plutôt que de miser sur un TTL généreux :

$lock = $this->lockFactory->createLock('long-job', ttl: 30);
$lock->acquire();

foreach ($batches as $batch) {
    $this->process($batch);
    $lock->refresh(); // repousse l'expiration tant que le travail avance
}

Tant que le processus vit, il rafraîchit ; s'il meurt, plus de rafraîchissement et le verrou expire de lui-même après le TTL. C'est le pattern robuste pour les jobs longs.

Les stores : la portée du verrou


Ce qui rend Lock puissant, c'est l'abstraction du store, le backend qui matérialise le verrou. Le store détermine la portée de l'exclusion.

StorePortéeUsage
FlockStoreUn seul serveur (verrou fichier)Mono-serveur, défaut local
SemaphoreStoreUn seul serveur (sémaphore système)Mono-serveur, performant
RedisStoreDistribuéePlusieurs serveurs partageant un Redis
DoctrineDbalStoreDistribuéeVerrous en base, sans infra supplémentaire
ZookeeperStoreDistribuéeCoordination de cluster

La configuration se fait par DSN, et le code applicatif reste identique quel que soit le store :

# config/packages/lock.yaml
framework:
  lock:
    default: '%env(LOCK_DSN)%'   # flock en local, redis en production

C'est le point essentiel : un verrou flock sur fichier ne coordonne que les processus d'une seule machine. Dès qu'on déploie sur plusieurs serveurs derrière un load balancer, il faut un store distribué (Redis, base de données), sinon chaque machine verrouille dans son coin et la concurrence inter-serveurs n'est pas protégée.

Cas d'usage : empêcher le chevauchement d'une commande


L'application la plus fréquente est d'éviter qu'un cron lancé toutes les minutes ne se chevauche si une exécution dure plus longtemps que prévu :

#[AsCommand(name: 'app:sync-inventory')]
final class SyncInventoryCommand extends Command
{
    public function __construct(private LockFactory $lockFactory)
    {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $lock = $this->lockFactory->createLock('app-sync-inventory');

        if (!$lock->acquire()) {
            $output->writeln('Une synchronisation est déjà en cours, on passe.');
            return Command::SUCCESS;
        }

        try {
            $this->synchronize();
        } finally {
            $lock->release();
        }

        return Command::SUCCESS;
    }
}

Le même pattern protège un handler Messenger contre le double traitement d'un message, ou un endpoint contre les double-clics et les soumissions multiples.

Verrous partagés : lecteurs et rédacteurs


Pour les cas où plusieurs lecteurs peuvent cohabiter mais un rédacteur exige l'exclusivité, le composant propose le verrou partagé (read/write). Plusieurs acquisitions en lecture sont autorisées simultanément, mais l'acquisition en écriture attend que tous les lecteurs aient relâché.

$lock = $this->lockFactory->createLock('report-generation');

$lock->acquireRead();  // plusieurs lecteurs en parallèle
// ... lecture
$lock->release();

Tous les stores ne supportent pas ce mode ; le RedisStore et le FlockStore, oui. C'est utile pour protéger une ressource lue souvent et écrite rarement, sans sérialiser inutilement les lectures.

Résumé


NotionÀ retenir
Race conditionCorruption due à des accès concurrents non coordonnés
LockFactory / cléLa clé identifie la ressource protégée, pas l'action
acquire()Non bloquant par défaut, acquire(true) pour attendre
TTL et refresh()Le verrou expire seul après un crash ; on rafraîchit les jobs longs
Storesflock est local, Redis/Doctrine sont distribués
Verrou partagéPlusieurs lecteurs, un seul rédacteur

Le composant Lock règle une classe entière de bugs sournois, ceux qui n'apparaissent qu'en charge ou en parallèle et sont quasi impossibles à reproduire. La seule erreur structurante à éviter est le choix du store : un verrou local sur une infrastructure multi-serveurs donne une fausse impression de sécurité.