Concurrencia sin dolor en Java puro, parte 2
En el post anterior, ya vimos lo difícil que es manejar concurrencia en Java, cuando usamos únicamente las herramientas que nos ofrece el JDK. Algunos llaman a este modelo synchronize and suffer.
En esta segunda parte, veremos una alternativa, implementada desde hace mucho tiempo en Lisp, y recientemente en Clojure, un lenguaje alterno para la JVM basado en Lisp. Este modelo de llama memoria transaccional, o STM, Software Transactional Memory.
Clojure
Clojure es un lenguaje alternativo para la JVM, basado en Lisp. En Clojure no hay variables, todo es inmutable. Sí, otra vez: en Clojure no hay variables. O si de plano no pueden concebir algo así, entonces pueden verlo de esta forma: en Clojure, todas las variables son inmutables. Es decir, una vez que asignan un valor a una variable, no se puede volver a modificar. Cuando dicen
, ya no pueden posteriormente cambiar el valor de
(por lo tanto, si ya no pueden variar, no se pueden llamar variables... se llaman valores).
Los problemas de estar sincronizando hilos vienen por la mutabilidad de las variables; si todos los valores que manejamos son inmutables, entonces no hay nada que sincronizar. El ejemplo que dio Venkat aquí es que si vemos por ejemplo el valor de las acciones de una empresa a la 1PM, ese valor nunca cambia, es inmutable. Si lo volvemos a ver a las 3PM, puede ser distinto del valor de la 1PM, pero el valor que tenía a la 1PM sigue siendo el mismo.
Pero bueno, en Java tenemos mutabilidad y la tenemos por todas partes. De modo que en ocasiones debemos sincronizar. Pero la manera en que sincronizamos en Java es muy ineficiente; cuando usamos
o Locks, lo que hacemos es frenar a un hilo antes de leer un recurso, sin siquiera saber si hay otros hilos que quieran usar ese mismo recurso al mismo tiempo. ¿No sería lindo si sólo se tuviera que sincronizar cuando va a haber una colisión?
Software Transactional Memory
Lo de Clojure viene a colación porque precisamente tiene un mecanismo para manejar esas colisiones. Se llama STM y lo que hace es similar a las transacciones que conocemos en base de datos, pero en memoria. Lo que nos permite STM es modificar un valor (es decir, tener mutabilidad), pero únicamente dentro de una transacción, y al final se hace commit de la transacción; la primera transacción en modificar el valor será exitosa, pero si hay otras transacciones queriendo modificar ese mismo valor, fallarán (los mecanismos de STM aseguran que se van a serializar dichas transacciones, cuando afecten los mismos recursos). Pero, no van a fallar y darnos un error y ya; STM también tiene un mecanismo para reintentar las transacciones, leyendo de nueva cuenta los valores actualizados de los recursos involucrados.
Con el ejemplo del saldo: si dos hilos quieren modificar el saldo de la misma cuenta al mismo tiempo, lo hacen dentro de una transacción; la primera transacción que haga commit, será exitosa. La segunda fallará porque el valor del saldo ya fue modificado, pero entonces se reintenta la misma de forma automática, tomando el valor actualizado del saldo. Si estamos incrementando el saldo pues no habrá problema; si estamos decrementando y hay una validación de que sea mayor al monto, puede que esa validación pase o no pase, pero la comparación se hará contra el valor más actualizado del saldo, como lo haya dejado la transacción que sí se pudo ejecutar. Si no se puede hacer commit porque hubo otra transacción que modificó el valor, entonces se reintenta nuevamente. STM reintenta cada transacción hasta cien mil veces; la filosofía es que si en cien mil intentos no se pudo realizar una transacción, es porque pues... no le tocaba realizarse.
Otra cosa que valida STM es que los valores que se van a modificar, únicamente se pueden modificar dentro de una transacción. Si alguien intenta modificar un valor fuera de una transacción, se arroja una excepción. Pero para leer estos valores, no hay problema; no tenemos que abrir una transacción de manera explícita, porque STM nos maneja una microtransacción para leer ese valor y tener lo más actualizado. Con todo eso, tenemos lo siguiente:
- Evitamos estar sincronizando de manera innecesaria cuando sólo un hilo está usando un recurso.
- Evitamos tener que manejar la sincronización manualmente, STM se encarga de ello por nosotros y sólo lo hará cuando sea necesario.
- Tenemos la garantía de que al leer un valor que puede estar siendo modificado por otros hilos, tenemos el valor más reciente, posterior a la última modificación (y obviamente previo a la siguiente); evitamos así el problema de que cada hilo tenga una copia de ese valor, que puede o no estar actualizada.
- Las operaciones que fallen porque hubo modificaciones previas, serán reintentadas automáticamente.
...y Java?
Pero, no se supone que todo esto era para ver concurrencia en Java? qué bonito lo que haga Clojure, pero... y Java?
Bueno, pues resulta que podemos usar STM desde Java. Lo único que tenemos que hacer es agregar clojure.jar a nuestro classpath, para poder utilizar STM, con lo que la clase de Cuenta quedaría así (vamos explicando por partes):
Primero que nada, vemos que ahora el saldo ya no es un int, sino una instancia de
, que es una clase de Clojure para manejar objetos inmutables. El constructor de Ref marca que arroja Exception, por eso ahora el constructor de Cuenta arroja Exception. Y como pueden ver, el objeto Ref se crea con el saldo, pero el constructor está declarado con un tipo Object, de modo que lo que va a ocurrir aquí es que entra en acción el autoboxing de Java 5 y el int se convertirá en un Integer, que es un objeto inmutable.
El objeto Ref puede envolver cualquier objeto, pero es responsabilidad del programador asegurarse que el objeto envuelto sea inmutable, de otro modo no sirve de nada estar usando STM.
Ahora, veamos los métodos para modificar y consultar el saldo:
Como podemos ver, ahora los métodos arrojan Exception, debido a que así viene declarado el método
. Este método puede recibir un closure en Clojure, Groovy, Scala, etc pero en Java no tenemos closures aún, así que tenemos que pasar un Callable. El parámetro
se marca como
para que pueda ser utilizado dentro de dicho Callable.
Entonces, el patrón consiste en que la operación que se va a realizar sobre del dato (el saldo en este caso), se debe realizar dentro de la transacción. Vemos el método
, que nos devuelve el valor almacenado, el cual sabemos que es un Integer; hacemos la suma o resta y nuevamente almacenamos el resultado en nuestro Ref. En el caso del retiro, si el saldo es insuficiente, arrojamos una RuntimeException. Esto es muy importante, porque cuando la transacción arroja una RuntimeException, se cancela, restaurando el Ref a su valor precio a que comenzara la misma.
El método para consultar saldo no requiere de una transacción porque únicamente está leyendo el valor; internamente el método
hará lo necesario para asegurarnos que el valor devuelto es el más actual y el mismo en todos los hilos. A fin de cuentas, con STM tenemos un mecanismo ACI, similar al ACID de las bases de datos: atomicidad, consistencia, aislamiento (Isolation), pero no Durabilidad porque todo esto es únicamente en memoria.
La transferencia de saldo también requiere una transacción, para que el valor del saldo sea consistente durante la misma. Pero los métodos tienen su propia transacción; eso no es un problema, ya que al detectarse que hay ya una transacción abierta en el hilo, se utilizará en vez de crear una nueva. El commit es implícito; si la ejecución del Callable termina bien, se hará commit (en el caso de la transferencia se hará hasta que termine la transacción "externa"):
Aquí hay ahora una diferencia muy importante: el código se ha simplificado bastante, puesto que lo único que estamos haciendo dentro de la transacción son las dos operaciones más fundamentales, simplemente ponerle dinero a una cuenta y quitárselo a la otra. Y en ese orden, además; normalmente tendríamos que estar mal de la cabeza para hacer algo así, porque le estamos depositando a una cuenta y después retirando de la otra, lo cual podría llevarnos a la quiebra si la segunda operación falla; sin embargo, debido a que esto se ejecuta en una transacción, el saldo del destino todavía no ha sido modificado de manera definitiva. La verificación de fondos se hace en el método
, el cual arroja una RuntimeException al fallar, de modo que toda la transacción se cancela cuando no hay fondos suficientes, y los saldos quedarían entonces intactos, ya que se hace rollback y se restauran los datos a como estaban antes de comenzar la transacción. No hay reintento porque el rollback se dio a causa de condiciones internas de las operaciones que realizamos; el reintento se da únicamente cuando al querer hacer el commit, el sistema se da cuenta de que ya los datos han sido modificados por otra transacción.
Transacciones puras
Un detalle importante en la implementación de las transacciones es que éstas deben tener código puro, esto significa que el código no debe realizar ninguna operación que tenga efectos colaterales fuera de la transacción, como por ejemplo enviar un correo, dejar un log, etc. Eso se tiene que realizar al final, fuera de la transacción. La razón es que al haber la posibilidad de los reintentos, ese código puede ejecutarse varias veces. Por tanto, si por ejemplo hacemos un println o enviamos un correo o cualquier aviso al exterior de "ya se hizo el depósito" por ejemplo, y a la mera hora la transacción falla (ya sea por condiciones posteriores en transacciones externas, o por el mecanismo de STM de que se detectaron modificaciones a los datos desde otras transacciones), ese mensaje puede potencialmente ser enviado un montón de veces. STM nos garantiza que nuestro código contenido en la transacción se ejecutará con datos consistentes, pero eso implica que tal vez haya varios reintentos, por lo que es importante recalcar que no se debe incluir ningún código que tenga efectos colaterales fuera de la transacción.
Bueno, pues tal vez no sea muy evidente a primera vista, pero este mecanismo es más simple que estar manejando candados y estar metiendo bloques de
en nuestro código, pero sobretodo tiene la ventaja de que es mucho más eficiente, debido a que no hará bloqueos innecesarios sobre recursos compartidos y el mecanismo de reintentos nos permite olvidarnos de preocupaciones acerca de la consistencia de los datos.
En la tercera parte de esta serie, veremos una alternativa más: el modelo de actores, que es algo que otros lenguajes tienen ya integrado pero que podemos usar en Java gracias a algunas bibliotecas externas.
- ezamudio's blog
- Inicie sesión o regístrese para enviar comentarios
Bravo!!!! Muy buena
Bravo!!!!
Muy buena explicación @ezamudio
Lo único que sí da como penita ajena, es que ya otros lenguajes manejen éste tipo de cuestiones transaccionales, de forma transparente, y Java en sí mismo tenga que recurrir o, al quebradero de cabeza del programador, o en su defecto a bibliotecas externas, en éste caso Clojure, y apuesto que lo del modelo de actores está en Scala...
Gracias por tu reseña!
Decir que Java no tiene STM es como decir que ....
Decir que Java no tiene STM es como decir que el lenguaje Java no tiene Entrada/Salida, y realmente no "tiene" el lenguaje ninguna de las dos cosas, como no la tienen muchos lenguajes.
Una cosa es el lenguaje y otra cosa las cosas que se hacen para ese lenguaje. Por ejemplo, el lenguaje Pascal tiene las operaciones de entrada salida incorporadas en la definición del lenguaje y no es necesario escribir bibliotecas de funciones para las operaciones básicas de I/O porque ellas son parte del propio lenguaje.
Java no tiene incorporado en el lenguaje las operaciones de entrada salida. Por eso es necesario todo lo que contiene el paquete java.io para realizar esas operaciones. Son cosas que se hacen "externas" al lenguaje.
Existen igualmente para Java paquetes para las técnicas de STM y el modelo de actores.
Igual pasa con Scala; su modelo de actores está implementado en clases, traits, etc. y no es parte del lenguaje.
¿Por qué aparecen en otros lenguajes antes que Java estos modelos?
Una posible razón es que Java sí incorpora en el lenguaje y en su máquina virtual un modelo de concurrencia basado en mutexes y condiciones de sincronización, modelo que sigue siendo útil para muchas aplicaciones y que es usado por los sistemas operativos, donde la concurrencia es necesaria.
precisamente
En Clojure lo de STM tampoco es parte integral del lenguaje; hay unas funciones para manejarlo, pero pues todo fue diseñado para que sea muy fácil de usar en Clojure. La idea es que podemos usar el STM de Clojure en Java, que si como bien menciona bferro tiene un modelo de concurrencia que sigue siendo útil para aplicaciones y refleja el que usan los sistemas operativos, el argumento aquí es que ese modelo resulta de muy bajo nivel para muchas aplicaciones. Es demasiada talacha utilizarlo directamente, por eso Subramaniam promueve estas alternativas que facilitan las cosas y que además, por ejemplo en este caso de STM, cuando fallan, lo hacen de forma determinística, lo que permite realizar pruebas unitarias que nos van a dar la certeza de que cuando pasen (estando bien hechas, etc), ese código funcionará bien en un ambiente concurrente, a diferencia del código que hagamos con las puras herramientas del JDK, que puede pasar pruebas unitarias pero tal vez provoque un deadlock en producción, o tenga un muy mal desempeño porque está serializando la operación y deja un montón de hilos esperando indefinidamente.
synchronized
Les dejo una frase que mencionó Subramaniam en la plática: The 'synchronized' keyword is an insult to concurrency. Dice esto por algo que mencionó y repetí en la primera parte, de que
no está hecho para aprovechar o para habilitar realmente la concurrencia, sino para controlarla, para frenarla, para evitar que varios hilos utilicen el mismo recurso al mismo tiempo, de la manera más simple y rudimentaria.
La frase es muy radical
La frase de Subramaniam es muy radical, y es solamente eso, una frase. Sería bueno preguntarle a los que diseñan sistemas operativos si creen que el modelo basado en mutexes es un insulto a la concurrencia.
Con esto no quiero decir que los modelos de actores (más viejo que Matusalén) o de memoria transaccional no sean modelos que alivian varios de los problemas, pero no todos.
Hay que recordar que además de lo complejo que es implementar una solución de forma concurrente, más complejo es a veces idear esa solución.
la frase
Pero la platica no era acerca de manejar concurrencia en sistemas operativos... era en la JVM, orientado a aplicaciones.
Comentario repetido...
Uhm... al parecer este comentario se repitió dos veces...
No dejan de ser puntos de vista...
Decidí mejor convertir este comentario en blog post
Alguien podría poner los
Alguien podría poner los mismos ejemplos pero en Clojure directamente? Sería interesante para compararlos.
Efectivamente, estos y muchas otras funcionalidad no vienen directo de la caja en Java y la JVM tampoco las tiene por lo tanto todos los lenguajes que corren sobre la JVM los tienen que emular con bibliotecas.
Un ejemplo muy claro son los closures en Groovy y Scala ( y Ryz :P ) donde el bytecode generado es el exactamente el de una clase anónima, ni más ni menos. De la misma manera este STM debe de ser construído de alguna forma utilizando bytecode puro.
Lo importante es que estos nuevo lenguajes ofrecen alternativas para escribir más fácilmente esta funcionalidad y no tener que escribir como en el ejemplo esos Callables ( que aunque son engorrosos por lo mucho que hay que escribir, siguen siendo simples de entender - cuando ya se entendieron las clases anónimas claro - )
+1 Por el post y ahora con un poco más de tiempo leo la tercera parte.
Chau!
Por cierto, no es sino hasta
Por cierto, no es sino hasta Java 7 que otros lenguajes pueden tener lo que Java no tiene, invocación de métodos mmhh anónimos... ( por llamarlos de alguna forma ).
JRuby ya está implementando sus closures utilizando precisamente la instrucción
y me parece que está cerca su liberación y corre más rápido.
No sé el estado de los otros lenguajes de programación pero también deben de estar cerca implementandolos.
Clojure
Este seria un ejemplo en Clojure de STM.
Clojure no es un lenguaje orientado a objetos.
Como la mayoria de los lenguajes base lisp usa notacion prefija o polaca,en infija a+b en prefija + a b.
En Java funcion(x,y)
En Clojure (funcion x y)