Pourquoi Python threading.Condition() notify() nécessitent une serrure?

ma question porte précisément sur la raison pour laquelle il a été conçu de cette façon, en raison de l'incidence inutile sur le rendement.

lorsque le fil T1 porte ce code:

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

et le fil T2 a ce code:

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

ce qui se passe est que T1 attend et libère la serrure, puis T2 l'acquiert, notifie cv qui réveille T1. Maintenant, il y a une condition de race entre la libération de T2 et la réacquisition de T1 après retour de wait() . Si T1 tente de se racheter d'abord, il sera inutilement remis en service jusqu'à ce que T2 release() soit terminé.

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

cela ressemble à un défaut de conception. Y a-t-il une raison connue pour cela, ou est-ce que je manque quelque chose?

21
demandé sur pvg 2017-09-06 16:09:53

5 réponses

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, L'implémentation de threading de Python est basée sur de Java . La documentation de Java Condition.signal() se lit:

une implémentation peut (et requiert typiquement) que le thread courant retienne la serrure associée à cette Condition lorsque cette méthode est appelée.

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

quant à savoir pourquoi certains pensent que c'est souvent une meilleure idée de tenir la serrure, j'ai trouvé deux arguments principaux:

  1. à partir de la minute un serveur acquire() s la serrure-c'est-à-dire, avant de le libérer sur wait() - il est garanti pour être informé de signaux. Si le release() correspondant se produisait avant la signalisation, cela permettrait la séquence(où P=producteur et C=consommateur ) P: release(); C: acquire(); P: notify(); C: wait() , auquel cas le wait() correspondant au 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ù cela n'est pas souhaitable. C'est un argument.

  2. lorsque vous notify() à l'extérieur d'une serrure, cela peut causer une inversion de priorité de programmation; c'est-à-dire qu'un fil à faible priorité pourrait finir par prendre la priorité sur un fil à haute priorité. Envisager une file d'attente avec un producteur et deux consommateurs ( LC=consommateur de faible priorité et HC=consommateur de haute priorité ), où LC exécute actuellement un article 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 le notify() s'était produit avant release() , LC n'aurait pas été en mesure de acquire() avant HC avait été réveillé. C'est là que l'inversion de priorité s'est produite. C'est le deuxième argument.

L'argument en faveur de la notification à l'extérieur de la serrure est pour haute performance filetage, où un fil ne doit pas retourner au sommeil juste pour se réveiller à nouveau la prochaine fois-tranche il obtient-qui a déjà été expliqué comment cela pourrait se produire dans ma question.

Python threading Module

en Python, comme je l'ai dit, vous devez tenir la serrure tout en notifiant. L'ironie est que l'implémentation interne ne permet pas à L'OS sous-jacent d'éviter l'inversion de priorité, parce qu'il fait respecter une ordonnance de FIFO sur les serveurs. Bien sûr, le fait que l'ordre des serveurs soit déterministe pourrait s'avérer pratique, 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 la serrure et la variable de condition, car que dans certains flux qui nécessitent une simultanéité optimisée et un blocage minimal, acquire() ne devrait pas par lui-même enregistrer un État d'attente précédent, mais seulement le wait() s'appeler.

on peut soutenir que les programmeurs Python ne se soucient pas des performances à cet égard de toute façon-bien que cela ne réponde toujours pas à la question de savoir pourquoi, lors de la mise en œuvre d'une bibliothèque standard, on ne devrait pas permettre que plusieurs comportements standard soient possibles.

une chose qui reste à dire est que les développeurs du module threading auraient peut-être voulu spécifiquement une commande FIFO pour quelque raison que ce soit, et ont trouvé que c'était en quelque sorte la meilleure façon d'y parvenir, et voulait établir que comme un Condition au détriment de l'autre (probablement plus répandue) approches. Pour cela, ils méritent le bénéfice du doute jusqu'à ce qu'ils pourraient en compte pour eux-mêmes.

3
répondu Yam Marcovic 2017-09-13 09:40:21

ce qui se passe, C'est que T1 attend et libère la serrure, puis T2 l'acquiert, notifie cv qui réveille T1.

pas tout à fait. L'appel cv.notify() ne wake le fil T1: il le déplace seulement à une file d'attente différente. Avant le notify() , T1 attendait que la condition soit vraie. Après le notify() , T1 attend pour acquérir la serrure. T2 ne libère pas la serrure, et T1 ne se réveille pas" jusqu'à ce que T2 appelle explicitement cv.release() .

0
répondu Solomon Slow 2017-09-06 15:58:50

il y a quelques mois, la même question m'est venue à l'esprit. Mais comme j'avais ipython ouvert, en regardant threading.Condition.wait?? résultat (la source pour la méthode) n'a pas pris longtemps pour répondre moi-même.

