Les accès concurrents et autres types de verrous

Les verrous, kesako?

Les verrous… lorsque j’ai été la première fois confronté aux verrous, il a fallu que je comprenne exactement de quoi il s’agissait. Et la seule notion que l’on m’a présentée, c’étaient les sémaphores, en cours sur linux.

Un sémaphore est une variable (ou un type de donnée abstrait) et constitue la méthode utilisée couramment pour restreindre l’accès à des ressources partagées (par exemple un espace de stockage) et synchroniser les processus dans un environnement de programmation concurrente.

Les sémaphores fournissent la solution la plus courante pour le fameux problème du « dîner des philosophes », bien qu’ils ne permettent pas d’éviter tous les interblocages (ou deadlocks). Pour pouvoir exister sous forme logicielle, ils nécessitent une implémentation matérielle (au niveau du microprocesseur), permettant de tester et modifier la variable protégée au cours d’un cycle insécable. En effet, dans un contexte de multiprogrammation, on ne peut prendre le risque de voir la variable modifiée par un autre processus juste après que le processus courant vient de la tester et avant qu’il ne la modifie.

Les sémaphores sont utiles si vous utilisez un serveur, mais si vous en avez deux ou plus et que vous faites du load balancing ou autre système de partage des ressources, cela ne fonctionnera pas pour vous et vous devrez alors gérer les accès concurrents d’une autre manière (que nous allons aborder)

Les sémaphores en PHP

Très simples à utiliser, l’utilisation des sémaphores en PHP repose sur 4 étapes:

  1. Créer la clef du sémaphore
  2. Initier le sémaphore
  3. L’acquérir
  4. Le relâcher

Créer la clef du sémaphore

La fonction ftok convertit un chemin et un identifiant de projet en une clé System V IPC.

$key = ftok(__FILE__, 's');

Initier le sémaphore

$semaphore = sem_get($key);

Acquérir le sémaphore

Imaginons que nous parcourons une liste d’objets qui doivent être mis à jour, mais qu’un autre processus qui peut potentiellement tourner en même temps puisse vouloir updater les mêmes valeurs en base. Il ne faut pas que ça se produise.

Pour cela, dans notre boucle, chaque objet récupère le sémaphore et fait son traitement.

foreach ($objects as $object) {
   sem_acquire($semaphore);
   $object->setValue1($val1);
   $object->setValue2($val2);
   $object->save();
}

Relâcher le sémaphore

Une fois le traitement effectué, le sémaphore est relâché.

foreach ($objects as $object) {
   sem_acquire($semaphore);
   $object->setValue1($val1);
   $object->setValue2($val2);
   $object->save();
   sem_release($semaphore);
}

C’est tout! Si vous avez plusieurs crons qui tournent en même temps et qui updatent des lignes identiques, le sémaphore empêchera que l’un marche sur les pieds de l’autre.

Les accès concurrents

Là, nous parlons de plusieurs serveurs qui se passent la main, mais interagissent avec la même base de données: site e-commerce, réservation de sa place de cinéma en ligne, etc.

Il existe plusieurs manière de gérer cela: GET_LOCK/RELEASE LOCK, SELECT FOR UPDATE…

Personnellement, j’aime bien le SELECT FOR UPDATE qui s’utilise très simplement en Symfony2.

La mise en place se fait en plusieurs étapes:

  1. Lancement du début d’une transaction
  2. Lancement de la requête SQL qui pose un verrou sur un certain nombre de lignes en écriture (mais la lecture est toujours disponible)
  3. Commit de la transaction, qui relâche le verrou du SELECT FOR UPDATE
  4. Ou rollback si une erreur s’est produite (c’est à dire que toutes les actions effectuées sont rejouées en sens inverse)

Qu’est-ce qu’une transaction?

Une transaction est une unité de travail. Lorsqu’une transaction aboutit, toutes les modifications de données apportées lors de la transaction sont validées et intégrées de façon permanente à la base de données. Si une transaction rencontre des erreurs et doit être annulée ou restaurée, toutes les modifications de données sont annulées.

Lancement du début d’une transaction

$em->getConnection()->beginTransaction();

Requête SQL SELECT FOR UPDATE

En SQL, on écrirait: SELECT * FROM articles WHERE article.date_fin >= curdate() FOR UPDATE, par exemple.

En Symfony2, on écrirait:

$queryBuilder = $this->_em->createQueryBuilder('a')
      ->where('a.dateFin >= :now')
      ->setParameter(':now', new \Datetime())
      ->getQuery()
      ->setLockMode(LockMode::PESSIMISTIC_WRITE)
    ;

return $queryBuiler->getResult();

La ligne ->setLockMode(LockMode::PESSIMISTIC_WRITE)  pose le lock.

Commit de la transaction une fois que le travail est fini

foreach ($objects as $object) {
   $object->setValue1($val1);
   $object->setValue2($val2);
   $object->save();
}

$em->getConnection()->commit();

Avec le rollback

$em->getConnection()->beginTransaction();

try {
   foreach ($objects as $object) {
      $object->setValue1($val1);
      $object->setValue2($val2);
      $object->save();
   }
   $em->getConnection()->commit();

// faire un traitement sur ces objets qui sont à présent lockés

} catch (\Doctrine\ORM\PessimisticLockException $exception) {
   $em->getConnection()->rollback();
   throw $exception;
}

