paint-brush
En route pour les courses : 3 stratégies efficaces pour atténuer les conditions de coursepar@ahasoftware
402 lectures
402 lectures

En route pour les courses : 3 stratégies efficaces pour atténuer les conditions de course

par Aha!7m2023/09/22
Read on Terminal Reader

Trop long; Pour lire

Bien qu’il n’existe pas de panacée pour remédier aux conditions de concurrence, de nombreuses situations peuvent être résolues grâce aux bonnes stratégies. Obtenez des exemples de deux catégories de conditions de concurrence et trois façons de les résoudre.
featured image - En route pour les courses : 3 stratégies efficaces pour atténuer les conditions de course
Aha! HackerNoon profile picture



Qu'est-ce qu'une condition de concurrence ?

J'ai cherché une bonne définition d'une condition de concurrence et voici la meilleure que j'ai trouvée :


Une condition de concurrence critique est un comportement imprévu provoqué par plusieurs processus interagissant avec des ressources partagées dans un ordre différent de celui prévu.


C'est toute une bouchée et on ne sait toujours pas très clairement comment les conditions de course apparaissent dans Rails .


En utilisant Rails, nous travaillons toujours avec plusieurs processus : chaque demande ou tâche en arrière-plan est un processus individuel qui peut fonctionner principalement indépendamment des autres processus.


Nous travaillons également toujours avec des ressources partagées. L'application utilise-t-elle une base de données relationnelle ? C'est une ressource partagée. L'application utilise-t-elle une sorte de serveur de mise en cache ? Oui, c'est une ressource partagée. Utilisez-vous une sorte d’API externe ? Vous l'aurez deviné : c'est une ressource partagée.


Il y a deux exemples de catégories de conditions de course dont j'aimerais parler, puis aborder la manière d'aborder ces conditions.


Lecture-modification-écriture

La catégorie lecture-modification-écriture est un type de condition de concurrence dans laquelle un processus lira les valeurs d'une ressource partagée, modifiera la valeur en mémoire, puis tentera de la réécrire dans la ressource partagée. Cela semble très simple lorsque nous l’examinons sous l’angle d’un seul processus. Mais lorsqu’un deuxième processus survient, il peut entraîner un comportement imprévu.


Considérez un code qui ressemble à ceci :

 class IdeasController < ActionController::Base def vote @idea = Idea.find(params[:id]) @idea.votes += 1 @idea.save! end end

Ici, nous lisons ( Idea.find(params[:id]) ), modifions ( @idea.votes += 1 ), puis écrivons ( @idea.save! ).


On voit que cela augmenterait d’un le nombre de votes sur une idée. S’il y avait une idée avec zéro voix, elle finirait par avoir une voix. Cependant, si une deuxième requête arrivait et lisait l'idée dans la base de données alors qu'elle n'avait encore aucun vote et incrémentait cette valeur en mémoire, nous pourrions avoir une situation où deux votes arrivent simultanément - mais le résultat final est que le nombre de votes dans la base de données, il n’y en a qu’un.


Ceci est également appelé condition de concurrence critique pour les mises à jour perdues .


Vérifier, puis agir

La catégorie vérifier puis agir est un type de condition de concurrence dans laquelle les données sont chargées à partir d'une ressource partagée et, en fonction de la valeur présente, nous déterminons si une action doit être effectuée.

L'un des exemples classiques de la façon dont cela apparaît est la validation validates_uniqueness_of dans Rails, comme ceci :

 class User < ActiveRecord::Base validates_uniqueness_of :email end


Considérez un code qui ressemble à ceci :

 User.create(email: "[email protected]")


