python - stop - threading get_ident()
¿Por qué Python threading.Condition() notification() requiere un bloqueo? (5)
Lo que sucede es que T1 espera y libera el bloqueo, luego T2 lo adquiere y notifica al CV que activa T1.
No exactamente. La llamada cv.notify()
no despierta el hilo T1: solo lo mueve a una cola diferente. Antes de notify()
, T1 esperaba que la condición se cumpliera. Después de notify()
, T1 está esperando para adquirir el bloqueo. T2 no libera el bloqueo y T1 no se "reactiva" hasta que T2 llama explícitamente a cv.release()
.
Mi pregunta se refiere específicamente a por qué se diseñó de esa manera, debido a la implicación innecesaria del rendimiento.
Cuando el hilo T1 tiene este código:
cv.acquire()
cv.wait()
cv.release()
y el hilo T2 tiene este código:
cv.acquire()
cv.notify() # requires that lock be held
cv.release()
lo que sucede es que T1 espera y libera el bloqueo, luego T2 lo adquiere y notifica al cv
que activa T1. Ahora, hay una condición de carrera entre la liberación de T2 y la readmisión de T1 después de regresar de wait()
. Si T1 intenta volver a adquirir primero, se volverá a suspender innecesariamente hasta que se complete la release()
T2.
Nota: no uso intencionalmente la instrucción with
, para ilustrar mejor la carrera con llamadas explícitas.
Esto parece un defecto de diseño. ¿Hay alguna razón conocida para esto, o me estoy perdiendo algo?
Esta no es una respuesta definitiva, pero se supone que cubre los detalles relevantes que he logrado reunir sobre este problema.
Primero, la implementación de subprocesos de Python se basa en Java . La documentación de Condition.signal()
Java lee:
Una implementación puede (y normalmente requiere) que el subproceso actual mantenga el bloqueo asociado con esta condición cuando se llama a este método.
Ahora, la pregunta era por qué imponer este comportamiento en Python en particular. Pero primero quiero cubrir los pros y los contras de cada enfoque.
En cuanto a por qué algunos piensan que a menudo es mejor mantener el bloqueo, encontré dos argumentos principales:
Desde el momento en que un camarero
acquire()
el bloqueo, es decir, antes de soltarlo enwait()
, se garantiza que se le notificarán las señales. Si larelease()
correspondienterelease()
ocurriera antes de la señalización, esto permitiría la secuencia (donde P = Producer y C = Consumer )P: release(); C: acquire(); P: notify(); C: wait()
P: release(); C: acquire(); P: notify(); C: wait()
P: release(); C: acquire(); P: notify(); C: wait()
en cuyo caso lawait()
correspondiente a laacquire()
del mismo flujo perdería la señal. Hay casos en los que esto no importa (e incluso podría considerarse más preciso), pero hay casos en los que eso no es deseable. Este es un argumento.Cuando
notify()
fuera de un bloqueo, esto puede provocar una inversión de prioridad de programación; es decir, un subproceso de baja prioridad podría terminar teniendo prioridad sobre un subproceso de alta prioridad. Considere una cola de trabajo con un productor y dos consumidores ( LC = consumidor de prioridad baja y HC = consumidor de prioridad alta ), donde LC está ejecutando un elemento de trabajo y HC está bloqueado enwait()
.
La siguiente secuencia puede ocurrir:
P LC HC
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
execute(item) (in wait())
lock()
wq.push(item)
release()
acquire()
item = wq.pop()
release();
notify()
(wake-up)
while (wq.empty())
wait();
Mientras que si se notify()
antes de la release()
, LC no habría podido acquire()
antes de que HC se despertara. Aquí es donde se produjo la inversión de prioridad. Este es el segundo argumento.
El argumento a favor de notificar fuera del bloqueo es para el subproceso de alto rendimiento, donde un subproceso no tiene que volver a dormir solo para despertarse de nuevo la siguiente franja de tiempo que recibe, lo que ya se explicó cómo podría suceder en mi pregunta.
Módulo de threading
de Python
En Python, como dije, debe mantener el bloqueo mientras se lo notifica. La ironía es que la implementación interna no permite que el sistema operativo subyacente evite la inversión de prioridad, ya que impone una orden FIFO en los meseros. Por supuesto, el hecho de que el orden de los camareros sea determinista podría ser útil, pero la pregunta sigue siendo por qué se debe hacer cumplir tal cosa cuando se podría argumentar que sería más preciso diferenciar entre el bloqueo y la variable de condición, para eso en algunos flujos que requieren una concurrencia optimizada y un bloqueo mínimo, acquire()
no debe registrar por sí mismo un estado de espera precedente, sino solo la llamada wait()
sí.
Podría decirse que a los programadores de Python no les importaría el rendimiento en esta medida de todos modos, aunque eso todavía no responde a la pregunta de por qué, cuando se implementa una biblioteca estándar, no se debe permitir varios comportamientos estándar.
Una cosa que queda por decir es que los desarrolladores del módulo de threading
podrían haber deseado específicamente un pedido FIFO por alguna razón, y descubrieron que de alguna manera esta era la mejor forma de lograrlo, y querían establecerlo como una Condition
a expensas. de los otros enfoques (probablemente más prevalentes). Por esto, merecen el beneficio de la duda hasta que ellos mismos puedan dar cuenta de ello.
Hace un par de meses se me ocurrió exactamente la misma pregunta. Pero ya que tenía ipython
abierto, mirando el threading.Condition.wait??
ipython
threading.Condition.wait??
el resultado (la source del método) no tardó en responderlo yo mismo.
En resumen, el método de wait
crea otro bloqueo llamado camarero, lo adquiere, lo agrega a una lista y luego, por sorpresa, libera el bloqueo en sí mismo. Después de eso, adquiere al camarero una vez más, es decir, comienza a esperar hasta que alguien lo suelte. Luego vuelve a adquirir el bloqueo sobre sí mismo y vuelve.
El método de notify
saca a un camarero de la lista de camareros (el camarero es un candado, como recordamos) y lo libera permitiendo que el método de wait
correspondiente continúe.
El truco es que el método de wait
no es mantener el bloqueo en la misma condición mientras se espera que el método de notify
libere al camarero.
UPD1 : Parece que he entendido mal la pregunta. ¿Es correcto que le moleste que T1 intente volver a adquirir el bloqueo antes de que T2 lo libere?
Pero, ¿es posible en el contexto de GIL de python? ¿O cree que se puede insertar una llamada de IO antes de liberar la condición, lo que permitiría que T1 se despierte y espere para siempre?
Hay varias razones que son convincentes (cuando se toman juntas).
1. El notificador debe llevar un candado.
Haga de cuenta que Condition.notifyUnlocked()
existe.
El arreglo estándar de productor / consumidor requiere tomar cerraduras en ambos lados:
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)
Esto falla porque tanto el push()
como el notifyUnlocked()
pueden intervenir entre el if qu:
y el wait()
.
Escribiendo cualquiera 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()
Obras (que es un ejercicio interesante para demostrar). La segunda forma tiene la ventaja de eliminar el requisito de ser seguro para subprocesos, pero no cuesta más bloqueos para responder a la llamada a notify()
también .
Queda por explicar la preferencia por hacerlo, especialmente dado que (como observó) CPython activa el hilo notificado para que cambie a espera en el mutex (en lugar de simplemente moverlo a esa cola de espera ).
2. La variable de condición necesita un candado.
La Condition
tiene datos internos que deben protegerse en caso de esperas / notificaciones concurrentes. (Echando un vistazo a la implementación de CPython , veo la posibilidad de que dos notify()
no sincronizados notify()
s notify()
puedan dirigirse erróneamente al mismo hilo en espera, lo que podría causar un rendimiento reducido o incluso un interbloqueo). Por supuesto, podría proteger esos datos con un bloqueo dedicado. Dado que ya necesitamos un bloqueo visible para el usuario, usarlo evita los costos de sincronización adicionales.
3. Múltiples condiciones de vigilia pueden necesitar la cerradura.
(Adaptado de un comentario en la publicación del blog vinculado a continuación).
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()
Supongamos que box.val
es False
y el subproceso # 1 está esperando en waitFor(box,True,cv)
. Thread # 2 llama setSignal
; cuando libera cv
, el # 1 todavía está bloqueado en la condición. El subproceso # 3 llama a waitFor(box,False,cv)
, encuentra que box.val
es True
y espera. Luego, las llamadas # 2 notify()
, se activan # 3, que aún no están satisfechas y se bloquean nuevamente. Ahora, el número 1 y el número 3 están esperando, a pesar del hecho de que uno de ellos debe cumplir su condición.
def setTrue(box,cv):
with cv:
if not box.val:
box.val=True
cv.notify()
Ahora esa situación no puede surgir: el # 3 llega antes de la actualización y nunca espera, o llega durante o después de la actualización y aún no ha esperado, garantizando que la notificación pase al # 1, que regresa de waitFor
.
4. El hardware puede necesitar un bloqueo
Con el modo de espera en espera y sin GIL (en alguna implementación alternativa o futura de Python), el orden de memoria ( consulte las reglas de Java ) impuesto por la liberación de bloqueo después de notify()
y la adquisición de bloqueo en el retorno de wait()
puede ser el solo se garantiza que las actualizaciones del hilo de notificación sean visibles para el hilo en espera.
5. Los sistemas en tiempo real pueden necesitarlo.
Inmediatamente después del texto POSIX que citó find :
sin embargo, si se requiere un comportamiento de programación predecible, entonces ese mutex estará bloqueado por el subproceso que llama a pthread_cond_broadcast () o pthread_cond_signal ().
Una de las publicaciones del blog contiene una discusión más detallada sobre las razones y el historial de esta recomendación (así como sobre algunos de los otros temas aquí).
No hay condición de carrera, así es como funcionan las variables de condición.
Cuando se llama a wait (), se libera el bloqueo subyacente hasta que se produce una notificación. Se garantiza que la persona que llama en espera volverá a adquirir el bloqueo antes de que se devuelva la función (por ejemplo, después de que finalice la espera).
Tienes razón en que podría haber alguna ineficiencia si T1 se despertara directamente cuando se llama a Notify (). Sin embargo, las variables de condición generalmente se implementan a través de primitivas del sistema operativo, y el sistema operativo a menudo será lo suficientemente inteligente como para darse cuenta de que T2 todavía tiene el bloqueo, por lo que no se activará inmediatamente T1 sino que se pondrá en cola para que se active.
Además, en Python, esto no importa de todos modos, ya que solo hay un único hilo debido a la GIL, por lo que los hilos no podrían ejecutarse de manera concurrente.
Además, se prefiere utilizar los siguientes formularios en lugar de llamar a adquirir / liberar directamente:
with cv:
cv.wait()
Y:
with cv:
cv.notify()
Esto garantiza que el bloqueo subyacente se libere incluso si se produce una excepción.