en bref, la méthode wait crée une autre serrure appelée serveur, l'acquiert, l'ajoute à une liste et puis, surprise, libère la serrure sur elle-même. Après cela, il acquiert le serveur une fois de plus, c'est qu'il commence à attendre jusqu'à ce que quelqu'un libère le garçon. Puis il reprend la serrure sur lui-même et revient.

la méthode notify détache un serveur de la liste des serveurs (le serveur est une serrure, on s'en souvient) et le libère, ce qui permet à la méthode wait correspondante de continuer.

C'est le truc c'est que la méthode wait ne tient pas la serrure sur la condition elle-même en attendant la méthode notify pour libérer le serveur.

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

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

0
répondu newtover 2017-09-09 19:19:55

il y a plusieurs raisons impérieuses (lorsqu'elles sont considérées ensemble).

1. Le notifiant doit prendre un verrouillage

prétendre que Condition.notifyUnlocked() existe.

l'arrangement standard producteur/consommateur exige de prendre des serrures 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 parce que le push() et le notifyUnlocked() peuvent intervenir entre le if qu: et le wait() .

Écrit 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()

fonctionne (c'est un exercice intéressant à démontrer). La deuxième forme a l'avantage de supprimer l'exigence que qu soit filetage-sûr, mais il ne coûte plus de serrures pour l'emporter autour de l'appel à notify() ainsi que .

il reste à expliquer la préférence pour le faire, d'autant plus que (comme vous l'avez fait remarquer) CPython réveille le fil notifié pour le faire attendre sur le mutex (plutôt que simplement le déplaçant vers cette file d'attente ).

2. La variable de condition elle-même a besoin d'une serrure

le Condition dispose de données internes qui doivent être protégées en cas d'attente/notifications simultanées. (Un coup d'oeil sur la mise en œuvre du CPython , je vois la possibilité que deux notify() non synchronisés puissent cibler par erreur le même fil d'attente, ce qui pourrait causer une réduction du débit ou même un blocage.) Il pourrait bien sûr protéger ces données avec un verrou dédié; puisque nous avons déjà besoin d'un verrou visible par l'utilisateur, l'utilisation de celui-ci permet d'éviter des coûts de synchronisation supplémentaires.

3. Plusieurs conditions de sillage peuvent nécessiter la serrure

(adapté d'un commentaire sur le blog dessous.)

def setTrue(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()

Suppose box.val est False et le fil #1 attend dans waitFor(box,True,cv) . Le fil # 2 appelle setSignal ; quand il libère cv , #1 est toujours bloqué sur la condition. Fil #3 appelle alors waitFor(box,False,cv) , trouve que box.val est True , et attend. Puis #2 appelle notify() , le réveil #3, qui est toujours insatisfait et bloque à nouveau. Maintenant #1 et #3 attendent tous les deux, malgré le fait que l'un d'eux doit avoir sa condition remplie.

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 #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 va au #1, qui retourne de waitFor .

4. Le matériel pourrait avoir besoin d'une serrure

avec wait morphing et no GIL( dans une implémentation alternative ou future de Python), la mémoire de la commande ( cf. règles Java ) imposées par le lock-release après notify() et le lock-acquire au retour de wait() pourrait être la seule garantie que les mises à jour du thread notifiant soient visibles par le thread d'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 programmation prévisible est nécessaire, alors que mutex doit être verrouillé par le thread appelant pthread_cond_broadcast() ou pthread_cond_signal ().

Un blog contient à la suite de la discussion de la justification et de l'histoire de cette recommandation (ainsi que de certaines autres questions ici).

0
répondu Davis Herring 2017-09-15 08:14:52

il n'y a pas de condition de race, c'est ainsi que les variables de condition fonctionnent.

quand wait () est appelé, alors le verrou sous-jacent est libéré jusqu'à ce qu'une notification se produise. Il est garanti que l'appelant d'attente seront réacquérir le verrou avant le retour de fonction (par exemple, après l'attente est terminée).

vous avez raison, il pourrait y avoir une certaine inefficacité si T1 était directement réveillé lorsque notify() est appelé. Cependant, les variables de condition sont généralement mis en œuvre via des primitives OS, et L'OS sera souvent assez intelligent pour réaliser que T2 a toujours le verrou, donc il ne se réveillera pas immédiatement T1, mais plutôt la file D'attente pour être réveillé.

de plus, en python, cela n'a pas vraiment d'importance de toute façon, car il n'y a qu'un seul thread dû à la GIL, donc les threads ne seraient pas en mesure de fonctionner simultanément de toute façon.


de plus, il est préférable d'utiliser les formulaires suivants au lieu de appelant acquire / release directement:

with cv:
    cv.wait()

et:

with cv:
    cv.notify()

ceci garantit que la serrure sous-jacente est libérée même si une exception se produit.

-2
répondu Dustin Spicuzza 2017-09-06 13:52:53