Concurrencia sin dolor en Java puro, parte 1

El miércoles 7 de septiembre tuvimos el honor de recibir una plática del Dr. Venkat Subramaniam, en una sala facilitada por SynergyJ y la comunidad SpringHispano.

El tema de la plática fue cómo manejar concurrencia en Java y estuvo muy, muy interesante.

Todos sabemos que el JDK ofrece varias facilidades para manejo de concurrencia, pero son de muy bajo nivel: la palabra clave   y los Locks, principalmente. Pero aquí se nos mostró cómo muy rápidamente se pueden complicar las cosas, primero por la cantidad de código que hay que escribir, segundo porque hay que conocer muy bien el modelo de memoria de la JVM y la manera en que maneja los Threads, y tercero porque hay muchas cosas que pueden salir mal si ponemos un   en el lugar equivocado, o si olvidamos ponerlo, etc. No sólo deadlocks, sino también livelocks: cuando un hilo se queda esperando a que otro hilo libere un recurso para poder continuar. Esto realmente va en contra de la concurrencia; es un desperdicio tener hilos esperando indefinidamente a que otros hilos liberen los recursos que requieren.

El ejemplo fue con algo muy simple: Una transferencia de saldo entre dos cuentas. El objeto Cuenta es más o menos así:

 

Evidentemente esta es la versión que no tiene absolutamente nada para manejo de concurrencia y por lo tanto no puede ser utilizada en ambientes concurrentes: qué pasa si dos hilos quieren retirar saldo al mismo tiempo, o hacer depósitos al mismo tiempo?

Manejo de concurrencia JDK

Desde sus inicios, Java tenía algunas facilidades para manejo de concurrencia, la palabra clave  .

Los que ya hemos hecho aplicaciones que manejan concurrencia, pensamos luego luego: "ah pues fácil, hay que ponerle   a todo!" Bueno, no a todo, pero sí lo necesita en un par de lugares, no?

 

Y ya, no? Pues no. El método   no está sincronizado; pensamos que no importa, porque sólo está devolviendo un valor, no lo va a modificar; sin embargo, por no sincronizar este método, el valor devuelto puede ser ya inválido, porque hay un hilo que ya está modificando el saldo y si en este instante otro hilo pide el saldo, obtendrá un valor que ya no es el actual. Ya no refleja la realidad. Por lo tanto, necesitamos ponerle también   al método  .

Y una vez teniendo eso, podemos implementar el método de transferencia, en otra clase:

 

El código anterior tiene varios problemas: Qué pasa si dos hilos o más, lo invocan al mismo tiempo, usando la misma cuenta origen, o la misma destino, o uno usa la cuenta X como origen y otro la usa como destino? Sólo un hilo debería poder hacer la operación completa a la vez. Por lo tanto necesitamos agregar unos candados también en este método de transferencia:

 

Rápidamente ya se complicó el código bastante. Tenemos manera de estar seguros de que funciona? No es fácil realizar pruebas sobre este código; con un hilo seguramente funcionará bien; con dos hilos tal vez funcione bien, en un procesador de un solo CPU; en un multicore, tal vez funcione bien. En producción sabremos, tal vez, si nunca nos reportan un problema.

La realidad es que el código sigue teniendo varios problemas. Uno es que si hay muchos hilos queriendo hacer depósitos a la misma cuenta (imagínense durante un Teleton o algo así), todos se van a hacer de manera secuencial, y el sistema se va a sentir muy lento, porque todos los hilos que quieren hacer transferencia a la misma cuenta están esperando a que uno pueda obtener el candado sobre el destino para realizar el depósito; sólo uno a la vez podrá modificar ese saldo. Esto es porque   no tiene un timeout.

Y además, la realidad es que el método de transferencia puede atorarse en un deadlock, si dos hilos intentan al mismo transferir uno de la cuenta A a la B y otro de la cuenta B a la A.

Con esto nos damos cuenta que   realmente nos sirve sólo para controlar la concurrencia, no para aprovecharla. Qué otras opciones ofrece el JDK?

Concurrencia con JDK a partir de Java 5

Los Locks son objetos introducidos en Java 5, que realizan una función similar a   pero pueden tener un timeout y pueden fallar en caso de no obtener el recurso. Reimplementemos la clase Cuenta usando Locks:

 

Tuvimos que definir el   y agregar 5 líneas de código a cada método, para poder adquirir el candado, con una espera máxima de un segundo, para realizar la operación. Si no se puede obtener el candado en un segundo, la operación falla (por eso cambiamos la firma de los métodos a  ). Y todavía falta el método de transferencia:

 

Este código funciona mucho mejor, ya no habrá esperas infinitas, y no habrá deadlocks, ambos problemas se solucionan con el timeout. Pero, ahora tenemos mucho más código que mantener, y sigue siendo algo muy difícil de probar, y lo peor de todo es que no hay manera de saber si esto va a fallar o no.

En las siguientes dos partes veremos dos alternativas para manejo de concurrencia que nos pueden ayudar mucho, porque no solamente nos ahorran todo este código tan susceptible de tener errores, sino que nos evitan el tener que estar sincronizando datos, lo cual hace que el desempeño de las aplicaciones sea mucho mejor, ya que podremos aprovechar la concurrencia, no solamente frenarla con candados.

Segunda parte: STM

Tercera parte: Actores

Opciones de visualización de comentarios

Seleccione la forma que prefiera para mostrar los comentarios y haga clic en «Guardar las opciones» para activar los cambios.
Imagen de Abaddon

Excelente!

Excelente idea hacer esta serie de blogs sobre la platica de ayer que estuvo muuuuy interesante

Saludos

Imagen de ezamudio

tnx

Y espero pronto poder hacer las otras dos reseñas, tengo el puro outline pero ya me lanzo a SGCE, a ver si mañana y el fin termino, mientras lo tengo fresco...

Imagen de clausia

Muy bueno

Muchas gracias por compartir este conocimiento, ya quiero ver las otras dos reseñas :D

Imagen de Algus Dark

Muchas gracias por compartir

Muchas gracias por compartir ésto para los que no pudieron atender el opebtalks. Espero con ansias las entregas restantes.

Saludos.

Imagen de Edgardini

Interesante...

Por lo que veo estuvo muy buena la charla, espero con ansias la continuación del post.
Thanks @ezamudio =]

Imagen de ingegil

Excelente!

Por andar en el SG no alcanzamos a llegar... Y supimos que estuvo de lujo. Muchisimas gracias por compartir y esperamos las otras dos entregas!

ohh... ya que se estaba

ohh... ya que se estaba poniendo bueno.

Muy interesante ezamudio y que bueno que re-transmites esto que aprendiste con la presentación de Venkat :)

Supongo ( sin tener idea de que más se trato la charla ) que una de las alternativas será tener objetos sin inmutables ( como se describe en Effective Java Item 15: Minimize mutability )

 

Donde al no poder mutar la clase, no necesitamos ya sincronizar nada y en vez de eso se crean y crean nuevos objetos. De forma similar a como funcionan la clase   y   .. pero bueno mejor no me adelanto y espero a que esté la segunda entrega.

...

...

Ya?

...

Ya esta?

:P

Saludos!

Imagen de Sr. Negativo

Segunda entrega

Al igual que Oscar (no desesperes jajaja) también espero la segunda parte.

La explicación de @ezamudio estuvo muy bien.

?:)

Imagen de mdrmtz

Spoiler

Clojure ...
Akka ...

Imagen de benek

Re: ohh... ya que se estaba

Emm... el modificador final no hace una clase inmutable, solamente hace que no se pueda extender.

Las dos alternativas que propuso Venkat son sublimes, esperemos pronto los siguientes posts. :-)

"...Emm... el modificador

"...Emm... el modificador final no hace una clase inmutable, solamente hace que no se pueda extender"

Claro que no, es es solo la primera parte, la segunda parte es que el atributo   no se puede modificar y la tercera parte es que los métodos   y   no cambian el estado del objeto ( justo como   no modifica un String ).

Con estas modificaciones el objeto se puede pasear entre hilos sin mayor preocupación, el problema viene para sincronizarlo después.

Pero bueno bueno.. mejor será esperar a la segunda parte en vez de desviar el hilo :)

Imagen de greeneyed

Un pequeño detalle...

El ejemplo de usar synchronized y olvidarse de ponerlo en getSaldo() como algo incorrecto no es cierto. Un getter que no hace nada mas que devolver un valor primitivo no necesita synchronized puesto que la operacion es "atomica" y el valor que devuelve no depende de nada que pueda estar fuera de sincronía.
Para tener un problema necesitas usar al menos dos veces el valor y que se modifique y sea invalido entre que lo usas una vez u otra, a no ser que entres en cuestion de si la copia en local del valor de un Thread y memoria, que si volatile etc.

