JavaRush /Blog Java /Random-ES /Fundamentos de la concurrencia: interbloqueos y monitores...
Snusmum
Nivel 34
Хабаровск

Fundamentos de la concurrencia: interbloqueos y monitores de objetos (secciones 1, 2) (traducción del artículo)

Publicado en el grupo Random-ES
Artículo fuente: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html Publicado por Martin Mois Este artículo es parte de nuestro curso Java Concurrency Fundamentals . En este curso profundizarás en la magia del paralelismo. Aprenderá los conceptos básicos del paralelismo y el código paralelo, y se familiarizará con conceptos como atomicidad, sincronización y seguridad de subprocesos. ¡ Échale un vistazo aquí !

Contenido

1. Vida  1.1 Punto muerto  1.2 Inanición 2. Monitores de objetos con esperar() y notificar()  2.1 Bloques sincronizados anidados con esperar() y notificar()  2.2 Condiciones en bloques sincronizados 3. Diseño para subprocesos múltiples  3.1 Objeto inmutable  3.2 Diseño de API  3.3 Almacenamiento de subprocesos locales
1. Vitalidad
Al desarrollar aplicaciones que utilizan el paralelismo para lograr sus objetivos, puede encontrarse con situaciones en las que diferentes subprocesos pueden bloquearse entre sí. Si la aplicación se ejecuta más lento de lo esperado en esta situación, diríamos que no se ejecuta como se esperaba. En esta sección, analizaremos más de cerca los problemas que pueden amenazar la capacidad de supervivencia de una aplicación multiproceso.
1.1 Bloqueo mutuo
El término punto muerto es bien conocido entre los desarrolladores de software e incluso la mayoría de los usuarios comunes y corrientes lo utilizan de vez en cuando, aunque no siempre en el sentido correcto. Estrictamente hablando, este término significa que cada uno de dos (o más) subprocesos está esperando que el otro subproceso libere un recurso bloqueado por él, mientras que el primer subproceso ha bloqueado un recurso al que el segundo está esperando acceder: Para comprender mejor el problema, eche un vistazo al Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A siguiente código: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } Como puede ver en el código anterior, se inician dos subprocesos e intentan bloquear dos recursos estáticos. Pero para el interbloqueo, necesitamos una secuencia diferente para ambos subprocesos, por lo que usamos una instancia del objeto Aleatorio para elegir qué recurso el subproceso desea bloquear primero. Si la variable booleana b es verdadera, entonces el recurso1 se bloquea primero y luego el subproceso intenta adquirir el bloqueo para el recurso2. Si b es falso, entonces el hilo bloquea el recurso2 y luego intenta adquirir el recurso1. Este programa no necesita ejecutarse por mucho tiempo para lograr el primer punto muerto, es decir El programa se colgará para siempre si no lo interrumpimos: [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. En esta ejecución, Tread-1 ha adquirido el bloqueo del recurso2 y está esperando el bloqueo del recurso1, mientras que Tread-2 tiene el bloqueo del recurso1 y está esperando el recurso2. Si estableciésemos el valor de la variable booleana b en el código anterior en verdadero, no podríamos observar ningún punto muerto porque la secuencia en la que los subprocesos 1 y 2 solicitan bloqueos siempre sería la misma. En esta situación, uno de los dos subprocesos obtendría el bloqueo primero y luego solicitaría el segundo, que todavía está disponible porque el otro subproceso está esperando el primer bloqueo. En general, podemos distinguir las siguientes condiciones necesarias para que se produzca un interbloqueo: - Ejecución compartida: Hay un recurso al que sólo puede acceder un hilo en cada momento. - Retención de recursos: mientras adquiere un recurso, un hilo intenta adquirir otro bloqueo en algún recurso único. - Sin preferencia: no existe ningún mecanismo para liberar un recurso si un subproceso mantiene el bloqueo durante un período de tiempo determinado. - Espera circular: durante la ejecución, se produce una colección de subprocesos en los que dos (o más) subprocesos esperan entre sí para liberar un recurso que ha sido bloqueado. Aunque la lista de condiciones parece larga, no es raro que aplicaciones multiproceso bien ejecutadas tengan problemas de bloqueo. Pero puedes evitarlos si puedes eliminar una de las condiciones anteriores: - Ejecución compartida: esta condición a menudo no se puede eliminar cuando el recurso debe ser utilizado por una sola persona. Pero esta no tiene por qué ser la razón. Cuando se utilizan sistemas DBMS, una posible solución, en lugar de utilizar un bloqueo pesimista en alguna fila de la tabla que necesita ser actualizada, es utilizar una técnica llamada Bloqueo Optimista . - Una forma de evitar retener un recurso mientras se espera otro recurso exclusivo es bloquear todos los recursos necesarios al comienzo del algoritmo y liberarlos todos si es imposible bloquearlos todos a la vez. Por supuesto, esto no siempre es posible; tal vez los recursos que requieren bloqueo se desconozcan de antemano, o este enfoque simplemente conducirá a un desperdicio de recursos. - Si el bloqueo no se puede adquirir inmediatamente, una forma de evitar un posible punto muerto es introducir un tiempo de espera. Por ejemplo, la clase ReentrantLockdel SDK ofrece la posibilidad de establecer una fecha de vencimiento para el bloqueo. - Como vimos en el ejemplo anterior, no se produce un punto muerto si la secuencia de solicitudes no difiere entre los diferentes subprocesos. Esto es fácil de controlar si puede colocar todo el código de bloqueo en un método por el que deben pasar todos los subprocesos. En aplicaciones más avanzadas, incluso podría considerar implementar un sistema de detección de interbloqueos. Aquí deberá implementar alguna apariencia de monitoreo de subprocesos, en el que cada subproceso informe que ha adquirido exitosamente el bloqueo y está intentando adquirirlo. Si los subprocesos y los bloqueos se modelan como un gráfico dirigido, puede detectar cuando dos subprocesos diferentes contienen recursos mientras intentan acceder a otros recursos bloqueados al mismo tiempo. Si luego puede forzar a los subprocesos de bloqueo a liberar los recursos necesarios, puede resolver la situación de punto muerto automáticamente.
1.2 Ayuno
El programador decide qué subproceso en el estado EJECUTABLE debe ejecutarse a continuación. La decisión se basa en la prioridad del hilo; por lo tanto, los subprocesos con menor prioridad reciben menos tiempo de CPU en comparación con aquellos con mayor prioridad. Lo que parece una solución razonable también puede causar problemas si se abusa de ella. Si los subprocesos de alta prioridad se ejecutan la mayor parte del tiempo, entonces los subprocesos de baja prioridad parecen morir de hambre porque no tienen suficiente tiempo para hacer su trabajo correctamente. Por lo tanto, se recomienda establecer la prioridad de los subprocesos sólo cuando exista una razón de peso para hacerlo. Un ejemplo no obvio de falta de hilo lo da, por ejemplo, el método finalize(). Proporciona una forma para que el lenguaje Java ejecute código antes de que un objeto sea recolectado como basura. Pero si observa la prioridad del hilo de finalización, notará que no se ejecuta con la prioridad más alta. En consecuencia, la falta de subprocesos se produce cuando los métodos finalize() de su objeto pasan demasiado tiempo en relación con el resto del código. Otro problema con el tiempo de ejecución surge del hecho de que no está definido en qué orden los hilos atraviesan el bloque sincronizado. Cuando muchos subprocesos paralelos atraviesan algún código que está enmarcado en un bloque sincronizado, puede suceder que algunos subprocesos tengan que esperar más que otros antes de ingresar al bloque. En teoría, es posible que nunca lleguen allí. La solución a este problema es el llamado bloqueo "justo". Los bloqueos justos tienen en cuenta los tiempos de espera de los subprocesos al determinar a quién pasar a continuación. Un ejemplo de implementación de bloqueo justo está disponible en el SDK de Java: java.util.concurrent.locks.ReentrantLock. Si se utiliza un constructor con un indicador booleano establecido en verdadero, entonces ReentrantLock da acceso al hilo que ha estado esperando por más tiempo. Esto garantiza la ausencia del hambre pero, al mismo tiempo, conduce al problema de ignorar las prioridades. Debido a esto, los procesos de menor prioridad que a menudo esperan en esta barrera pueden ejecutarse con mayor frecuencia. Por último, pero no menos importante, la clase ReentrantLock solo puede considerar subprocesos que están esperando un bloqueo, es decir. Hilos que se lanzaron con suficiente frecuencia y alcanzaron la barrera. Si la prioridad de un subproceso es demasiado baja, esto no le sucederá con frecuencia y, por lo tanto, los subprocesos de alta prioridad seguirán pasando el bloqueo con más frecuencia.
2. Monitores de objetos junto con wait() y notify()
En la informática de subprocesos múltiples, una situación común es tener algunos subprocesos de trabajo esperando a que su productor cree algún trabajo para ellos. Pero, como aprendimos, esperar activamente en un bucle mientras se verifica un determinado valor no es una buena opción en términos de tiempo de CPU. Usar el método Thread.sleep() en esta situación tampoco es particularmente adecuado si queremos comenzar nuestro trabajo inmediatamente después de llegar. Para ello, el lenguaje de programación Java tiene otra estructura que se puede utilizar en este esquema: esperar() y notificar(). El método wait(), heredado por todos los objetos de la clase java.lang.Object, se puede utilizar para suspender el hilo actual y esperar hasta que otro hilo nos despierte usando el método notify(). Para funcionar correctamente, el hilo que llama al método wait() debe mantener un bloqueo que adquirió previamente utilizando la palabra clave sincronizada. Cuando se llama a wait(), el bloqueo se libera y el hilo espera hasta que otro hilo que ahora contiene el bloqueo llama a notify() en la misma instancia de objeto. En una aplicación multiproceso, naturalmente puede haber más de un hilo esperando notificación sobre algún objeto. Por lo tanto, existen dos métodos diferentes para activar subprocesos: notificar () y notificar a todos (). Mientras que el primer método activa uno de los subprocesos en espera, el método notifyAll() los activa a todos. Pero tenga en cuenta que, al igual que con la palabra clave sincronizada, no existe una regla que determine qué subproceso se activará a continuación cuando se llame a notify(). En un ejemplo simple con un productor y un consumidor, esto no importa, ya que no nos importa qué hilo se activa. El siguiente código muestra cómo se pueden usar wait() y notify() para hacer que los subprocesos del consumidor esperen a que un subproceso del productor ponga en cola un nuevo trabajo: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } El método main() inicia cinco subprocesos consumidores y un subproceso productor y luego espera a que finalicen. Luego, el subproceso productor agrega el nuevo valor a la cola y notifica a todos los subprocesos en espera que algo ha sucedido. Los consumidores obtienen un bloqueo de cola (es decir, un consumidor aleatorio) y luego se van a dormir, para ser activados más tarde cuando la cola esté llena nuevamente. Cuando el productor termina su trabajo, notifica a todos los consumidores para despertarlos. Si no hiciéramos el último paso, los subprocesos del consumidor esperarían eternamente hasta la siguiente notificación porque no establecimos un tiempo de espera para esperar. En su lugar, podemos usar el método de espera (tiempo de espera prolongado) para despertarnos al menos después de que haya pasado un tiempo.
2.1 Bloques sincronizados anidados con esperar() y notificar()
Como se indicó en la sección anterior, llamar a wait() en el monitor de un objeto solo libera el bloqueo en ese monitor. Otros bloqueos retenidos por el mismo hilo no se liberan. Como es fácil de entender, en el trabajo diario puede suceder que el hilo que llama a wait() mantenga el bloqueo aún más. Si otros subprocesos también están esperando estos bloqueos, puede ocurrir una situación de punto muerto. Veamos el bloqueo en el siguiente ejemplo: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } Como aprendimos anteriormente , agregar sincronizado a la firma de un método equivale a crear un bloque sincronizado(este){}. En el ejemplo anterior, agregamos accidentalmente la palabra clave sincronizada al método y luego sincronizamos la cola con el monitor del objeto de la cola para enviar este hilo a modo de suspensión mientras esperaba el siguiente valor de la cola. Luego, el hilo actual libera el bloqueo en la cola, pero no el bloqueo en esta. El método putInt() notifica al hilo inactivo que se ha agregado un nuevo valor. Pero por casualidad también agregamos la palabra clave sincronizada a este método. Ahora que el segundo hilo se ha quedado dormido, todavía mantiene el candado. Por lo tanto, el primer hilo no puede ingresar al método putInt() mientras el segundo hilo mantiene el bloqueo. Como resultado, nos encontramos en una situación de punto muerto y un programa congelado. Si ejecuta el código anterior, sucederá inmediatamente después de que el programa comience a ejecutarse. En la vida cotidiana, esta situación puede no ser tan obvia. Los bloqueos mantenidos por un subproceso pueden depender de los parámetros y condiciones encontrados en el tiempo de ejecución, y el bloque sincronizado que causa el problema puede no estar tan cerca en el código de donde colocamos la llamada wait(). Esto hace que sea difícil encontrar este tipo de problemas, especialmente porque pueden ocurrir con el tiempo o bajo una carga elevada.
2.2 Condiciones en bloques sincronizados
A menudo es necesario comprobar que se cumple alguna condición antes de realizar cualquier acción en un objeto sincronizado. Cuando tienes una cola, por ejemplo, quieres esperar a que se llene. Por lo tanto, puede escribir un método que verifique si la cola está llena. Si todavía está vacío, envía el hilo actual a dormir hasta que se despierte: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } El código anterior se sincroniza con la cola antes de llamar a wait() y luego espera en un bucle while hasta que aparece al menos un elemento en la cola. El segundo bloque sincronizado vuelve a utilizar la cola como monitor de objetos. Llama al método poll() de la cola para obtener el valor. Para fines de demostración, se genera una excepción IllegalStateException cuando la encuesta devuelve un valor nulo. Esto sucede cuando la cola no tiene elementos para recuperar. Cuando ejecute este ejemplo, verá que se lanza IllegalStateException con mucha frecuencia. Aunque sincronizamos correctamente usando el monitor de cola, se produjo una excepción. La razón es que tenemos dos bloques sincronizados diferentes. Imaginemos que tenemos dos hilos que han llegado al primer bloque sincronizado. El primer hilo entró al bloque y se quedó dormido porque la cola estaba vacía. Lo mismo ocurre con el segundo hilo. Ahora que ambos subprocesos están despiertos (gracias a la llamada notifyAll() llamada por el otro subproceso para el monitor), ambos ven el valor (elemento) en la cola agregado por el productor. Entonces ambos llegaron a la segunda barrera. Aquí el primer hilo ingresó y recuperó el valor de la cola. Cuando ingresa el segundo hilo, la cola ya está vacía. Por lo tanto, recibe nulo como valor devuelto por la cola y genera una excepción. Para evitar tales situaciones, debe realizar todas las operaciones que dependen del estado del monitor en el mismo bloque sincronizado: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } Aquí ejecutamos el método poll() en el mismo bloque sincronizado que el método isEmpty(). Gracias al bloque sincronizado, estamos seguros de que solo un hilo está ejecutando un método para este monitor en un momento dado. Por lo tanto, ningún otro hilo puede eliminar elementos de la cola entre llamadas a isEmpty() y poll(). Traducción continuada aquí .
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION