¿Es seguro el hilo! = Check?

140

Sé que las operaciones compuestas, como i++no son seguras para subprocesos, ya que implican múltiples operaciones.

¿Pero verificar la referencia consigo mismo es una operación segura para subprocesos?

a != a //is this thread-safe

Traté de programar esto y usar múltiples hilos pero no falló. Supongo que no podría simular la carrera en mi máquina.

EDITAR:

public class TestThreadSafety {
    private Object a = new Object();

    public static void main(String[] args) {

        final TestThreadSafety instance = new TestThreadSafety();

        Thread testingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                long countOfIterations = 0L;
                while(true){
                    boolean flag = instance.a != instance.a;
                    if(flag)
                        System.out.println(countOfIterations + ":" + flag);

                    countOfIterations++;
                }
            }
        });

        Thread updatingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                while(true){
                    instance.a = new Object();
                }
            }
        });

        testingReferenceThread.start();
        updatingReferenceThread.start();
    }

}

Este es el programa que estoy usando para probar la seguridad del hilo.

Comportamiento extraño

Cuando mi programa comienza entre algunas iteraciones, obtengo el valor del indicador de salida, lo que significa que la !=verificación de referencia falla en la misma referencia. PERO después de algunas iteraciones, la salida se convierte en un valor constante falsey luego ejecutar el programa durante mucho tiempo no genera una sola truesalida.

Como sugiere la salida después de algunas iteraciones n (no fijas), la salida parece ser un valor constante y no cambia.

Salida:

Para algunas iteraciones:

1494:true
1495:true
1496:true
19970:true
19972:true
19974:true
//after this there is not a single instance when the condition becomes true
Narendra Pathai
fuente
2
¿Qué quiere decir con "hilo seguro" en este contexto? ¿Estás preguntando si se garantiza que siempre devolverá falso?
JB Nizet
@JBNizet sí. En eso estaba pensando.
Narendra Pathai
55
Ni siquiera siempre devuelve falso en un contexto de subproceso único. Podría ser un NaN ..
Harold
44
Explicación probable: el código se compiló justo a tiempo y el código compilado carga la variable solo una vez. Esto se espera.
Marko Topolnik
3
Imprimir resultados individuales es una mala manera de evaluar las carreras. La impresión (formateando y escribiendo los resultados) es relativamente costosa en comparación con su prueba (y, a veces, su programa terminará bloqueándose en la escritura cuando el ancho de banda de la conexión al terminal o el terminal en sí es lento). Además, IO a menudo contiene mutexes propios que permutarán el orden de ejecución de sus hilos (tenga en cuenta que sus líneas individuales 1234:truenunca se rompen entre sí ). Una prueba de carrera necesita un circuito interno más ajustado. Imprima un resumen al final (como alguien hizo a continuación con un marco de prueba de unidad).
Ben Jackson

Respuestas:

124

En ausencia de sincronización este código

Object a;

public boolean test() {
    return a != a;
}

puede producir true. Este es el bytecode paratest()

    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    IF_ACMPEQ L1
...

Como podemos ver, carga el campo aa los vars locales dos veces, es una operación no atómica, si ase produce un cambio en el medio por otra comparación de subprocesos false.

Además, el problema de visibilidad de la memoria es relevante aquí, no hay garantía de que los cambios arealizados por otro hilo sean visibles para el hilo actual.

Evgeniy Dorofeev
fuente
22
Aunque hay pruebas sólidas, el bytecode no es en realidad una prueba. También debe estar en algún lugar del JLS ...
Marko Topolnik
10
@Marko Estoy de acuerdo con tu pensamiento, pero no necesariamente con tu conclusión. Para mí, el código de bytes anterior es la forma obvia / canónica de implementación !=, que implica cargar el LHS y el RHS por separado. Entonces, si el JLS no menciona nada específico sobre optimizaciones cuando LHS y RHS son sintácticamente idénticos, entonces se aplicaría la regla general, lo que significa cargar ados veces.
Andrzej Doyle
20
En realidad, suponiendo que el bytecode generado se ajuste al JLS, ¡es una prueba!
proskor
66
@Adrian: Primero: incluso si esa suposición es inválida, la existencia de un único compilador donde puede evaluar como "verdadero" es suficiente para demostrar que a veces puede evaluar como "verdadero" (incluso si la especificación lo prohibió, lo cual no) En segundo lugar: Java está bien especificado y la mayoría de los compiladores se ajustan estrechamente a él. Tiene sentido usarlos como referencia a este respecto. En tercer lugar: utiliza el término "JRE", pero no creo que signifique lo que cree que significa. . .
ruakh
2
@AlexanderTorstling: "Sin embargo, no estoy seguro de si eso es suficiente para evitar una optimización de lectura única". No es suficiente De hecho, en ausencia de sincronización (y el extra "ocurre antes de" relaciones que impone esta), la optimización es válida,
Stephen C
47

