Buscar..


Trampa: uso incorrecto de esperar () / notificar ()

Los métodos object.wait() , object.notify() y object.notifyAll() están destinados a ser utilizados de una manera muy específica. (consulte http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 )

El problema de "notificación perdida"

Un error común de principiante es llamar incondicionalmente a object.wait()

private final Object lock = new Object();

public void myConsumer() {
    synchronized (lock) {
        lock.wait();     // DON'T DO THIS!!
    }
    doSomething();
}

La razón por la que esto es incorrecto es que depende de algún otro subproceso para llamar a lock.notify() o lock.notifyAll() , pero nada garantiza que el otro subproceso no realizó esa llamada antes del subproceso del consumidor llamado lock.wait() .

lock.notify() y lock.notifyAll() no hacen nada en absoluto si algún otro hilo no está esperando la notificación. El hilo que llama a myConsumer() en este ejemplo se bloqueará para siempre si es demasiado tarde para detectar la notificación.

El error "Estado del monitor ilegal"

Si llama a wait() o notify() en un objeto sin mantener el bloqueo, la JVM lanzará la IllegalMonitorStateException .

public void myConsumer() {
    lock.wait();      // throws exception
    consume();
}

public void myProducer() {
    produce();
    lock.notify();    // throws exception
}

(El diseño para wait() / notify() requiere que el bloqueo se mantenga porque es necesario para evitar las condiciones de carrera sistémicas. Si fuera posible llamar a wait() o notify() sin bloqueo, sería imposible implementar el caso de uso principal de estas primitivas: esperar que ocurra una condición.)

Esperar / notificar es de muy bajo nivel

La mejor manera de evitar problemas con wait() y notify() es no usarlos. La mayoría de los problemas de sincronización se pueden resolver utilizando los objetos de sincronización de nivel superior (colas, barreras, semáforos, etc.) que están disponibles en el paquete java.utils.concurrent .

Pitfall - Extendiendo 'java.lang.Thread'

El javadoc para la clase Thread muestra dos formas de definir y usar un hilo:

Usando una clase de hilo personalizado:

 class PrimeThread extends Thread {
     long minPrime;
     PrimeThread(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeThread p = new PrimeThread(143);
 p.start();

Usando un Runnable :

 class PrimeRun implements Runnable {
     long minPrime;
     PrimeRun(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeRun p = new PrimeRun(143);
 new Thread(p).start();

(Fuente: java.lang.Thread javadoc .)

El enfoque de clase de subproceso personalizado funciona, pero tiene algunos problemas:

  1. Es incómodo usar PrimeThread en un contexto que usa un grupo de subprocesos clásico, un ejecutor o el marco de ForkJoin. (No es imposible, porque PrimeThread implementa indirectamente Runnable , pero usar una clase Thread personalizada como Runnable es ciertamente torpe y puede que no sea viable ... dependiendo de otros aspectos de la clase).

  2. Hay más oportunidad de errores en otros métodos. Por ejemplo, si declara un PrimeThread.start() sin delegar a Thread.start() , terminará con un "hilo" que se ejecutó en el hilo actual.

El enfoque de poner la lógica del hilo en un Runnable evita estos problemas. De hecho, si usa una clase anónima (Java 1.1 en adelante) para implementar el Runnable el resultado es más sucinto y más legible que los ejemplos anteriores.

 final long minPrime = ...
 new Thread(new Runnable() {
     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }.start();

Con una expresión lambda (Java 8 en adelante), el ejemplo anterior sería aún más elegante:

 final long minPrime = ...
 new Thread(() -> {
    // compute primes larger than minPrime
     . . .
 }).start();

Pitfall - Demasiados hilos hace que una aplicación sea más lenta.

Una gran cantidad de personas que son nuevas en los subprocesos múltiples piensan que el uso de subprocesos hace que una aplicación vaya más rápido. De hecho, es mucho más complicado que eso. Pero una cosa que podemos decir con certeza es que para cualquier computadora hay un límite en el número de subprocesos que se pueden ejecutar al mismo tiempo:

  • Una computadora tiene un número fijo de núcleos (o hipervínculos ).
  • Se debe programar un subproceso de Java a un núcleo o hipervínculo para que se ejecute.
  • Si hay más subprocesos Java ejecutables que núcleos / hipervínculos (disponibles), algunos de ellos deben esperar.

Esto nos dice que simplemente crear más y más hilos de Java no puede hacer que la aplicación vaya más rápido y más rápido. Pero hay otras consideraciones también:

  • Cada subproceso requiere una región de memoria fuera del montón para su pila de subprocesos. El tamaño de pila de hilos típico (predeterminado) es 512Kbytes o 1Mbytes. Si tiene un número significativo de subprocesos, el uso de la memoria puede ser significativo.

  • Cada subproceso activo hará referencia a una serie de objetos en el montón. Eso aumenta el conjunto de trabajo de objetos alcanzables , lo que afecta la recolección de basura y el uso de la memoria física.

  • La sobrecarga de cambiar entre hilos no es trivial. Por lo general, implica un cambio en el espacio del kernel del sistema operativo para tomar una decisión de programación de subprocesos.

  • Los gastos generales de sincronización de subprocesos y señalización entre subprocesos (por ejemplo, esperar (), notificar () / notifyAll) pueden ser significativos.

Dependiendo de los detalles de su aplicación, estos factores generalmente significan que hay un "punto dulce" para el número de hilos. Más allá de eso, agregar más hilos proporciona una mejora mínima del rendimiento y puede empeorar el rendimiento.

Si su aplicación se crea para cada nueva tarea, un aumento inesperado en la carga de trabajo (por ejemplo, una alta tasa de solicitud) puede llevar a un comportamiento catastrófico.

Una mejor manera de lidiar con esto es usar un grupo de subprocesos limitados cuyo tamaño puede controlar (estática o dinámicamente). Cuando hay mucho trabajo por hacer, la aplicación necesita poner en cola las solicitudes. Si usa un ExecutorService , se encargará de la administración del grupo de subprocesos y la cola de tareas.

Pitfall - La creación de hilos es relativamente cara

Considere estos dos micro-puntos de referencia:

El primer punto de referencia simplemente crea, inicia y une hilos. El Runnable del hilo no funciona.

public class ThreadTest {
    public static void main(String[] args) throws Exception {
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Thread t = new Thread(new Runnable() {
                        public void run() {
                }});
                t.start();
                t.join();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ThreadTest 
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C

En una PC moderna típica que ejecuta Linux con 64bit Java 8 u101, este punto de referencia muestra un tiempo promedio que se tarda en crear, iniciar y unir hilos de entre 33.6 y 33.9 microsegundos.

El segundo punto de referencia hace el equivalente al primero, pero utiliza un ExecutorService para enviar tareas y un Future para encontrarse con el final de la tarea.

import java.util.concurrent.*;

public class ExecutorTest {
    public static void main(String[] args) throws Exception {
        ExecutorService exec = Executors.newCachedThreadPool();
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Future<?> future = exec.submit(new Runnable() {
                    public void run() {
                    }
                });
                future.get();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C

Como puedes ver, los promedios están entre 5.3 y 5.6 microsegundos.

Si bien los tiempos reales dependerán de una variedad de factores, la diferencia entre estos dos resultados es significativa. Claramente, es más rápido utilizar un grupo de subprocesos para reciclar subprocesos que crear nuevos subprocesos.

Pitfall: las variables compartidas requieren una sincronización adecuada

Considera este ejemplo:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (!stop) {
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        tt.stop = true;            // Tell child thread to stop.
    }
}

La intención de este programa es iniciar un subproceso, dejar que se ejecute durante 1000 milisegundos y luego hacer que se detenga al establecer el indicador de stop .

¿Funcionará según lo previsto?

Tal vez sí tal vez no.

Una aplicación no necesariamente se detiene cuando el método main regresa. Si se ha creado otro hilo y ese hilo no se ha marcado como un hilo daemon, entonces la aplicación continuará ejecutándose después de que el hilo principal haya finalizado. En este ejemplo, eso significa que la aplicación continuará ejecutándose hasta que finalice el subproceso secundario. Eso debería suceder cuando tt.stop se establece en true .

Pero eso en realidad no es estrictamente cierto. De hecho, el subproceso secundario se detendrá después de que haya observado una stop con el valor true . ¿Eso sucederá? Tal vez sí tal vez no.

La especificación del lenguaje Java garantiza que las lecturas y escrituras de memoria hechas en un hilo sean visibles para ese hilo, según el orden de las declaraciones en el código fuente. Sin embargo, en general, esto NO está garantizado cuando un hilo escribe y otro hilo (posteriormente) lee. Para obtener una visibilidad garantizada, es necesario que haya una cadena de relaciones de suceso antes de una escritura y una lectura posterior. En el ejemplo anterior, no hay tal cadena para la actualización del indicador de stop , y por lo tanto, no se garantiza que el subproceso secundario verá el cambio de stop en true .

(Nota para los autores: Debe haber un tema separado en el Modelo de memoria de Java para profundizar en los detalles técnicos).

¿Cómo solucionamos el problema?

En este caso, hay dos formas sencillas de garantizar que la actualización de stop esté visible:

  1. Declara stop de ser volatile ; es decir

     private volatile boolean stop = false;
    

    Para una variable volatile , el JLS especifica que hay una relación de suceso antes entre una escritura por un hilo y una lectura posterior por un segundo hilo.

  2. Use un mutex para sincronizar de la siguiente manera:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (true) {
            synchronize (this) {
                if (stop) {
                    break;
                }
            }
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        synchronize (tt) {
            tt.stop = true;        // Tell child thread to stop.
        }
    }
}

Además de garantizar que haya una exclusión mutua, el JLS especifica que hay una relación de suceso antes entre la liberación de un mutex en un hilo y obtener el mismo mutex en un segundo hilo.

¿Pero no es la asignación atómica?

¡Sí lo es!

Sin embargo, ese hecho no significa que los efectos de la actualización serán visibles simultáneamente a todos los subprocesos. Sólo una cadena adecuada de relaciones de suceso-antes lo garantizará.

¿Por qué hicieron esto?

Los programadores que realizan programación de subprocesos múltiples en Java por primera vez encuentran que el modelo de memoria es un desafío. Los programas se comportan de una manera no intuitiva porque la expectativa natural es que las escrituras son visibles de manera uniforme. Entonces, ¿por qué los diseñadores de Java diseñan el modelo de memoria de esta manera?

En realidad, se trata de un compromiso entre el rendimiento y la facilidad de uso (para el programador).

Una arquitectura de computadora moderna consiste en múltiples procesadores (núcleos) con conjuntos de registros individuales. La memoria principal es accesible para todos los procesadores o para grupos de procesadores. Otra propiedad del hardware moderno de las computadoras es que el acceso a los registros suele ser un orden de magnitud de acceso más rápido que el acceso a la memoria principal. A medida que aumenta la cantidad de núcleos, es fácil ver que leer y escribir en la memoria principal puede convertirse en el principal cuello de botella en el rendimiento del sistema.

Esta discrepancia se resuelve implementando uno o más niveles de almacenamiento en caché de memoria entre los núcleos del procesador y la memoria principal. Cada núcleo de acceso accede a las celdas a través de su caché. Normalmente, la lectura de la memoria principal solo ocurre cuando hay una falta de caché, y la escritura de la memoria principal solo ocurre cuando se debe vaciar una línea de caché. Para una aplicación donde el conjunto de trabajo de cada núcleo de las ubicaciones de la memoria se ajuste a su caché, la velocidad del núcleo ya no está limitada por la velocidad de la memoria principal / ancho de banda.

Pero eso nos da un nuevo problema cuando varios núcleos están leyendo y escribiendo variables compartidas. La última versión de una variable puede estar en el caché de un núcleo. A menos que ese núcleo descargue la línea de caché en la memoria principal, Y otros núcleos invaliden su copia en caché de versiones anteriores, algunos de ellos pueden ver versiones obsoletas de la variable. Pero si los cachés se vaciaran en la memoria cada vez que hay una escritura de caché ("por si acaso" hubo una lectura por parte de otro núcleo) que consumiría innecesariamente el ancho de banda de la memoria principal.

La solución estándar utilizada en el nivel de conjunto de instrucciones de hardware es proporcionar instrucciones para la invalidación de la memoria caché y la escritura de la memoria caché, y dejar que el compilador decida cuándo usarlas.

Volviendo a Java. El modelo de memoria está diseñado para que los compiladores de Java no tengan que emitir la invalidación de la memoria caché y las instrucciones de escritura directa donde no sean realmente necesarias. El supuesto es que el programador utilizará un mecanismo de sincronización apropiado (por ejemplo, mutexes primitivos, clases de concurrencia volatile , de alto nivel, etc.) para indicar que necesita visibilidad de memoria. En ausencia de una relación de suceso antes , los compiladores de Java son libres de asumir que no se requieren operaciones de caché (o similares).

Esto tiene importantes ventajas de rendimiento para aplicaciones de subprocesos múltiples, pero la desventaja es que escribir aplicaciones de subprocesos múltiples correctas no es una cuestión simple. El programador tiene que entender lo que él o ella está haciendo.

¿Por qué no puedo reproducir esto?

Hay varias razones por las que problemas como este son difíciles de reproducir:

  1. Como se explicó anteriormente, la consecuencia de no hacer frente a la visibilidad de memoria emite problemas adecuadamente suele ser que su aplicación compilada no maneja los cachés de memoria correctamente. Sin embargo, como mencionamos anteriormente, los cachés de memoria a menudo se vacían de todos modos.

  2. Cuando cambia la plataforma de hardware, las características de los cachés de memoria pueden cambiar. Esto puede llevar a un comportamiento diferente si su aplicación no se sincroniza correctamente.

  3. Es posible que esté observando los efectos de la sincronización fortuita . Por ejemplo, si agrega traceprints, normalmente se produce una sincronización entre bambalinas en las secuencias de E / S que causan vacíos de caché. Por lo tanto, agregar traceprints a menudo hace que la aplicación se comporte de manera diferente.

  4. La ejecución de una aplicación en un depurador hace que el compilador JIT compile de forma diferente. Los puntos de interrupción y el paso único exacerban esto. Estos efectos a menudo cambiarán el comportamiento de una aplicación.

Estas cosas hacen que los errores debidos a una sincronización inadecuada sean particularmente difíciles de resolver.



Modified text is an extract of the original Stack Overflow Documentation
Licenciado bajo CC BY-SA 3.0
No afiliado a Stack Overflow