paint-brush
Una guía para volver a aprender las primitivas de subprocesos de Javapor@shai.almog
761 lecturas
761 lecturas

Una guía para volver a aprender las primitivas de subprocesos de Java

por Shai Almog8m2023/04/11
Read on Terminal Reader

Demasiado Largo; Para Leer

Synchronized fue revolucionario y todavía tiene grandes usos. Pero es hora de pasar a primitivos de subprocesos más nuevos y, potencialmente, repensar nuestra lógica central.
featured image - Una guía para volver a aprender las primitivas de subprocesos de Java
Shai Almog HackerNoon profile picture

He codificado en Java desde la primera versión beta; incluso en ese entonces, los hilos estaban en la parte superior de mi lista de características favoritas. Java fue el primer lenguaje en introducir soporte de subprocesos en el propio lenguaje; fue una decisión controvertida en ese entonces.


En la última década, todos los lenguajes se apresuraron a incluir async/await, e incluso Java tenía algún soporte de terceros para eso ... Pero Java zigzagueó en lugar de zagging e introdujo los subprocesos virtuales muy superiores (proyecto Loom) . Esta publicación no se trata de eso.


Creo que es maravilloso y demuestra el poder central de Java. No solo como lengua, sino como cultura. Una cultura de cambios deliberados en lugar de precipitarse en la tendencia de moda.


En esta publicación, quiero volver a visitar las antiguas formas de hacer subprocesos en Java. Estoy acostumbrado a synchronized , wait , notify , etc. Pero ha pasado mucho tiempo desde que fueron el enfoque superior para enhebrar en Java.


Soy parte del problema; Todavía estoy acostumbrado a estos enfoques y me resulta difícil acostumbrarme a algunas API que existen desde Java 5. Es una fuerza del hábito. T


Hay muchas API excelentes para trabajar con subprocesos que analizo en los videos aquí, pero quiero hablar sobre bloqueos que son básicos pero importantes.

Sincronizado vs ReentrantLock

Una renuencia que tuve con dejar sincronizado es que las alternativas no son mucho mejores. La principal motivación para dejarlo hoy es que, en este momento, sincronizado puede desencadenar la fijación de hilos en Loom, lo cual no es ideal.


JDK 21 podría arreglar esto (cuando Loom pase a GA), pero aún tiene sentido dejarlo.


El reemplazo directo de sincronizado es ReentrantLock. Desafortunadamente, ReentrantLock tiene muy pocas ventajas sobre la sincronización, por lo que el beneficio de migrar es dudoso en el mejor de los casos.


De hecho, tiene una gran desventaja; para tener una idea de eso, veamos un ejemplo. Así es como usaríamos sincronizado:

 synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }


La primera desventaja de ReentrantLock es la verbosidad. Necesitamos el bloque try ya que si ocurre una excepción dentro del bloque, el bloqueo permanecerá. Manijas sincronizadas que sin problemas para nosotros.


Hay un truco que algunas personas usan para envolver la cerradura con AutoClosable que se ve más o menos así:

 public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }


Tenga en cuenta que no implemento la interfaz de bloqueo que hubiera sido ideal. Esto se debe a que el método de bloqueo devuelve la implementación de cierre automático en lugar de void .


