¿Por qué este programa Java termina a pesar de que aparentemente no debería (y no lo hizo)?

205

Una operación sensible en mi laboratorio hoy salió completamente mal. Un actuador en un microscopio electrónico superó sus límites, y después de una cadena de eventos perdí $ 12 millones en equipos. He reducido más de 40K líneas en el módulo defectuoso a esto:

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Algunas muestras de la salida que estoy obteniendo:

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Como no hay ninguna aritmética de coma flotante aquí, y todos sabemos que los enteros con signo se comportan bien en el desbordamiento en Java, creo que no hay nada de malo en este código. Sin embargo, a pesar de que la salida indica que el programa no alcanzó la condición de salida, alcanzó la condición de salida (¿se alcanzó y no se alcanzó?). ¿Por qué?


Me di cuenta de que esto no sucede en algunos entornos. Estoy en OpenJDK 6 en Linux de 64 bits.

Perro
fuente
41
12 millones de equipos? Tengo mucha curiosidad de cómo podría suceder eso ... ¿por qué está utilizando el bloque de sincronización vacío: sincronizado (esto) {}?
Martin V.
84
Esto ni siquiera es remotamente seguro para subprocesos.
Matt Ball
8
Es interesante notar: agregar el finalcalificador (que no tiene ningún efecto sobre el código de bytes producido) a los campos xy y"resuelve" el error. Aunque no afecta el código de bytes, los campos están marcados con él, lo que me lleva a pensar que esto es un efecto secundario de una optimización JVM.
Niv Steingarten
9
@Eugene: Debe no terminar. La pregunta es "¿por qué termina?". Se Point pconstruye un A que satisface p.x+1 == p.y, luego se pasa una referencia al hilo de votación. Finalmente, el hilo de sondeo decide salir porque cree que la condición no se cumple para uno de los Pointmensajes que recibe, pero luego la salida de la consola muestra que debería haberse cumplido. La falta de volatileaquí simplemente significa que el hilo de votación puede atascarse, pero ese claramente no es el problema aquí.
Erma K. Pizarro
21
@JohnNicholas: El código real (que obviamente no es esto) tenía una cobertura de prueba del 100% y miles de pruebas, muchas de las cuales probaron cosas en miles de diferentes órdenes y permutaciones ... Las pruebas no encuentran mágicamente todos los casos extremos causados ​​por no deterministas JIT / caché / planificador. El verdadero problema es que el desarrollador que escribió este código no sabía que la construcción no ocurre antes de usar el objeto. Observe cómo eliminar el vacío synchronizedhace que el error no ocurra. Eso es porque tuve que escribir código al azar hasta que encontré uno que reprodujera este comportamiento de manera determinista.
Perro

Respuestas:

140

Obviamente, la escritura en currentPos no ocurre antes de leerlo, pero no veo cómo puede ser ese el problema.

currentPos = new Point(currentPos.x+1, currentPos.y+1);hace algunas cosas, incluyendo escribir valores predeterminados en xy y(0) y luego escribir sus valores iniciales en el constructor. Como su objeto no se publica de manera segura, el compilador / JVM puede reordenar libremente esas 4 operaciones de escritura.

Entonces, desde la perspectiva del hilo de lectura, es una ejecución legal leer xcon su nuevo valor pero ycon su valor predeterminado de 0, por ejemplo. Cuando llega a la printlndeclaración (que por cierto está sincronizada y, por lo tanto, influye en las operaciones de lectura), las variables tienen sus valores iniciales y el programa imprime los valores esperados.

Marcar currentPoscomo volatilegarantizará una publicación segura ya que su objeto es efectivamente inmutable: si en su caso de uso real el objeto está mutado después de la construcción, las volatilegarantías no serán suficientes y podría ver un objeto inconsistente nuevamente.

Alternativamente, puede hacer el Pointinmutable que también garantizará una publicación segura, incluso sin usar volatile. Para lograr la inmutabilidad, simplemente necesita marcar xy yfinalizar.

Como nota al margen y como ya se mencionó, synchronized(this) {}la JVM puede tratarlo como no operativo (entiendo que lo incluyó para reproducir el comportamiento).

asilias
fuente
44
No estoy seguro, pero ¿hacer que xey final no tenga el mismo efecto, evitando la barrera de la memoria?
Michael Böckling
3
Un diseño más simple es un objeto puntual inmutable que prueba invariantes en la construcción. Por lo tanto, nunca se arriesga a publicar una configuración peligrosa.
Ron
@BuddyCasino Sí, sí, lo he agregado. Para ser honesto, no recuerdo toda la discusión hace 3 meses (el uso de la final se propuso en los comentarios, así que no estoy seguro de por qué no lo incluí como una opción).
Assylias
2
La inmutabilidad en sí misma no garantiza una publicación segura (si x an y fuera privado pero expuesto solo con getters, el mismo problema de publicación aún existiría). final o volátil lo garantiza. Prefiero final sobre volátil.
Steve Kuo
@SteveKuo La inmutabilidad requiere final: sin final, lo mejor que puede obtener es la inmutabilidad efectiva que no tiene la misma semántica.
Assylias
29