Doctrine2 soutient actuellement deux modes de verrouillage pessimiste:

  • Ecriture pessimiste (Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE), verrouille les lignes de bases de données sous-jacente pour lire et écrire les opérations simultanées.
  • Lecture pessimiste (Doctrine\DBAL\LockMode::PESSIMISTIC_READ), locks les autres verrous de demandes simultanées qui tentent de mettre à jour ou de verrouiller des lignes en mode écriture.

Vous pouvez utiliser les verrous pessimistes dans trois scénarios différents:

  • Using
    • EntityManager#find($className, $id,\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)
    • EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_READ)
  • Using
    • EntityManager#lock($entity, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)
    • EntityManager#lock($entity, \Doctrine\DBAL\LockMode::PESSIMISTIC_READ)
  • Using
    • Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)
    • Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_READ)

Que se passe-t-il lors des accès concurrent?

Le serveur 1 démarre sa transaction.
Le serveur 2 démarre sa transaction.

Le serveur 1 pose le verrou sur des lignes.
Le serveur 2 demande à poser le verrou mais il est bloqué et attend que le verrou SELECT FOR UPDATE soit relâché. Il peut cependant toujours faire des SELECT classiques mais ne voient pas les modifications que l’autre serveur effectue sur ces lignes.

Le serveur 1 update un certain nombre de lignes en base, puis commit.
Le serveur 2 récupère la main et exécute sa requête. Il se peut alors qu’il récupère des lignes identiques s’il devait travailler sur les mêmes lignes, ou des lignes différentes (s’il ne récupérait que les 20 premières commandes d’un site e-commerce dont il devait assurer le renouvellement et donc la date de renouvellement est antérieure à la date actuelle, par exemple).

Ainsi, le premier serveur récupérait par exemple les 20 dernières commandes, les renouvellaient, et le second serveur récupère les 20 commandes suivantes.

Pour optimiser le tout, on peut rajouter un champ « locked ».
Le premier serveur fait un SELECT FOR UPDATE, récupère 20 commandes, set le champ locked à true, puis fait son commit et fait ensuite le reste de son boulot sur ces lignes.
Le second serveur récupère la main et récupère les 20 commandes suivantes dont la date de renouvellement est antérieure à la date actuelle et dont le champ « locked » n’est pas à null. Il ne récupère donc pas les mêmes lignes en base.

Ainsi, les deux serveurs vont se passer la main à tour de rôle, uniquement pour locker des lignes, puis pour relâcher le verrou une fois que c’est fait, pour ensuite faire ce qu’ils veulent avec les lignes récupérées.

Je vous invite également, si cela vous intéresse, à regarder ces fonctions SQL: http://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html

Mise à jour de l’article

Lors d’un projet, l’utilisation de SELECT FOR UPDATE a causé des dead locks quand on utilisait deux serveurs. Cela peut parfois se produire et il existe alors une manière d’éviter cela: le GET_LOCK/RELEASE_LOCK

Très simplement, il s’agit d’une requête SQL native exécutable facilement avec doctrine:

    /**
     * @param $name
     * @param int $timeout
     * @return string 1 if lock obtained or 0 if not
     * @throws ExceptionService if lock not obtained
     */
     public function lock($name, $timeout = 30)
     {
        $query = 'SELECT GET_LOCK(:name, :timeout) as locked';
  
        $result = $this->getManager()->getConnection()->executeQuery(
             $query,
             array(
                 'name' => $name,
                 'timeout' => $timeout,
             )
         );

        $row = $result->fetch();

        if (empty($row['locked'])) {
            throw new ExceptionService('Lock not obtained');
        }
     }


    /**
     * @param $name
     * @return string 1 if lock released or 0 if not
     * @throws ExceptionService if lock not released
     */
     public function unlock($name)
     {
        $query = 'SELECT RELEASE_LOCK(:name) as unlocked';
  
        $result = $this->getManager()->getConnection()->executeQuery(
             $query,
             array(
                 'name' => $name,
             )
         );
        $row = $result->fetch();

        if (empty($row['unlocked'])) {
            throw new ExceptionService('Lock not released');
        }
     }

Et la requête doctrine de récupération d’entités devient:

$queryBuilder = $this->_em->createQueryBuilder('a')
      ->where('a.dateFin >= :now')
      ->setParameter(':now', new \Datetime())
      ->andWhere('a.locked = :locked')
      ->setParameter(':locked', false)
      ->getQuery()
    ;

return $queryBuiler->getResult();

 

Le code donnera donc ça:

$this->repoService->lock('LOCK_ENTITIES', 30);

$this->repoService->getUnlockedObjects();

foreach ($objects as $object) 
{
   $object->setLocked(true);
}
$em->flush();

$this->repoService->unlock('LOCK_ENTITIES');

// faire un traitement sur ces objets.

Que se passe-t-il ici?

  • Le serveur 1 arrive. Il pose un lock de 30 secondes maximum.
  • Le serveur 2 arrive. Il veut poser le même lock mais c’est impossible. Il est donc bloqué et se prend une exception.
  • Il récupère x lignes en bdd d’une entité dont le champs ‘locked’ est à false
  • Il set le champs ‘locked’ à true pour ces objets et les sauvent
  • Une fois que les x objets ont été mis à jour, le lock est relâché
  • Le serveur 2 se présente pendant que le serveur 1 fait un traitement plus ou moins long sur les objets qu’il a récupéré. Il peut poser le lock et récupère les x objets suivants qui ont le champs locked à false
  • Le serveur 1 a fini ce qu’il devait faire avec ses objets et revient poser son lock. Il ne peut l’obtenir et se prend une exception.
  • Le serveur 2 lock les objets, relâche le lock.
  • Et ainsi de suite.
Rédigé par

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.