Synchronisation distribuée en C# avec Azure Storage

François Hyvrier
YounitedTech
Published in
5 min readNov 4, 2019
La gare centrale de Francfort : “Comment synchroniser les accès aux voies pour éviter l’apocalypse ?”

Beaucoup de langages proposent des mécanismes de lock pour interdire ou limiter les accès simultanés à une même ressource. En C#, on trouve l’instruction lock et des classes telles que Monitor, Mutex, Semaphore, et bien d’autres dans le namespace System.Threading.

Cependant ces outils ne peuvent généralement être appliqués que dans le contexte d’une application multi-threadée ou bien de plusieurs applications exécutées sur la même machine. C’est le cas de la classe Mutex du framework .NET, qui permet de synchroniser plusieurs threads d’une même application ou plusieurs applications exécutées par le même système d’exploitation.

Il n’existe pas de solution prête à l’emploi lorsque les applications à synchroniser sont exécutées sur des machines différentes, il faut se reposer sur les fonctionnalités d’autres produits, comme par exemple Azure Storage.

Azure Storage est un ensemble de services de stockage proposés par Microsoft. Parmi eux, le service Azure Blobs permet de stocker des fichiers texte ou binaires et de leur appliquer des opérations élémentaires (création, modification, suppression, copie, etc.).

L’opération « Lease Blob » (obtenir un bail sur un blob) sert notamment à acquérir un accès exclusif à un fichier pour empêcher qu’il soit modifié ou supprimé par plusieurs auteurs en même temps. Avant de modifier un fichier, un auteur doit donc appeler l’opération « Lease Blob » pour obtenir un accès. Si le fichier est déjà accédé par quelqu’un d’autre l’opération « Lease Blob » lèvera une exception avec le statut « Conflict », mais si ce n’est pas le cas elle rendra la main à l’appelant qui sera alors le seul à pouvoir modifier le fichier jusqu’à ce qu’il invoque l’opération « Release lease », ou bien jusqu’à l’expiration de la durée d’accès exclusif, ou encore jusqu’à ce que quelqu’un invoque l’opération « Break lease ».

C’est ce mécanisme de bail que nous allons utiliser pour implémenter un lock distribué. Le principe est très simple : un lock = un blob (un fichier).

Interface du lock distribué

Les méthodes exposées par le lock distribué sont basiques :

  • AcquireAsync permet d’obtenir le lock et de le conserver pour une durée donnée. Cette méthode attend aussi un paramètre « timeout », il s’agit de la durée maximale que l’on souhaite attendre la libération du lock lorsqu’il est déjà pris par quelqu’un d’autre. Si l’accès n’a pas pu être obtenu au-delà de ce délai, une TimeoutException sera levée, charge à l’appelant de la catcher pour éventuellement tenter à nouveau d’obtenir le lock.
  • ReleaseAsync sert à libérer le lock.

Voici un exemple d’utilisation de ce lock :

Un exemple d’implémentation

Sous le capot, nous allons avoir besoin d’un blob, et donc d’un compte Azure Storage et d’un conteneur où sera localisé ce blob. C’est pourquoi la classe AzureStorageBlobDistributedLock implémentant l’interface IDistributedLock attend en paramètre de son constructeur la chaîne de connexion au compte de stockage, le nom du conteneur, et également le nom du blob. Au passage, il faudra rajouter une dépendance au package NuGet WindowsAzure.Storage.

Dans cette implémentation, c’est la classe AzureStorageBlobDistributedLock qui sera responsable de créer le conteneur et le blob s’ils n’existent pas déjà, à l’aide de la méthode GetBlockBlobReferenceAsync :