¿El cheque a != aes seguro para subprocesos?

Si apotencialmente puede ser actualizado por otro hilo (¡sin la sincronización adecuada!), Entonces No.

Traté de programar esto y usar múltiples hilos pero no fallé. Supongo que no podría simular la carrera en mi máquina.

¡Eso no significa nada! El problema es que si el JLS permite una ejecución en la que aotro subproceso actualiza , entonces el código no es seguro para subprocesos. El hecho de que no se pueda hacer que la condición de carrera suceda con un caso de prueba particular en una máquina particular y una implementación de Java particular, no impide que ocurra en otras circunstancias.

¿Esto significa que a! = A podría volver true.

Sí, en teoría, bajo ciertas circunstancias.

Alternativamente, a != apodría regresar falseaunque aestuviera cambiando simultáneamente.


Sobre el "comportamiento extraño":

Cuando mi programa comienza entre algunas iteraciones, obtengo el valor del indicador de salida, lo que significa que la referencia! = Check falla en la misma referencia. PERO después de algunas iteraciones, la salida se convierte en un valor constante falso y luego ejecutar el programa durante mucho tiempo no genera una sola salida verdadera.

Este comportamiento "extraño" es consistente con el siguiente escenario de ejecución:

  1. El programa se carga y la JVM comienza a interpretar los códigos de bytes. Como (como hemos visto en la salida de javap) el código de bytes realiza dos cargas, usted (aparentemente) ve los resultados de la condición de carrera, ocasionalmente.

  2. Después de un tiempo, el compilador JIT compila el código. El optimizador JIT se da cuenta de que hay dos cargas de la misma ranura de memoria ( a) juntas, y optimiza la segunda. (De hecho, existe la posibilidad de que optimice la prueba por completo ...)

  3. Ahora la condición de carrera ya no se manifiesta, porque ya no hay dos cargas.

Tenga en cuenta que esto es todo coherente con lo que el JLS permite una implementación de Java para hacer.


@kriss comentó así:

Esto parece ser lo que los programadores de C o C ++ llaman "Comportamiento indefinido" (depende de la implementación). Parece que podría haber algunos UB en Java en casos de esquina como este.

El modelo de memoria Java (especificado en JLS 17.4 ) especifica un conjunto de condiciones previas bajo las cuales se garantiza que un hilo vea los valores de memoria escritos por otro hilo. Si un hilo intenta leer una variable escrita por otro, y esas condiciones previas no se cumplen, entonces puede haber varias ejecuciones posibles ... algunas de las cuales probablemente sean incorrectas (desde la perspectiva de los requisitos de la aplicación). En otras palabras, el conjunto de posibles comportamientos (es decir, el conjunto de "ejecuciones bien formadas") está definido, pero no podemos decir cuál de esos comportamientos ocurrirá.

El compilador puede combinar y reordenar cargas y guardar (y hacer otras cosas) siempre que el efecto final del código sea el mismo:

  • cuando es ejecutado por un solo hilo, y
  • cuando lo ejecutan diferentes subprocesos que se sincronizan correctamente (según el modelo de memoria).

Pero si el código no se sincroniza correctamente (y, por lo tanto, las relaciones "suceden antes" no limitan suficientemente el conjunto de ejecuciones bien formadas), el compilador puede reordenar cargas y almacenes de manera que arrojarían resultados "incorrectos". (Pero eso realmente solo dice que el programa es incorrecto).

Stephen C
fuente
¿Esto significa que a != apodría volver cierto?
proskor
Quise decir que tal vez en mi máquina no podría simular que el código anterior no es seguro para subprocesos. Entonces quizás haya un razonamiento teórico detrás de esto.
Narendra Pathai
@NarendraPathai: no hay una razón teórica por la que no puedas demostrarlo. Posiblemente haya una razón práctica ... o tal vez simplemente no tuvo suerte.
Stephen C
Verifique mi respuesta actualizada con el programa que estoy usando. La verificación devuelve verdadero a veces, pero parece haber un comportamiento extraño en la salida.
Narendra Pathai
1
@NarendraPathai - Mira mi explicación.
Stephen C
27

Probado con test-ng:

public class MyTest {

  private static Integer count=1;

  @Test(threadPoolSize = 1000, invocationCount=10000)
  public void test(){
    count = new Integer(new Random().nextInt());
    Assert.assertFalse(count != count);
  }

}

Tengo 2 fallas en 10 000 invocaciones. Así que NO , se NO hilo de seguridad

Arnaud Denoyelle
fuente
66
Ni siquiera estás buscando la igualdad ... la Random.nextInt()parte es superflua. Podrías haber probado con new Object()igual de bien.
Marko Topolnik
@MarkoTopolnik Verifique mi respuesta actualizada con el programa que estoy usando. La verificación devuelve verdadero a veces, pero parece haber un comportamiento extraño en la salida.
Narendra Pathai
1
Una nota al margen, los objetos aleatorios generalmente están destinados a ser reutilizados, no creados cada vez que necesita un nuevo int.
Simon Forsberg
15

No, no es. Para una comparación, la máquina virtual Java debe poner los dos valores para comparar en la pila y ejecutar la instrucción de comparación (cuál depende del tipo de "a").

La máquina virtual Java puede:

  1. Lea "a" dos veces, ponga cada una en la pila y luego compare los resultados
  2. Lea "a" solo una vez, póngalo en la pila, duplíquelo (instrucción "dup") y ejecute la comparación
  3. Elimine la expresión por completo y reemplácela con false

En el primer caso, otro hilo podría modificar el valor de "a" entre las dos lecturas.

La estrategia elegida depende del compilador de Java y del Java Runtime (especialmente el compilador JIT). Incluso puede cambiar durante el tiempo de ejecución de su programa.

Si desea asegurarse de cómo se accede a la variable, debe hacerlo volatile(una llamada "barrera de memoria media") o agregar una barrera de memoria completa ( synchronized). También puede usar alguna API de nivel superior (por ejemplo, AtomicIntegercomo mencionó Juned Ahasan).

Para obtener detalles sobre la seguridad de subprocesos, lea JSR 133 ( Modelo de memoria Java ).

stefan.schwetschke
fuente
Declarar acomo volatilesería todavía implicar dos distintos lee, con la posibilidad de un cambio en el medio.
Holger
6

Todo ha sido bien explicado por Stephen C. Por diversión, podría intentar ejecutar el mismo código con los siguientes parámetros de JVM:

-XX:InlineSmallCode=0

Esto debería evitar la optimización realizada por el JIT (lo hace en el servidor de punto de acceso 7) y verá truepara siempre (me detuve en 2,000,000 pero supongo que continúa después de eso).

Para obtener información, a continuación se muestra el código JIT. Para ser honesto, no leo el ensamblaje con suficiente fluidez como para saber si la prueba realmente se realizó o de dónde provienen las dos cargas. (la línea 26 es la prueba flag = a != ay la línea 31 es la llave de cierre de la while(true)).

  # {method} 'run' '()V' in 'javaapplication27/TestThreadSafety$1'
  0x00000000027dcc80: int3   
  0x00000000027dcc81: data32 data32 nop WORD PTR [rax+rax*1+0x0]
  0x00000000027dcc8c: data32 data32 xchg ax,ax
  0x00000000027dcc90: mov    DWORD PTR [rsp-0x6000],eax
  0x00000000027dcc97: push   rbp
  0x00000000027dcc98: sub    rsp,0x40
  0x00000000027dcc9c: mov    rbx,QWORD PTR [rdx+0x8]
  0x00000000027dcca0: mov    rbp,QWORD PTR [rdx+0x18]
  0x00000000027dcca4: mov    rcx,rdx
  0x00000000027dcca7: movabs r10,0x6e1a7680
  0x00000000027dccb1: call   r10
  0x00000000027dccb4: test   rbp,rbp
  0x00000000027dccb7: je     0x00000000027dccdd
  0x00000000027dccb9: mov    r10d,DWORD PTR [rbp+0x8]
  0x00000000027dccbd: cmp    r10d,0xefc158f4    ;   {oop('javaapplication27/TestThreadSafety$1')}
  0x00000000027dccc4: jne    0x00000000027dccf1
  0x00000000027dccc6: test   rbp,rbp
  0x00000000027dccc9: je     0x00000000027dcce1
  0x00000000027dcccb: cmp    r12d,DWORD PTR [rbp+0xc]
  0x00000000027dcccf: je     0x00000000027dcce1  ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd1: add    rbx,0x1            ; OopMap{rbp=Oop off=85}
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd5: test   DWORD PTR [rip+0xfffffffffdb53325],eax        # 0x0000000000330000
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
                                                ;   {poll}
  0x00000000027dccdb: jmp    0x00000000027dccd1
  0x00000000027dccdd: xor    ebp,ebp
  0x00000000027dccdf: jmp    0x00000000027dccc6
  0x00000000027dcce1: mov    edx,0xffffff86
  0x00000000027dcce6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dcceb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=112}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dccf0: int3   
  0x00000000027dccf1: mov    edx,0xffffffad
  0x00000000027dccf6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dccfb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=128}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dcd00: int3                      ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
  0x00000000027dcd01: int3   