Yo más que plantearlo como "antes de Java 5 no se podia y ahora sí"... antes se podía hacer "lo mismo" que ahora, pero el código te lo tenias que currar tú, con la posibilidad de introducir errores por que no es trivial, y ahora te lo dan hecho. Pero antes de Java 5 la gente se hacia sus monitores y sus semaforos y zonas protegidas, lo que pasa es que no bastaba con poner synchronized alegremente y en el caso de tener que sincronizar dos objetos, como en el ejemplo, el código dista mucho de ser trivial ni corto.

@greeneyed Si.. es demasiado

@greeneyed Si.. es demasiado apretado pero es correcto afirmar que   puede ser un problema usando concurrencia así como está porque si un hilo tiene el monitor del objeto y está modificando el valor, otro hilo puede leer ese valor en ese momento y obtener un número incorrecto, saltandose por completo el control que ofrece el monitor.

Aunque sinceramente no podría explicar como o porque puede pasar esto, sé que se puede, aunque la verdad nunca lo he visto demostrado, si alguien puede o tiene una referencia de como   es non-thread safety, me encantaría ver como, pero es una de esas cosas que es dificil de replicar por el tema de los procesadores y el scheduling y etc.

De hecho es una de las razones por las que se introdujo en Java 1.5 el paquete java.util.concurrent.atomic ( o era solamente para tener versiones libres de candados?

La alternativa podría ser utilizar AtomicInteger

Creo que esta podría ser una buena oportunidad para entender porque ( y en que contexto ) este tipo de operaciones no son seguras de usar con hilos. Ahora nomás falta encontrar quien nos lo explique :)

Imagen de greeneyed

No, otro Thread no puede

No, otro Thread no puede "estar modificando el valor mientras getSaldo() devuelve el suyo" por que un return es solo una operación. Hilando fino si puede haber distintos valores de la misma variable en distintos threads () pero no es por que distintos Threads modifiquen la misma variable a la vez si no por que tienen copias de la variable (y el problema te pasaría hasta sin hacer un get) y por eso la solución pasa por hacer la variable volatile. Usar synchronized también vale, pero es matar moscas a cañonazos para un misero get, y te queda el problema de que pasa si accedes directamente a la variable.

Otro tema es que dependas de ese valor para hacer otra cosa y que otro Thread modifique el saldo mientras tu te fías de lo que te devolvió la primera vez getSaldo(). Pero eso es un problema del metodo que usa el valor de getSaldo() dos veces sin sincronizar, no de getSaldo() y además no lo solucionas de ninguna manera sincronizando getSaldo().
Synchronized es para proteger lo que estas haciendo de manipulaciones mientras haces lo que sea. Un único return no hace nada que pueda ser manipulable, así que un synchronised no sirve "de nada" en este caso.

El paquete atomic es para "hacer una operacion y devolver" como addAndGet() etc. Eso no es un simple return, son tres operaciones y por lo tanto si puede haber interferencias de otros Threads.
Es decir, en este caso tu haces:
----
saldo = saldo + delta
return saldo.
---
Entre la suma y la asignación y la asignación y el return otro Thread puede haber modificado saldo si no lo proteges. Y además tratan las variables como volatile así que te evitas también el problema de las copias locales de Threads.

Así que el get() te puede dar problemas, pero no por que se de una "race condition" si no por las copias locales de los Threads, y el paquete atomic es para resolver otro problema, ese si cuando hay más de una operación atómica implicada.

Imagen de ezamudio

Barrera de memoria

El detalle del synchronized en el getter reside en el modelo de memoria de Java. La explicación es extensa y compleja, mejor les dejo esta liga donde lo explican muy bien, busquen la sección de Visibility donde se habla de las condiciones bajo las cuales se garantiza que los cambios hechos a una variable en un hilo serán visibles a los otros hilos.

El synchronized en el getter sirve para forzar el cruce de la barrera de memoria (ver la liga).

Según yo, no mencioné que hubiera race condition en el getter. Simplemente que se puede obtener un valor no actualizado de la variable, por lo que menciona greeneyed de que cada hilo tiene su copia de ese valor (a menos que se ponga volatile, etc).

Hay soluciones como usar volatile, o un AtomicInteger, etc. Hay alternativas. Pero no dejan de ser complicadas y delicadas de manejar y hay que saber perfectamente bien lo que se está haciendo (en las otras soluciones también pero no a nivel tan bajo), y el problema de fondo sigue siendo que cuando el software falla, falla de manera silenciosa y además la falla no es determinística, no podemos saber si el código va a fallar o no a simple vista, tenemos que revisar muy bien y a veces ni así podemos estar seguros.

Imagen de benek

Re: "...Emm... el modificador

Ah, yo pensé que decías que "con solo poner el modificador final a la clase"... meh... :-)