Tras un tiempo sin Curso de Java, volvemos hoy con un tema muy importante para conseguir el máximo rendimiento de nuestro programa utilizando todos los núcleos de nuestro procesador.
La programación concurrente es capaz de realizar varias tareas de forma simultánea.
Hay varios tipos de concurrencia, nosotros trabajaremos con programación de memoria común, donde tan sólo tendremos una memoria RAM que es compartida por los demás programas y tareas.
Hay un aspecto muy importante para lograr que la concurrencia sea correcta:
- El resultado debe ser el mismo si se hace con un procesador que con cuatro procesadores. Es decir, el resultado no debe depender del número de núcleos/procesadores del ordenador.
Nota: Hablaremos de proceso = hilo.
Los sistemas operativos actuales permiten la concurrencia de procesos, el propio sistema operativo se encarga de permitir el uso de variables compartidas para pasar datos de un proceso a otro y controlar las regiones críticas.
¿Qué es una región crítica?
Es un trozo de código, en el que la correción del programa se ve comprometido por el uso de variables compartidas. Un proceso sólo podrá acceder a esta región crítica durante un tiempo determinado para que no halla inanición. Java permite de forma intrínseca la concurrencia.
Para garantizar la corrección del programa, en Java tenemos varios métodos que podemos utilizar:
- Monitores: Un monitor implementa una región crítica condicional, de tal forma que podemos sacar de la cola de espera a uno o a todos los procesos esperando. Para que sea un monitor, todos los métodos de la clase deben ser synchronized.
Para el uso de monitores, nos ayudamos de los métodos:
- wait(): Si no se cumple la condición, esperamos. notify(): Cuando hemos entrado en la región crítica, y hemos hecho cierta acción, notificamos a un proceso que hay esperando para entrar si se cumple la condición (le despertamos del wait()).
- notifyAll(): Igual que el anterior pero notificamos a todos los hilos que hay esperando.
- Semáforos: El nombre de semáforos es como en la vida real, un semáforo cerrado no podrán pasar coches hacia un lado, y un semáforo abierto sí podrán. Los semáforos garantizan la exclusión mutua y la sincronización (para que los coches no se choquen en la región crítica que en este caso es el cruce). En semáforos nos ayudamos de varios métodos como por ejemplo:
- acquire(): Para adquirir el semáforo (ponerlo en verde para la cola de coches A) una vez que lo hemos adquirido pueden pasar los coches porque está en verde.
- release(): El último coche en pasar hace un realease() para que los coches que están esperando del otro semáforo puedan pasar ya que nosotros hemos terminado.
Los semáforos se usan para controlar el número de hilos que pueden acceder a un recurso. Un proceso bloqueado en el semáforo, puede ser liberado por otro, esto no ocurre en los locks que veremos a continuación.
- Locks: Los locks proporciona mayor rendimiento, con la misma semántica que la sincronización. Soporta timeout al adquirir un bloqueo e incluso soporte para interrumpir un hilo. Podemos decir que con los locks controlamos más lo que hace nuestro programa, es más «manual», y por tanto, se necesita la experiencia del programador para que no tengamos fallo en el programa.
Todo esto es básicamente lo que veremos en concurrencia de memoria común, ampliaremos algo de teoría, pero sobre todo pondremos ejemplos para que veáis como funciona.
¿Concurrencia? ¿Realmente hay diferencia de rendimiento en los programas?
Vamos a calcular un determinado de números primos, de forma secuencial y de forma concurrente (sin usar regiones críticas).
Clase Primos.java (se encargará de calcular dichos números primos).
[java]public class Primos {
private int x, y, n = 0;
public Primos(int x, int y) {
this.x = x;
this.y = y;
}
private boolean esPrimo(int n) {
int raiz = (int) Math.sqrt((double) n);
for (int i = 2; i
if (n % i == 0) {
return false;
}
}
return true;
}
public void calcular() {
for (int i = x; i
if (esPrimo(i)) {
n++;
}
}
}
public int cuantos() {
return n;
}
}[/java]
Programa principal ejecutable de Primos sin concurrencia:
[java]public class CuantosPrimos {
public static void main(String[] args) {
long t0 = (new Date()).getTime();
Primos p1 = new Primos(1, 2000000);
Primos p2 = new Primos(2000001, 4000000);
Primos p3 = new Primos(4000001, 6000000);
Primos p4 = new Primos(6000001, 8000000);
Primos p5 = new Primos(8000001, 10000000);
p1.calcular();
p2.calcular();
p3.calcular();
p4.calcular();
p5.calcular();
int n = p1.cuantos() + p2.cuantos() + p3.cuantos() + p4.cuantos() + p5.cuantos();
long t1 = (new Date()).getTime();
System.out.println("Número de primos menores que 10000000: " + n + " calculado en " + (t1 – t0) + " miliseg.");
}
}[/java]
Número de primos menores que 10000000: 664580 calculado en 7491 miliseg.
Ahora os voy a poner la clase de Primos que se hace de forma concurrente. Para la concurrencia podemos extender la clase Thread, o implementar la interfaz Runnable.
¿Cuando debemos usar una u otra? Debido a que al extender la clase Thread, estamos heredando todos sus métodos, si queremos que una clase herede de otra, y encima que sea concurrente, no podremos hereder (extends) de ambas clases, ya que Java no permite la herencia múltiple. De esta forma hacemos un extends Padre implements Runnable, para heredar de Padre e implementar la concurrencia. Por tanto, implementando la interfaz tenemos más «flexibilidad» a la hora de programar nuestras clases porque esa interfaz la podemos modificar a nuestro antojo para agregarle más funcionalidades.
Interfaz Runnable:
[java]public interface Runnable {
public abstract void run() ;
}[/java]
Extendiendo la clase Thread quedaría:
[java]public class PrimosThread extends Thread {
private int x, y, n = 0;
public PrimosThread(int x, int y) {
this.x = x;
this.y = y;
}
private boolean esPrimo(int n) {
int raiz = (int) Math.sqrt((double) n);
for (int i = 2; i
if (n % i == 0) {
return false;
}
}
return true;
}
@Override
public void run() {
for (int i = x; i
if (esPrimo(i)) {
n++;
}
}
}
public int cuantos() {
return n;
}
}[/java]
Implementando la clase Runnable, sustituímos: extends Thread por implements Runnable y listo, no tendremos que cambiar nada más en esta clase.
Programa Principal de Primos con extends Thread:
[java]public class CuantosPrimos2 {
public static void main(String[] args) {
long t0 = (new Date()).getTime();
PrimosThread p1 = new PrimosThread(1, 2000000);
PrimosThread p2 = new PrimosThread(2000001, 4000000);
PrimosThread p3 = new PrimosThread(4000001, 6000000);
PrimosThread p4 = new PrimosThread(6000001, 8000000);
PrimosThread p5 = new PrimosThread(8000001, 10000000);
p1.start();
p2.start();
p3.start();
p4.start();
p5.start();
try {
p1.join();
p2.join();
p3.join();
p4.join();
p5.join();
} catch (InterruptedException e) {
}
int n = p1.cuantos() + p2.cuantos() + p3.cuantos() + p4.cuantos() + p5.cuantos();
long t1 = (new Date()).getTime();
System.out.println("Número de primos menores que 10000000: " + n + " calculado en " + (t1 – t0) + " miliseg.");
}
}[/java]
Programa principal con implements Runnable:
[java]public class CuantosPrimos2 {
public static void main(String[] args) {
long t0 = (new Date()).getTime();
PrimosThread p1 = new PrimosThread(1, 2000000);
PrimosThread p2 = new PrimosThread(2000001, 4000000);
PrimosThread p3 = new PrimosThread(4000001, 6000000);
PrimosThread p4 = new PrimosThread(6000001, 8000000);
PrimosThread p5 = new PrimosThread(8000001, 10000000);
p1.run();
p2.run();
p3.run();
p4.run();
p5.run();
long t5 = (new Date()).getTime();
int n = p1.cuantos() + p2.cuantos() + p3.cuantos() + p4.cuantos() + p5.cuantos();
System.out.println("Número de primos menores que 10000000: " + n + " calculado en " + (t5 – t0) + " miliseg.");
}
}[/java]
La diferencia es bastante significativa:
Número de primos menores que 10000000: 664580 calculado en 4262 miliseg.
Calculamos el mismo número de primos en casi la mitad de tiempo. Pruebas realizadas con un Intel Core2Duo T8300 con 2 núcleos.
Parece que es útil, ¿verdad?