Une fois la validation en place, Rails vérifiera s'il existe un utilisateur existant avec cet e-mail. S'il n'y en a pas d'autre, il agira en persistant l'utilisateur dans la base de données. Cependant, que se passerait-il si une deuxième requête exécutait le même code en même temps ? Nous pourrions nous retrouver dans une situation où les deux requêtes vérifient s'il y a des données en double (et il n'y en a pas) - elles agiront alors toutes les deux en enregistrant les données, ce qui entraînerait un utilisateur en double dans la base de données.


Aborder les conditions de course

Il n’existe pas de solution miracle pour remédier aux conditions de concurrence, mais il existe une poignée de stratégies qui peuvent être exploitées pour résoudre un problème particulier. Il existe trois catégories principales pour supprimer les conditions de concurrence :

1. Supprimez la section critique

Bien que cela puisse être considéré comme la suppression du code incriminé, vous pouvez parfois refactoriser le code afin qu'il ne soit pas vulnérable aux conditions de concurrence. D’autres fois, vous pouvez vous pencher sur les opérations atomiques.

Une opération atomique est une opération dans laquelle aucun autre processus ne peut interrompre l'opération. Vous savez donc qu'elle s'exécutera toujours comme une seule unité.


Pour l'exemple lecture-modification-écriture, au lieu d'incrémenter les votes d'idées en mémoire, ils pourraient être incrémentés dans la base de données :

 @ideas.increment!(:votes)


Cela exécutera du SQL qui ressemble à ceci :

 UPDATE "ideas" SET "votes" = COALESCE("votes", 0) + 1 WHERE "ideas"."id" = 123


Son utilisation ne serait pas soumise aux mêmes conditions de concurrence.


Pour l'exemple check-then-act, au lieu de permettre à Rails de valider le modèle, nous pourrions insérer l'enregistrement directement dans la base de données avec un upsert :

 User.where(email: "[email protected]").upsert({}, unique_by: :email)


Cela insérera l'enregistrement dans la base de données. S'il y a un conflit sur l'e-mail (ce qui nécessiterait un index unique sur l'e-mail), il ignorera simplement l'insertion.

2. Détecter et récupérer

Parfois, vous ne pouvez pas supprimer la section critique. Il est possible qu'il y ait une action atomique, mais cela ne fonctionne pas tout à fait comme le code l'exige. Dans ces situations, vous pouvez essayer une approche de détection et de récupération. Avec cette approche, des garanties sont mises en place qui vous informeront si une situation de concurrence critique se produit. Vous pouvez soit abandonner gracieusement, soit réessayer l'opération.


Pour l'exemple lecture-modification-écriture, cela pourrait être fait avec un verrouillage optimiste . Le verrouillage optimiste est intégré à Rails et peut permettre de détecter le moment où plusieurs processus fonctionnent sur le même enregistrement en même temps. Pour activer le verrouillage optimiste, il vous suffit d'ajouter une colonne lock_version à votre table et Rails l'activera automatiquement.

 change_table :ideas do |t| t.integer :lock_version, default: 0 end


Ensuite, lorsque vous essayez de mettre à jour un enregistrement, Rails ne le mettra à jour que si lock_version est la même version qu'elle était en mémoire. Si ce n'est pas le cas, une exception ActiveRecord::StaleObjectError sera déclenchée, qui pourra être récupérée pour la gérer. Le gérer pourrait être une retry ou simplement un message d'erreur signalé à l'utilisateur.

 def vote @idea = Idea.find(params[:id]) @idea.votes += 1 @idea.save! rescue ActiveRecord::StaleObjectError retry end


Pour l'exemple de vérification puis d'action, cela pourrait être fait avec un index unique sur la colonne, puis en récupérant l'exception lors de la persistance des données.

 add_index :users, [:email], unique: true


Avec un index unique en place, si des données existent déjà dans la base de données avec cet email , Rails générera une erreur ActiveRecord::RecordNotUnique et qui pourra être récupérée et traitée de manière appropriée.

 begin user = User.create(email: "[email protected]") rescue ActiveRecord::RecordNotUnique user = User.find_by(email: "[email protected]") end

Idempotence

