list :: empty () comportamiento de subprocesos múltiples?

9

Tengo una lista de la que quiero diferentes hilos para tomar elementos. Para evitar bloquear el mutex que protege la lista cuando está vacío, verifico empty()antes de bloquear.

Está bien si la llamada a list::empty()no es correcta el 100% del tiempo. Solo quiero evitar fallar o interrumpir concurrentes list::push()y list::pop()llamadas.

¿Estoy seguro de asumir que VC ++ y Gnu GCC solo a veces se empty()equivocan y nada peor?

if(list.empty() == false){ // unprotected by mutex, okay if incorrect sometimes
    mutex.lock();
    if(list.empty() == false){ // check again while locked to be certain
         element = list.back();
         list.pop_back();
    }
    mutex.unlock();
}
Anne Quinn
fuente
1
No, no puedes asumir esto. Puede usar un contenedor concurrente como concurrent_queue
Panagiotis Kanavos
2
@Fureeish Esto debería ser una respuesta. Agregaría que std::list::sizeha garantizado una complejidad de tiempo constante, lo que básicamente significa que el tamaño (número de nodos) debe almacenarse en una variable separada; Digamos que es size_. std::list::emptyentonces es probable que devuelva algo como size_ == 0, y la lectura y escritura concurrentes size_causarían una carrera de datos, por lo tanto, UB.
Daniel Langr
@DanielLangr ¿Cómo se mide el "tiempo constante"? ¿Está en una llamada de función única o en el programa completo?
curioso
1
@curiousguy: DanielLangr respondió su pregunta "independientemente del número de nodos de la lista", es decir, la definición exacta de O (1), lo que significa que cada llamada se ejecuta en menos de un tiempo constante, independientemente del número de elementos. en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions La otra opción (hasta C ++ 11) sería lineal = O (n), lo que significaría que el tamaño tendría que contar los elementos (lista vinculada), lo que sería aún peor para la concurrencia (carrera de datos más obvia que la lectura / escritura no atómica en el mostrador).
Firda
1
@curiousguy: Tomando su propio ejemplo con dV, la complejidad del tiempo es el mismo límite matemático. Todas estas cosas se definen recursivamente, o en la forma de "Existe C tal que f (N) <C para cada N" - esa es la definición de O (1) (para dado / cada HW existe C constante tal que el algo termina en menos de C-time en cualquier entrada). Amortizado significa en promedio , lo que significa que algunas entradas pueden tardar más en procesarse (por ejemplo, se necesita volver a hacer hash / volver a asignar), pero aún es constante en promedio (suponiendo todas las entradas posibles).
Firda

Respuestas:

10

Está bien si la llamada a list::empty()no es correcta el 100% del tiempo.

No, no esta bien. Si verifica si la lista está vacía fuera de algún mecanismo de sincronización (bloqueando el mutex), entonces tiene una carrera de datos. Tener una carrera de datos significa que tienes un comportamiento indefinido. Tener un comportamiento indefinido significa que ya no podemos razonar sobre el programa y cualquier resultado que obtenga es "correcto".

Si valora su cordura, tomará el golpe de rendimiento y bloqueará el mutex antes de verificar. Dicho esto, es posible que la lista ni siquiera sea el contenedor correcto para usted. Si puede hacernos saber exactamente qué está haciendo con él, podríamos sugerirle un mejor contenedor.

NathanOliver
fuente
Perspectiva personal, la llamada list::empty()es una acción de lectura que no tiene nada que ver conrace-condition
Ngọc Khánh Nguyễn
3
@ NgọcKhánhNguyễn Si están agregando elementos a la lista, entonces definitivamente causa una carrera de datos mientras escribes y lees el tamaño al mismo tiempo.
NathanOliver
66
@ NgọcKhánhNguyễn Eso es falso. Una condición de carrera es read-writeo write-write. Si no me
cree
1
@ NgọcKhánhNguyễn: Debido a que no se garantiza que ni la escritura ni la lectura sean atómicas, por lo tanto, se pueden ejecutar simultáneamente, por lo tanto, la lectura puede tener algo totalmente incorrecto (llamada lectura desgarrada). Imagine que la escritura cambia de 0x00FF a 0x0100 en MCU little endian de 8 bits, comenzando por reescribir bajo 0xFF a 0x00 y la lectura ahora obtiene exactamente ese cero, leyendo ambos bytes (el hilo de escritura se ralentizó o suspendió), la escritura continúa actualizando el byte alto a 0x01, pero el hilo de lectura ya tiene un valor incorrecto (ni 0x00FF ni 0x0100 pero inesperado 0x0000).
Firda
1
@ NgọcKhánhNguyễn Puede que en algunas arquitecturas, pero la máquina virtual C ++ no ofrece tal garantía. Incluso si su hardware lo hiciera, sería legal que el compilador optimice el código de una manera que nunca vería un cambio, ya que a menos que haya sincronización de subprocesos, puede suponer que ejecuta un solo subproceso y optimizar en consecuencia.
NathanOliver
6

Hay una lectura y una escritura (muy probablemente para el sizemiembro de std::list, si suponemos que se llama así) que no están sincronizadas entre sí . Imagina que un hilo llama empty()(en tu exterior if()) mientras que el otro hilo entra en el interior if()y se ejecuta pop_back(). Entonces está leyendo una variable que posiblemente se está modificando. Este es un comportamiento indefinido.

Fureeish
fuente
2

Como ejemplo de cómo las cosas podrían salir mal:

Un compilador suficientemente inteligente podría ver que mutex.lock()no es posible cambiar el list.empty()valor de retorno y, por lo tanto, omitir la ifcomprobación interna por completo, lo que finalmente lleva a pop_backuna lista que eliminó su último elemento después del primero if.

¿Por qué puede hacer eso? No hay sincronización list.empty(), por lo tanto, si se cambiara simultáneamente, constituiría una carrera de datos. El estándar dice que los programas no tendrán carreras de datos, por lo que el compilador dará eso por sentado (de lo contrario, casi no podría realizar ninguna optimización). Por lo tanto, puede asumir una perspectiva de subproceso único en lo no sincronizado list.empty()y concluir que debe permanecer constante.

Esta es solo una de varias optimizaciones (o comportamientos de hardware) que podrían romper su código.

Max Langhof
fuente
Los compiladores actuales ni siquiera parecen querer optimizar a.load()+a.load()...
curiousguy
1
@curiousguy ¿Cómo le gustaría eso optimizado? Solicitas consistencia secuencial completa allí, así que obtendrás eso ...
Max Langhof
@MaxLanghof ¿No crees que la optimización a.load()*2es obvia? Ni siquiera a.load(rel)+b.load(rel)-a.load(rel)está optimizado de todos modos. Nada es. ¿Por qué esperas que las cerraduras (que en su mayoría tienen una consistencia seq) sean más optimizadas?
curioso
@curiousguy ¿Porque el ordenamiento de la memoria de los accesos no atómicos (aquí antes y después del bloqueo) y los atómicos son completamente diferentes? No espero que el bloqueo se optimice "más", espero que los accesos no sincronizados se optimicen más que los accesos secuencialmente consistentes. La presencia de la cerradura es irrelevante para mi punto. Y no, el compilador no se le permite optimizar el a.load() + a.load()a 2 * a.load(). Siéntase libre de hacer una pregunta sobre esto si desea saber más.
Max Langhof
@MaxLanghof No tengo idea de lo que incluso estás tratando de decir. Las cerraduras son esencialmente secuencialmente consistentes. ¿Por qué la implementación trataría de hacer optimizaciones en algunas primitivas de subprocesos (bloqueos) y no en otras (atómicas)? ¿Espera que los accesos no atómicos se optimicen en torno a los usos de los atómicos?
curioso