L’obtention d’un bail sur un blob se fait grâce à la méthode AcquireLeaseAsync, mais comme elle lève une exception lorsqu’il existe déjà un bail pour le blob, nous devons gérer cette exception pour éviter que notre programme plante. Nous allons mettre en place un mécanisme de retry : l’idée est qu’en cas d’exception, le programme attende un certain temps (quelques centaines de millisecondes), et essaye à nouveau d’acquérir le bail. Si au-delà de la durée « timeout » le bail n’a pas pu être obtenu, la méthode lève une TimeoutException. Ce comportement s’implémente sans difficulté avec un try/catch, mais ce cas est un bon prétexte pour utiliser la bibliothèque Polly, spécialisée dans la gestion des erreurs.

Polly propose des « policies » représentant chacune un comportement différent à appliquer en cas de problème. Nous allons utiliser ici les policies de type « Retry » et « Timeout » :

  • La policy Retry nous servira à intercepter les exceptions indiquant que le bail du blob n’a pas pu être obtenu et à retenter l’opération quelques millisecondes plus tard.
  • La policy Timeout permettra de limiter dans le temps le mécanisme de retry afin de ne pas attendre indéfiniment la libération d’un verrou, cela permettra de rendre la main au code appelant en levant une TimeoutException.

Ainsi on a défini en une dizaine de lignes de codes :

  • Une politique de retry qui ne s’applique qu’en cas de StorageException avec un statut « Conflict ». Elle fera en sorte d’attendre un temps aléatoire compris entre une et deux secondes entre deux tentatives.
  • Une politique de timeout qui englobe la politique de retry. Si jamais la politique de retry est toujours en train de boucler après 30 secondes, la politique de timeout la terminera et lèvera une TimeoutRejectedException (une exception spécifique au package Polly).

L’implémentation de la méthode ReleaseAsync consiste seulement à appeler la méthode ReleaseLeaseAsync du blob en lui passant le lease id obtenu lors de l’acquisition du lock.

Voici la classe AzureStorageBlobDistributedLock dans son intégralité :

Avec cette classe on peut maintenant synchroniser simplement des processus exécutés par des systèmes différents, il suffit qu’ils aient tous configuré la classe AzureStorageBlobDistributedLock de la même façon (mêmes connection string, container name et blob name).

Remarques

La fonctionnalité « Lease blob » d’Azure Blobs peut acquérir un bail pour une durée infinie ou bien comprise entre 15 et 60 secondes. Pour conserver un bail plus longtemps il faut faire appel à l’opération « Renew lease ».

La classe AzureStorageBlobDistributedLock présentée ici ne permet pas d’acquérir des accès exclusifs plus longs que 60 secondes, cela signifie que le traitement à effectuer une fois l’accès exclusif obtenu ne doit pas durer plus de 60 secondes non plus, car au-delà de cette durée, le verrou aura expiré et un autre thread aura pu entrer dans la section critique. Une solution à cela consiste à borner le temps d’exécution de la logique de la section critique, par exemple à l’aide d’une policy de type Timeout du package NuGet Polly.

Si jamais vous avez besoin d’obtenir des locks pour des durées de plus d’une minute, il faudra adapter la classe AzureStorageBlobDistributedLock, voici quelques pistes :

  • Au lieu d’obtenir un bail sur le blob pour une durée finie, demander un bail d’une durée infinie. Attention : en faisant cela on prend le risque qu’un lock distribué ne soit jamais libéré, cela peut se produire si l’application s’arrête avant d’avoir pu mettre fin au bail du blob.
  • Le fait de donner une durée limitée aux baux des blobs est intéressant car il permet d’éviter des situations de deadlock causées par des blobs verrouillés indéfiniment. Une autre solution plus sûre pourrait donc consister à modifier la classe AzureStorageBlobDistributedLock pour qu’elle continue à prendre un bail d’une durée finie, mais le renouvelle automatiquement avant qu’il n’expire, tant que la méthode ReleaseAsync n’a pas été appelée. De cette façon, si jamais l’application se termine brutalement, le bail du blob expirerait au bout d’un certain temps. L’article Veyo Technology: Using an Azure Lease Blob as a Distributed Mutex présente une implémentation avec renouvellement de bail à l’aide d’un timer.

Quelques liens sur ce sujet :

--

--