Como currentPosse está cambiando fuera del hilo, debe marcarse como volatile:

static volatile Point currentPos = new Point(1,2);

Sin volátil, el hilo no está garantizado para leer en las actualizaciones de CurrentPos que se están haciendo en el hilo principal. Por lo tanto, se siguen escribiendo nuevos valores para currentPos, pero el hilo continúa usando las versiones anteriores en caché por razones de rendimiento. Como solo un subproceso modifica currentPos, puede escapar sin bloqueos, lo que mejorará el rendimiento.

Los resultados se ven muy diferentes si lee los valores solo una vez dentro del hilo para su uso en la comparación y posterior visualización de los mismos. Cuando hago lo siguiente, xsiempre se muestra como 1y yvaría entre 0y algunos enteros grandes. Creo que su comportamiento en este punto es algo indefinido sin la volatilepalabra clave y es posible que la compilación JIT del código contribuya a que actúe así. Además, si comento el synchronized(this) {}bloque vacío , el código también funciona y sospecho que es porque el bloqueo causa un retraso suficiente currentPosy sus campos se vuelven a leer en lugar de usarse desde el caché.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}
Ed Plese
fuente
2
Sí, y también podría bloquear todo. ¿Cual es tu punto?
Perro
Agregué alguna explicación adicional para el uso de volatile.
Ed Plese
19

Tiene memoria ordinaria, la referencia 'currentpos' y el objeto Point y sus campos detrás de él, compartidos entre 2 hilos, sin sincronización. Por lo tanto, no hay un orden definido entre las escrituras que le suceden a esta memoria en el hilo principal y las lecturas en el hilo creado (llámelo T).

El hilo principal está haciendo las siguientes escrituras (ignorando la configuración inicial del punto, dará como resultado que px y py tengan valores predeterminados):

  • a px
  • a py
  • a currentpos

Debido a que no hay nada especial sobre estas escrituras en términos de sincronización / barreras, el tiempo de ejecución es libre de permitir que el hilo T los vea ocurrir en cualquier orden (el hilo principal siempre ve escrituras y lecturas ordenadas de acuerdo con el orden del programa), y ocurren en cualquier punto entre las lecturas en T.

Entonces T está haciendo:

  1. lee currentpos a p
  2. leer px y py (en cualquier orden)
  3. compara y toma la rama
  4. lea px y py (en cualquier orden) y llame a System.out.println

Dado que no hay relaciones de orden entre las escrituras en main y las lecturas en T, claramente hay varias formas en que esto puede producir su resultado, ya que T puede ver la escritura de main en currentpos antes de las escrituras en currentpos.y o currentpos.x:

  1. Primero lee currentpos.x, antes de que ocurra la escritura x - obtiene 0, luego lee currentpos.y antes de que ocurra la escritura y - obtiene 0. Compare evals con true. Las escrituras se vuelven visibles para T. Se llama a System.out.println.
  2. Primero lee currentpos.x, después de que se ha producido la escritura x, luego lee currentpos.y antes de que se haya producido la escritura y - obtiene 0. Compare evals con true. Las escrituras se hacen visibles para T ... etc.
  3. Primero lee currentpos.y, antes de que ocurra la escritura y (0), luego lee currentpos.x después de la escritura x, evalúa a verdadero. etc.

y así sucesivamente ... Hay una serie de carreras de datos aquí.

Sospecho que la suposición defectuosa aquí es pensar que las escrituras que resultan de esta línea se hacen visibles en todos los hilos en el orden del programa del hilo que lo ejecuta:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java no ofrece tal garantía (sería terrible para el rendimiento). Se debe agregar algo más si su programa necesita un orden garantizado de las escrituras en relación con las lecturas en otros hilos. Otros han sugerido hacer que los campos x, y sean finales, o alternativamente hacer que currentpos sea volátil.

  • Si hace que los campos x, y sean finales, Java garantiza que las escrituras de sus valores se verán antes de que regrese el constructor, en todos los hilos. Por lo tanto, como la asignación a currentpos es posterior al constructor, se garantiza que el hilo T verá las escrituras en el orden correcto.
  • Si hace que currentpos sea volátil, Java garantiza que se trata de un punto de sincronización que se ordenará por completo con otros puntos de sincronización. Como en general, las escrituras a x e y deben ocurrir antes de la escritura a currentpos, entonces cualquier lectura de currentpos en otro hilo debe ver también las escrituras de x, y que sucedieron antes.