Afin de réessayer des actions, il est important que l’ensemble de l’opération soit idempotent. Cela signifie que si une opération est effectuée plusieurs fois, le résultat est le même que si elle n’était appliquée qu’une seule fois.


Par exemple, imaginez si une tâche envoyait un e-mail et qu'elle était exécutée chaque fois que les votes d'une idée étaient modifiés. Ce serait vraiment dommage si un e-mail était envoyé à chaque nouvelle tentative. Pour rendre l'opération idempotente, vous pourriez retarder l'envoi de l'e-mail jusqu'à ce que l'ensemble de l'opération de vote soit terminée. Alternativement, vous pouvez mettre à jour la mise en œuvre du processus qui envoie l'e-mail pour envoyer l'e-mail uniquement si les votes ont changé depuis la dernière fois qu'il a été envoyé. Si une condition de concurrence critique se produit et que vous devez réessayer, la première tentative d'envoi d'un e-mail peut entraîner un échec et vous pouvez le déclencher à nouveau en toute sécurité.


De nombreuses opérations peuvent ne pas être idempotentes, comme la mise en file d'attente d'une tâche en arrière-plan, l'envoi d'un e-mail ou l'appel d'une API tierce.

3. Protégez le code

Si vous ne parvenez pas à détecter et à récupérer, vous pouvez essayer de protéger le code. Le but ici est de créer un contrat dans lequel un seul processus à la fois peut accéder à la ressource partagée. En fait, vous supprimez la concurrence : puisqu'un seul processus peut avoir accès à une ressource partagée, nous pouvons éviter la plupart des conditions de concurrence. Le compromis est cependant que plus la concurrence est supprimée, plus l'application peut être lente, car les autres processus attendront jusqu'à ce qu'ils soient autorisés à accéder.


Cela pourrait être géré à l’aide du verrouillage pessimiste intégré à Rails. Pour utiliser le verrouillage pessimiste , vous pouvez ajouter lock aux requêtes en cours de construction, et Rails indiquera à la base de données de verrouiller les lignes de ces enregistrements. La base de données empêchera alors tout autre processus d’obtenir le verrou jusqu’à ce que cela soit fait. Assurez-vous d'envelopper le code dans une transaction afin que la base de données sache quand libérer le verrou.

 Idea.transaction do @idea = Idea.lock.find(params[:id]) @idea.votes += 1 @idea.save! end


Si le verrouillage au niveau des lignes n'est pas possible, d'autres outils tels que Redlock ou with_advisory_lock peuvent être utilisés. Ceux-ci permettront de verrouiller un bloc de code arbitraire. L'utiliser pourrait être aussi simple que quelque chose comme ceci :

 email = "[email protected]" User.with_advisory_lock("user_uniqueness_#{email}"} do User.find_or_create_by(email: email) end


Ces stratégies obligeront les processus à attendre jusqu'à ce qu'un verrou soit obtenu. Ainsi, ils voudront également disposer d’une certaine forme de délai d’attente pour éviter qu’un processus n’attende indéfiniment – ainsi que d’une certaine gestion de ce qu’il faut faire en cas d’expiration.


Bien qu’il n’existe pas de panacée pour remédier aux conditions de concurrence, de nombreuses conditions de concurrence peuvent être résolues grâce à ces stratégies. Cependant, chaque problème est un peu différent, les détails des solutions peuvent donc varier. Vous pouvez jeter un œil à mon exposé de RailsConf 2023 qui entre plus en détail sur les conditions de course.



A propos de l'auteur

Kyle d’Oliveira

Kyle d'Oliveira


Kyle est passionné par la transformation d'idées abstraites en logiciels fonctionnels. Il est ingénieur logiciel principal chez Aha! — le logiciel de développement de produits n°1 au monde . Lorsqu'il ne se développe pas, Kyle profite d'incroyables brasseries gastronomiques et artisanales près de chez lui à Vancouver, au Canada.






Également publié ici.