web-dev-qa-db-fra.com

Pourquoi Python threading.Condition () notify () nécessite-t-il un verrou?

Ma question se réfère spécifiquement à la raison pour laquelle il a été conçu de cette manière, en raison de l'implication inutile des performances.

Lorsque le thread T1 a ce code:

cv.acquire()
cv.wait()
cv.release()

et le thread T2 a ce code:

cv.acquire()
cv.notify()  # requires that lock be held
cv.release()

ce qui se passe, c'est que T1 attend et relâche le verrou, puis T2 l'acquiert, notifie cv qui réveille T1. Maintenant, il y a une condition de concurrence entre la libération de T2 et la réacquisition de T1 après le retour de wait(). Si T1 essaie de réacquérir en premier, il sera suspendu inutilement jusqu'à ce que la release() de T2 soit terminée.

Note: Je n'utilise pas intentionnellement l'instruction with, pour mieux illustrer la course avec des appels explicites.

Cela ressemble à un défaut de conception. Y a-t-il une justification connue à cela, ou manque-t-il quelque chose?

26
Yam Marcovic

Ce n'est pas une réponse définitive, mais elle est censée couvrir les détails pertinents que j'ai réussi à recueillir sur ce problème.

Tout d'abord, Python l'implémentation du threading est basée sur Java . La documentation Condition.signal() de Java se lit comme suit:

Une implémentation peut (et nécessite généralement) que le thread en cours détienne le verrou associé à cette condition lorsque cette méthode est appelée.

Maintenant, la question était de savoir pourquoi appliquer ce comportement dans Python en particulier. Mais d'abord je veux couvrir les avantages et inconvénients de chaque approche.