El uso de final tiene la ventaja de que hace que los campos sean inmutables y, por lo tanto, permite que los valores se almacenen en caché. El uso de volátiles conduce a la sincronización en cada escritura y lectura de currentpos, lo que podría afectar el rendimiento.

Consulte el capítulo 17 de las especificaciones del lenguaje Java para ver los detalles sangrientos: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(La respuesta inicial suponía un modelo de memoria más débil, ya que no estaba seguro de que el volátil garantizado JLS fuera suficiente. La respuesta editada para reflejar el comentario de las asilias, señalando que el modelo de Java es más fuerte, sucede antes, es transitivo, y tan volátil en la posición actual también es suficiente )

paulj
fuente
2
Esta es la mejor explicación en mi opinión. ¡Muchas gracias!
skyde
1
@skyde pero equivocado en la semántica de volátil. volatile garantiza que las lecturas de una variable volátil verán la última escritura disponible de una variable volátil , así como cualquier escritura anterior . En este caso, si currentPosse hace volátil, la asignación garantiza la publicación segura del currentPosobjeto y de sus miembros, incluso si ellos mismos no son volátiles.
Assylias
Bueno, estaba diciendo que, por mí mismo, no podía ver exactamente cómo el JLS garantizaba que el volátil formara una barrera con otras lecturas y escrituras normales. Técnicamente, no me puedo equivocar en eso;). Cuando se trata de modelos de memoria, es prudente suponer que un pedido no está garantizado y que está equivocado (todavía está a salvo) que al revés y que está equivocado e inseguro. Es genial si volátil proporciona esa garantía. ¿Puede explicar cómo lo proporciona el canal 17 de JLS?
paulj
2
En resumen, en Point currentPos = new Point(x, y), tiene 3 escrituras: (w1) this.x = x, (w2) this.y = yy (w3) currentPos = the new point. El orden del programa garantiza que hb (w1, w3) y hb (w2, w3). Más adelante en el programa que lees (r1) currentPos. Si currentPosno es volátil, no hay hb entre r1 y w1, w2, w3, por lo que r1 podría observar alguno (o ninguno) de ellos. Con volátil, introduce hb (w3, r1). Y la relación hb es transitiva, por lo que también introduce hb (w1, r1) y hb (w2, r1). Esto se resume en la concurrencia de Java en la práctica (3.5.3. Idiomas de publicación segura).
Assylias
2
Ah, si hb es transitivo de esa manera, entonces es una 'barrera' lo suficientemente fuerte, sí. Tengo que decir que no es fácil determinar que 17.4.5 de JLS define hb para tener esa propiedad. Ciertamente no está en la lista de propiedades que se dan cerca del comienzo de 17.4.5. ¡El cierre transitivo solo se menciona más abajo después de algunas notas explicativas! De todos modos, es bueno saberlo, ¡gracias por la respuesta! :). Nota: Actualizaré mi respuesta para reflejar el comentario de las asilias.
paulj
-2

Puede usar un objeto para sincronizar las escrituras y las lecturas. De lo contrario, como otros dijeron antes, se producirá una escritura en currentPos en el medio de las dos lecturas p.x + 1 y py

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}
Germano Fronza
fuente
En realidad esto hace el trabajo. En mi primer intento puse la lectura dentro del bloque sincronizado, pero luego me di cuenta de que no era realmente necesario.
Germano Fronza
1
-1 La JVM puede probar que semno se comparte y tratar la declaración sincronizada como no operativa ... El hecho de que resuelva el problema es pura suerte.
Assylias
44
Odio la programación multiproceso, demasiadas cosas funcionan debido a la suerte.
Jonathan Allen
-3

Está accediendo a currentPos dos veces y no garantiza que no se actualice entre esos dos accesos.

Por ejemplo:

  1. x = 10, y = 11
  2. el subproceso de trabajo evalúa px como 10
  3. el hilo principal ejecuta la actualización, ahora x = 11 e y = 12
  4. hilo de trabajo evalúa py como 12
  5. el subproceso de trabajo observa que 10 + 1! = 12, por lo que se imprime y sale.

Básicamente estás comparando dos puntos diferentes .

Tenga en cuenta que incluso hacer que CurrentPos sea volátil no lo protegerá de esto, ya que son dos lecturas separadas por el hilo de trabajo.

Agregar un

boolean IsValid() { return x+1 == y; }

método a su clase de puntos. Esto asegurará que solo se use un valor de currentPos al verificar x + 1 == y.

usuario2686913
fuente
currentPos solo se lee una vez, su valor se copia en p. p se lee dos veces, pero siempre apuntará a la misma ubicación.
Jonathan Allen