Hilos en Java: Que funcione no quiere decir que esté bien

Hace tiempo que leo código con los mismos fallos que, creo, son errores de concepto. Mucha gente supone cosas sobre el funcionamiento de los hilos en Java que, directamente, son falsas. En esta entrada aportaré mi granito de arena para aclarar algunos de esos conceptos.

La sincronía es importante

Pongamos por ejemplo esta clase:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Prueba {
	private int a = 0;
	private int b = 0;
 
	public void marcar() {
		a = 5;
		b = 5;
	}
 
	public boolean siempreVerdadero() {
		return a == 0 || (  a == b && b == 5);
	}
}

Aunque la clase tiene una dudosa aplicación práctica (vale, no tiene nada de dudosa, es inútil), parece claro que el método “siempreVerdadero” retornará invariablemente true. ¿Seguro? No. Esta es una de las suposiciones que hacen muchos programadores y que suele provocar esos misteriosos errores de “algunas veces falla… pero no hay forma de saber cuándo”.

Evil Java Edition matará gatitos ahogándolos en café si no haces tus programas «thread safe»

Supongamos que un hilo llama a marcar al mismo tiempo que otro llama a siempreVerdadero. Estamos ante una situación “data race”, vamos, que el resultado de la operación depende del momento en el que la máquina virtual ejecute las instrucciones. ¿Por qué? Voy a tratar de explicarlo:

  1. Hilo 1 llama a marcar e Hilo 2 llama a siempreVerdadero
  2. a = 5;
  3. b = 5;
  4. return a == 0 || ( a == b && b == 5);

En este caso el orden de ejecución de las instrucciones era el que el programador estaba esperando. Todo perfecto…. pero podría darse el siguiente escenario:

  1. Hilo 1 llama a marcar e Hilo 2 llama a siempreVerdadero
  2. a = 5;
  3. return a == 0 || ( a == b && b == 5); // a vale 5 y b vale0, false
  4. b=5;

El problema es que suponemos atomicidad donde no la hay. Es evidente que el programador no quiere que ningún método que acceda a “a” o “b” pueda tener lugar mientras se ejecuta el método “marcar”. ¿Cómo deberíamos haberlo hecho? Lo más sencillo sería hacer uso de “synchronized” que nos garantiza precisamente eso:

1
2
3
4
	public synchronized void marcar() {
		a = 5;
		b = 5;
	}

Publicar el objeto antes de finalizar la construcción

Los constructores son métodos especiales. Sirven, valga la redundancia, para construir un objeto. Es decir, una vez el método constructor haya finalizado, el objeto está listo para publicarse. Si se publicase antes de su finalización, los resultados serían impredecible. Y lo que es peor, cada vez que publicas un objeto sin estar totalmente construido, ¡Java mata un gatito ahogándolo en café!

¿Qué mi madre es eso de “publicar un objeto”? Pues básicamente, hacer que otros objetos lo conozcan.

Publicar un objeto antes de tiempo se conoce como “fuga de publicación”. El caso más típico es pasar una referencia a “this” (es decir, el objeto que estamos construyendo) a otro objeto en el código del constructor.

Supongamos el siguiente código:

1
2
3
4
5
6
7
8
9
10
public class MiFuga {
	ArrayList<Cliente> clientes;
	public MiFuga(OtraClase otraClase) {
		clientes = new ArrayList<Cliente>();
		otraClase.subscribeToPeticionCliente(this);
	}
	public void añadirCliente(Cliente cliente){
		clientes.add(cliente)
	}
}

La clase crea una lista de clientes y se subscribe al objeto “otraClase” que le informará cuando un cliente deba ser añadido. Obviamente para que el método “añadirCliente” pueda ser ejecutado correctamente, “clientes” ha de ser creado.

El código, aunque pueda parecer correcto, tiene un “data race”, es decir, puede funcionar, o no, dependiendo del orden en que se ejecuten sus sentencias. ¿Dónde está ese data race? Pues en que “otraClase” recibe una referencia a una instancia de MiFuga que podría no estar totalmente construida.

En Java, un constructor debe construir el objeto y NO publicarse a sí mismo. Muchos programadores creen que, dado que la construcción del objeto parece finalizada (clientes ha sido creado) antes de realizar la publicación (es decir, de hacer el subscribe), no hay problema. Lo que nadie les debe haber dicho es que Java no garantiza que el orden en el que escribas las sentencias se cumpla.

El compilador tiene total libertad para reordenar las instrucciones siempre que no se rompa ninguna relación de dependencia. En el caso anterior, la creación de clientes no depende de la llamada a subscribe ni viceversa, por lo que el compilador podría poner la llamada a subscribe delante de la creación de clientes. De ocurrir esto, y “otraClase” lanzar el evento para procesar un cliente antes de la ejecución de la instrucción que crea clientes, obtendríamos un NullPointerException, ya que clientes tendría valor nulo.

En otras palabras, el código anterior puede funcionar, o no, dependiendo del orden que decida el compilador y de que “otraClase” lance el evento en el momento menos oportuno. Un “data race” en toda regla.

¡Apiádate de él!

Llegados a este punto tienes dos soluciones: evitar la publicación prematura de un objeto o enseñar a nadar en café a los gatitos. Personalmente creo que la segunda sería más divertida… pero es poco práctica.

Si queremos evitar la publicación prematura, la solución no es hacer un constructor “synchronized”. Intuitivamente parece correcto, ya que aseguraría la ejecución de las instrucciones en el orden escrito… pero el compilador se negará a hacer un constructor “synchronized”. Aunque pudiera parecer lógico, realmente carece de sentido. Los constructores sólo son accesibles por el hilo que los crea y por tanto, no requieren de sincronización… simplemente NO debes publicar un objeto en el constructor.

Las cachés existen, tenlas en cuenta

Las cachés se sienten menospreciadas porque la gente no se acuerda de ellas :( Se compasivo y escribe código que las contemple. No sólo harás a las cachés felices y evitarás su suicidio, también lograrás que tu programa falle cuando Jupiter se alinea con Saturno y una paloma te mira por la ventana. (Eh, no me miréis así, que todos hemos llegado a la conclusión de que nuestro programa falla sólo cuando hay un posible comprador/ jefe/profesor delante)

Antes de nada ¿Qué es una caché y por qué tiene que importarme?

Cuando un hilo se crea, puede tomar la decisión de cachear ciertas variables. Es decir, creará copias de los valores de las variables de la memoria principal, a su “espacio de trabajo”. Esto se hace para mejorar la eficiencia. ¿Por qué debe importarte? Sencillo, si un hilo cachea una variable que va a usar luego, y esa variable cambia de valor… su copia cacheada ¿reflejará el cambio? La respuesta es “ni puñetera idea”… a menos que tengas la caché en cuenta.

Para el ejemplo, tomaré un código de CodeCrab que no tiene en cuenta la caché:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ThreadClass extends Thread {
	private boolean thrbool = true;
 
	public void run(){
		while(thrbool){
			System.out.println("Hello from thread "+super.getId());
		try{
			 Thread.sleep(300);
		}catch(Exception e){}
		}
	}
	public void stopThread(){
		thrbool = false;
	}
}

El planteamiento es intuitivo. El bucle de run se ejecutará hasta que alguien realice una llamada a “stopThread”, que cambiará el valor de “thrbool” a false y provocará la condición de salida del bucle.

¿Qué os he dicho de las cachés? Son muy rencorosas… si no os acordáis de ellas, se pondrán tristes y se enfadarán con vosotros haciendo que vuestros códigos no funcionen.

El método “run” puede estar viendo versiones cacheadas de las variables de la clase. Vamos, que puede haber cacheado “thrbool” con su valor original, true. Si alguien llama a “stopThread” , no tenemos ninguna garantía de que “run” vea el cambio… porque si ha creado caché, no estamos forzando ningún tipo de actualización.

¿Qué solución tenemos? Una de ellas consiste en marcar “thrbool” como “volatile”. Una variable volatile garantiza que todos los hilos la vean con el mismo valor. Dicho de otra forma, una variable volatile NO será cacheada.

1
private volatile boolean thrbool = true;

Eso garantizaría que “run” viera siempre los cambios hechos por “stopThread”. ¿Por qué hay tantos códigos con este fallo? Porque no marcarla como volatile no implica que no funcione… simplemente que podría no hacerlo. Dependerá de la plataforma, la máquina virtual y la carga de trabajo de la máquina sobre la que se ejecuta… vamos, todo un encaje de bolillos. (¿Nunca os ha pasado eso de “pues en mi máquina funcionaba”?).

¿Y a mí que me importa si no uso hilos?

Es que SÍ usas hilos. Para empezar, probablemente uses Swing… así que ya hay hilos de por medio. Aún sin usar Swing, lo más probable es que el sistema sea multi-núcleo o tenga hyperthreading… con lo que los efectos serán los mismos. No busques excusas y haz tu código “thread safe” ;)

About the Author

Me llamo Pablo Carballude González, soy graduado en computación con master en HCI y Seguridad Informática. Actualmente trabajo para Amazon en Seattle como Software Developer Engineer. Soy de esas personas que no saben si los textos autobiográficos deben ser en primera o tercera persona. Lo intenté en segunda, pero no le entendí nada :P