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.
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 .
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.
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 :
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.
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
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.
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.
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.