Quant à savoir pourquoi certains pensent qu'il est souvent préférable de maintenir le verrou, j'ai trouvé deux arguments principaux:

  1. Dès la minute où un serveur acquire() s verrouille, c'est-à-dire avant de le relâcher sur wait(), il est garanti d'être averti des signaux. Si la release() correspondante s'est produite avant la signalisation, cela permettrait la séquence (où P = producteur et C = Consumer ) P: release(); C: acquire(); P: notify(); C: wait() auquel cas la wait() correspondant à acquire() du même flux manquerait le signal. Il y a des cas où cela n'a pas d'importance (et pourrait même être considéré comme plus précis), mais il y a des cas où ce n'est pas souhaitable. Ceci est un argument.

  2. Lorsque vous notify() en dehors d'un verrou, cela peut provoquer une inversion de priorité de planification; c'est-à-dire qu'un thread de faible priorité peut finir par avoir la priorité sur un thread de haute priorité. Considérons une file d'attente de travail avec un producteur et deux consommateurs ( LC = consommateur à faible priorité et HC = haute priorité consommateur ), où [~ # ~] lc [~ # ~] exécute actuellement un élément de travail et [~ # ~] hc [~ # ~] est bloqué dans wait().

La séquence suivante peut se produire:

P                    LC                    HC
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                     execute(item)                   (in wait())
lock()                                  
wq.Push(item)
release()
                     acquire()
                     item = wq.pop()
                     release();
notify()
                                                     (wake-up)
                                                     while (wq.empty())
                                                       wait();

Alors que si la notify() s'est produite avant release(), [~ # ~] lc [~ # ~] ne serait pas 'ai pas pu acquire() avant [~ # ~] hc [~ # ~] avait été réveillé. C'est là que l'inversion de priorité s'est produite. Ceci est le deuxième argument.

L'argument en faveur de la notification en dehors du verrou est pour le threading haute performance, où un thread n'a pas besoin de se rendormir juste pour se réveiller à nouveau la prochaine tranche de temps qu'il obtient - ce qui a déjà été expliqué comment cela pourrait se produire dans ma question.

Module threading de Python

En Python, comme je l'ai dit, vous devez tenir le verrou tout en notifiant. L'ironie est que l'implémentation interne ne permet pas au système d'exploitation sous-jacent d'éviter l'inversion de priorité, car elle impose un ordre FIFO aux serveurs. Bien sûr, le fait que l'ordre des serveurs soit déterministe pourrait utile, mais la question reste de savoir pourquoi appliquer une telle chose alors qu'on pourrait faire valoir qu'il serait plus précis de faire la différence entre le verrou et la variable de condition, pour cela dans certains flux qui nécessitent une concurrence optimisée et un blocage minimal, acquire() ne doit pas à lui seul enregistrer un état d'attente précédent, mais uniquement l'appel wait() lui-même.

On peut dire que Python ne se soucieraient pas des performances dans cette mesure de toute façon - bien que cela ne réponde toujours pas à la question de savoir pourquoi, lors de l'implémentation d'une bibliothèque standard, il ne faut pas autoriser plusieurs comportements standard à être possible.

Une chose qui reste à dire est que les développeurs du module threading auraient pu spécifiquement vouloir un ordre FIFO pour une raison quelconque, et ont trouvé que c'était en quelque sorte la meilleure façon de pour y parvenir, et a voulu établir cela en tant que Condition au détriment des autres approches (probablement plus répandues) .Pour cela, elles méritent le bénéfice du doute jusqu'à ce qu'elles puissent en rendre compte elles-mêmes.

5
Yam Marcovic

Il y a plusieurs raisons qui sont convaincantes (prises ensemble).

1. Le notifiant doit prendre un verrou

Imaginez que Condition.notifyUnlocked() existe.

L'arrangement standard producteur/consommateur nécessite de prendre des verrous des deux côtés:

def unlocked(qu,cv):  # qu is a thread-safe queue
  qu.Push(make_stuff())
  cv.notifyUnlocked()
def consume(qu,cv):
  with cv:
    while True:       # vs. other consumers or spurious wakeups
      if qu: break
      cv.wait()
    x=qu.pop()
  use_stuff(x)

Cela échoue car à la fois la Push() et la notifyUnlocked() peuvent intervenir entre la if qu: Et la wait().

Écriture soit de

def lockedNotify(qu,cv):
  qu.Push(make_stuff())
  with cv: cv.notify()
def lockedPush(qu,cv):
  x=make_stuff()      # don't hold the lock here
  with cv: qu.Push(x)
  cv.notifyUnlocked()

(ce qui est un exercice intéressant à démontrer). Le deuxième formulaire a l'avantage de supprimer l'exigence que qu soit thread-safe, mais il ne coûte plus de verrous pour le contourner l'appel à notify() également .

Il reste à expliquer la préférence pour le faire, d'autant plus que (comme vous l'avez observé) CPython réveille le thread notifié pour le faire passer en attente sur le mutex (plutôt que simplement le déplacer vers cette file d'attente ).

2. La variable de condition elle-même a besoin d'un verrou

Condition possède des données internes qui doivent être protégées en cas d'attentes/notifications simultanées. (En jetant un œil à l'implémentation de CPython , je vois la possibilité que deux notify() non synchronisées pourraient cibler par erreur le même thread en attente, ce qui pourrait entraîner une réduction du débit ou même un blocage.) les données avec un verrou dédié, bien sûr; puisque nous avons déjà besoin d'un verrou visible par l'utilisateur, son utilisation évite des coûts de synchronisation supplémentaires.

3. Plusieurs conditions de réveil peuvent nécessiter le verrouillage

(Adapté d'un commentaire sur le blog ci-dessous.)

def setSignal(box,cv):
  signal=False
  with cv:
    if not box.val:
      box.val=True
      signal=True
  if signal: cv.notifyUnlocked()
def waitFor(box,v,cv):
  v=bool(v)   # to use ==
  while True:
    with cv:
      if box.val==v: break
      cv.wait()

Supposons que box.val Est False et que le thread # 1 attend dans waitFor(box,True,cv). Thread # 2 calls setSignal; quand il libère cv, # 1 est toujours bloqué sur la condition. Le thread # 3 appelle ensuite waitFor(box,False,cv), trouve que box.val Est True et attend. Puis # 2 appelle notify(), réveillant # 3, qui n'est toujours pas satisfait et bloque à nouveau. Maintenant, les n ° 1 et n ° 3 attendent tous les deux, malgré le fait que l'un d'eux doive être satisfait.

def setTrue(box,cv):
  with cv:
    if not box.val:
      box.val=True
      cv.notify()

Maintenant, cette situation ne peut pas se produire: soit le n ° 3 arrive avant la mise à jour et n'attend jamais, soit il arrive pendant ou après la mise à jour et n'a pas encore attendu, garantissant que la notification passe au n ° 1, qui revient de waitFor.

4. Le matériel peut avoir besoin d'un verrou

Avec morphing d'attente et pas de GIL (dans une implémentation alternative ou future de Python), l'ordre de la mémoire ( cf. règles de Java ) imposé par le déverrouillage après notify() et le verrou-acquisition au retour de wait() pourrait être la seule garantie que les mises à jour du thread notifiant soient visibles pour le thread en attente.

5. Les systèmes en temps réel pourraient en avoir besoin

Immédiatement après le texte POSIX vous avez cité nous trouver :

cependant, si un comportement de planification prévisible est requis, ce mutex doit être verrouillé par le thread appelant pthread_cond_broadcast () ou pthread_cond_signal ().

n article de blog contient une discussion plus approfondie de la justification et de l'historique de cette recommandation (ainsi que de certaines des autres questions ici).

2
Davis Herring

Il y a quelques mois exactement, la même question m'est venue à l'esprit. Mais depuis que j'ai ouvert ipython, je regarde threading.Condition.wait?? result (le source pour la méthode) n'a pas mis longtemps à y répondre moi-même.

En bref, la méthode wait crée un autre verrou appelé serveur, l'acquiert, l'ajoute à une liste puis, surprise, libère le verrou sur lui-même. Après cela, il acquiert à nouveau le serveur, c'est-à-dire qu'il commence à attendre que quelqu'un le libère. Ensuite, il acquiert à nouveau le verrou et revient.

La méthode notify extrait un serveur de la liste des serveurs (le serveur est un verrou, comme nous nous en souvenons) et le libère en permettant à la méthode wait correspondante de continuer.

L'astuce est que la méthode wait ne maintient pas le verrou sur la condition elle-même en attendant que la méthode notify libère le serveur.

UPD1 : Je semble avoir mal compris la question. Est-il exact que vous vous inquiétez du fait que T1 puisse essayer de récupérer le verrou sur lui-même avant que le T2 ne le libère?

Mais est-ce possible dans le contexte du GIL de python? Ou vous pensez que l'on peut insérer un appel IO avant de libérer la condition, ce qui permettrait à T1 de se réveiller et d'attendre indéfiniment?

0
newtover

Ce qui se passe, c'est que T1 attend et libère le verrou, puis T2 l'acquiert, notifie cv qui réveille T1.

Pas assez. L'appel cv.notify() ne fait pas wake le thread T1: il le déplace uniquement vers une file d'attente différente. Avant la notify(), T1 attendait que la condition soit vraie. Après la notify(), T1 attend d'acquérir le verrou. T2 ne libère pas le verrou et T1 ne se "réveille" que lorsque T2 appelle explicitement cv.release().

0
Solomon Slow