Una vez que hagamos eso, podemos escribir un código más conciso como este:

 try(LOCK.lock()) { // safe code }


Me gusta la verbosidad reducida, pero este es un concepto problemático ya que probar con recursos está diseñado con el propósito de limpiar y reutilizar bloqueos. Está invocando close, pero volveremos a invocar ese método en el mismo objeto.


Creo que sería bueno ampliar la sintaxis de prueba con recursos para admitir la interfaz de bloqueo. Pero hasta que eso suceda, este podría no ser un truco que valga la pena.

Ventajas de ReentrantLock

La principal razón para usar ReentrantLock es la compatibilidad con Loom. Las otras ventajas son agradables, pero ninguna de ellas es una "función excelente".


Podemos usarlo entre métodos en lugar de en un bloque continuo. Esta es probablemente una mala idea ya que desea minimizar el área de bloqueo y la falla puede ser un problema. No considero esa característica como una ventaja.


Tiene la opción de equidad. Esto significa que atenderá primero el primer subproceso que se detuvo en un bloqueo. Traté de pensar en un caso de uso realista y no complicado en el que esto sea importante, y estoy dejando espacios en blanco.


Si está escribiendo un programador complejo con muchos subprocesos constantemente en cola en un recurso, puede crear una situación en la que un subproceso se "hambre" ya que siguen llegando otros subprocesos. Pero estas situaciones probablemente se atiendan mejor con otras opciones en el paquete de concurrencia. .


Tal vez me estoy perdiendo algo aquí...


lockInterruptibly() nos permite interrumpir un hilo mientras espera un bloqueo. Esta es una característica interesante, pero nuevamente, es difícil encontrar una situación en la que realmente haga una diferencia.


Si escribe código que debe ser muy receptivo para interrumpir, necesitará usar la API lockInterruptibly() para obtener esa capacidad. Pero, ¿cuánto tiempo pasa en promedio dentro del método lock() ?


Hay casos extremos en los que esto probablemente importa, pero no es algo con lo que la mayoría de nosotros nos encontremos, incluso cuando hacemos un código avanzado de subprocesos múltiples.

LeerEscribirReentranteBloquear

Un enfoque mucho mejor es ReadWriteReentrantLock . La mayoría de los recursos siguen el principio de lecturas frecuentes y pocas operaciones de escritura. Dado que la lectura de una variable es segura para subprocesos, no es necesario un bloqueo a menos que estemos en el proceso de escribir en la variable.


Esto significa que podemos optimizar la lectura hasta el extremo y hacer que las operaciones de escritura sean un poco más lentas.


Suponiendo que este sea su caso de uso, puede crear un código mucho más rápido. Cuando trabajamos con un bloqueo de lectura y escritura, tenemos dos bloqueos; un bloqueo de lectura como podemos ver en la siguiente imagen. Permite múltiples subprocesos y es efectivamente un "gratis para todos".


Una vez que necesitamos escribir en la variable, necesitamos obtener un bloqueo de escritura como podemos ver en la siguiente imagen. Intentamos solicitar el bloqueo de escritura, pero todavía hay subprocesos leyendo de la variable, por lo que debemos esperar.


Una vez que los subprocesos terminen de leerse, toda la lectura se bloqueará y la operación de escritura puede ocurrir desde un solo subproceso solo como se ve en la siguiente imagen. Una vez que liberemos el bloqueo de escritura, volveremos a la situación de "gratis para todos" en la primera imagen.


Este es un patrón poderoso que podemos aprovechar para hacer que las cobranzas sean mucho más rápidas. Una lista sincronizada típica es notablemente lenta. Se sincroniza sobre todas las operaciones, lectura o escritura. Tenemos una CopyOnWriteArrayList que es rápida para leer, pero cualquier escritura es muy lenta.


Suponiendo que pueda evitar la devolución de iteradores de sus métodos, puede encapsular operaciones de lista y usar esta API.


Por ejemplo, en el siguiente código, exponemos la lista de nombres como de solo lectura, pero luego, cuando necesitamos agregar un nombre, usamos el bloqueo de escritura. Esto puede superar fácilmente a las listas synchronized :

 private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }

Cerradura Estampada

Lo primero que debemos entender sobre StampedLock es que no es reentrante. Digamos que tenemos este bloque:

 synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }

Esto funcionará. Dado que sincronizado es reentrante. Ya mantenemos el candado, por lo que entrar en methodB() desde methodA() no se bloqueará. Esto también funciona con ReentrantLock, suponiendo que usemos el mismo bloqueo o el mismo objeto sincronizado.


StampedLock devuelve un sello que usamos para liberar el bloqueo. Por eso, tiene algunos límites. Pero sigue siendo muy rápido y potente. También incluye un sello de lectura y escritura que podemos usar para proteger un recurso compartido.


Pero a diferencia de ReadWriteReentrantLock, nos permite actualizar el bloqueo. ¿Por qué tendríamos que hacer eso?

Mire el método addName() de antes... ¿Qué pasa si lo invoco dos veces con "Shai"?


Sí, podría usar un Conjunto... Pero para el objetivo de este ejercicio, digamos que necesitamos una lista... Podría escribir esa lógica con ReadWriteReentrantLock :

 public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }


Esto apesta. "Pagué" por un bloqueo de escritura solo para verificar contains() en algunos casos (suponiendo que haya muchos duplicados). Podemos llamar isInList(name) antes de obtener el bloqueo de escritura. Entonces haríamos:


  • Coge el candado de lectura


  • Liberar el bloqueo de lectura


  • Agarra el bloqueo de escritura


  • Liberar el bloqueo de escritura


En ambos casos de acaparamiento, es posible que estemos en cola y que no valga la pena la molestia adicional.


Con StampedLock , podemos actualizar el bloqueo de lectura a un bloqueo de escritura y hacer el cambio en el lugar si es necesario como tal:

 public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }

Es una potente optimización para estos casos.

Finalmente

Cubro muchos temas similares en la serie de videos anterior; échale un vistazo y déjame saber lo que piensas.


A menudo busco las colecciones sincronizadas sin pensarlo dos veces. Eso puede ser razonable a veces, pero para la mayoría, probablemente sea subóptimo. Si dedicamos un poco de tiempo a las primitivas relacionadas con subprocesos, podemos mejorar significativamente nuestro rendimiento.


Esto es especialmente cierto cuando se trata de Loom, donde la disputa subyacente es mucho más delicada. Imagine escalar operaciones de lectura en 1 millón de subprocesos simultáneos... En esos casos, la importancia de reducir la contención de bloqueo es mucho mayor.


Podría pensar, ¿por qué las colecciones synchronized no pueden usar ReadWriteReentrantLock o incluso StampedLock ?


Esto es problemático ya que el área de superficie de la API es tan grande que es difícil optimizarla para un caso de uso genérico. Ahí es donde el control sobre las primitivas de bajo nivel puede marcar la diferencia entre un alto rendimiento y un código de bloqueo.