asilias
fuente
1
Este es un buen ejemplo del tipo de código que JVM producirá realmente cuando tenga un bucle infinito y todo se pueda izar más o menos. El "bucle" real aquí son las tres instrucciones de 0x27dccd1a 0x27dccdf. El jmpen el bucle es incondicional (ya que el bucle es infinito). Las únicas otras dos instrucciones en el bucle son add rbc, 0x1: que se está incrementando countOfIterations(a pesar de que el bucle nunca se cerrará, por lo que este valor no se leerá: tal vez sea necesario en caso de que lo interrumpa en el depurador), ... .
BeeOnRope
... y la testinstrucción de aspecto extraño , que en realidad solo está disponible para el acceso a la memoria (¡tenga en cuenta que eaxnunca se establece en el método!): es una página especial que se configura como no legible cuando la JVM desea activar todos los hilos para llegar a un punto seguro, para que pueda hacer gc o alguna otra operación que requiera que todos los hilos estén en un estado conocido.
BeeOnRope
Más concretamente, la JVM eliminó completamente la instance. a != instance.acomparación del bucle, y solo la realiza una vez, ¡antes de ingresar al bucle! Sabe que no es necesario volver a cargar instanceo aque no se declaran volátiles y que no hay otro código que pueda cambiarlos en el mismo subproceso, por lo que solo supone que son iguales durante todo el ciclo, lo cual está permitido por la memoria modelo.
BeeOnRope
5

No, a != ano es seguro para subprocesos. Esta expresión consta de tres partes: cargar a, cargar de anuevo y realizar !=. Es posible que otro subproceso obtenga el bloqueo intrínseco en ael padre y cambie el valor de aentre las 2 operaciones de carga.

Sin embargo, otro factor es si aes local. Si aes local, entonces ningún otro subproceso debería tener acceso y, por lo tanto, debería ser seguro para subprocesos.

void method () {
    int a = 0;
    System.out.println(a != a);
}

También debe imprimir siempre false.

Declarar acomo volatileno resolvería el problema si aes statico instancia. El problema no es que los hilos tengan valores diferentes a, sino que un hilo se carga ados veces con valores diferentes. En realidad, puede hacer que el caso sea menos seguro para subprocesos. Si ano lo está volatile, apuede almacenarse en caché y un cambio en otro subproceso no afectará el valor almacenado en caché.

DoubleMx2
fuente
Su ejemplo con synchronizedestá equivocado: para garantizar que se imprima ese código false, todos los métodos que establezca también a deberían serlo synchronized.
ruakh
¿Porque? Si el método está sincronizado, ¿cómo ganaría cualquier otro hilo el bloqueo intrínseco en ael padre mientras se ejecuta el método, necesario para establecer el valor a?
DoubleMx2
1
Tus premisas están mal. Puede establecer el campo de un objeto sin adquirir su bloqueo intrínseco. Java no requiere un hilo para adquirir el bloqueo intrínseco de un objeto antes de configurar sus campos.
ruakh
3

Con respecto al comportamiento extraño:

Dado que la variable ano está marcada como volatile, en algún momento su valor apodría ser almacenado en caché por el hilo. Ambos as a != ason entonces la versión en caché y, por lo tanto, siempre son los mismos (el significado flagahora es siempre false).

Walter Laan
fuente
0

Incluso la simple lectura no es atómica. Si aestá longy no está marcado como volatileentonces en JVM de 32 bits long b = ano es seguro para subprocesos.

ZhekaKozlov
fuente
volátil y atomicidad no están relacionados. incluso si marco un volátil será no atómico
Narendra Pathai
La asignación de un campo largo volátil siempre es atómica. Las otras operaciones como ++ no lo son.
ZhekaKozlov