Threads y prioridades
La prioridad de un thread es un valor entero (cuanto mayor es el número, mayor es la prioridad), que puede asignarse con el método setPriority. Por defecto, la prioridad de un thread es igual a la del thread que lo creó. Cuando hay varios threads en condiciones de ser ejecutados (estado runnable), la máquina virtual elige el thread que tiene una prioridad más alta, que se ejecutará hasta que:
- Un thread con una prioridad más alta esté en condiciones de ser ejecutado (runnable), o
- El thread termina (termina su metodo run), o
- Se detiene voluntariamente o
- Alguna condición hace que el thread no sea ejecutable (runnable), como una operación de entrada/salida o, si el sistema operativo tiene planificación por división de tiempos (time slicing), cuando expira el tiempo asignado.
El hecho de que un thread con una prioridad más alta interrumpa a otro se denomina se denomina "planificación con derecho preferente" (preemptive scheduling).
Cuando un thread entra en ejecución y no cede voluntariamente el control para que puedan ejecutarse otros threads, se dice que es un thread egoista (selfish thread). Algunos Sistemas Operativos, como Windows, combaten estas actitudes con una estrategia de planificación por división de tiempos (time-slicing), que opera con threads de igual prioridad que compiten por la CPU. En estas condiciones el Sistema Operativo asigna tiempos a cada thread y va cediendo el control consecutivamente a todos los que compiten por el control de la CPU, impidiendo que uno de ellos se apropie del sistema durante un intervalo de tiempo prolongado.
Este mecanismo lo proporciona el sistema operativo, no Java.
Sincronización de Threads
Un ejemplo típico en que dos procesos necesitan sincronizarse, es el caso en que un thread produzca algún tipo de información que es procesada por otro thread. Al primer thread le denominaremos Productor y al segundo Consumidor. El Productor podría tener el siguiente aspecto:
private Contenedor contenedor;
public Productor (Contenedor c) {
contenedor = c;
}
public void run() {
for (int i = 0; i < 10; i++) {
contenedor.put(i);
System.out.println("Productor. put: " + i);
try {
sleep((int)(Math.random() * 100));
} catch (InterruptedException e) { }
}
}
}
El Consumidor, por su parte podría tener el siguiente aspecto:
private Contenedor contenedor;
public Consumidor (Contenedor c) {
contenedor = c;
}
public void run() {
int value = 0;
for (int i = 0; i < 10; i++) {
value = contenedor.get();
System.out.println("Consumidor. get: " + value);
}
}
}
Productor y Consumidor se usarían desde un método main de la siguiente forma:
public static void main(String[] args) {
Contenedor c = new Contenedor ();
Productor produce = new Productor (c);
Consumidor consume = new Consumidor (c);
produce.start();
consume.start();
}
}
La sincronización que permite a productor y consumidor operar correctamente; es decir, que hace que consumidor espere hasta que haya un dato disponible, y que productor no genere uno nuevo hasta que haya sido consumido esta en la clase Contenedor, que tiene el siguiente aspecto:
private int dato;
private boolean hayDato = false;
public synchronized int get() {
while (hayDato == false) {
try {
// espera a que el productor coloque un valor.
wait(); }
catch (InterruptedException e) { }
}
hayDato = false;
// notificar que el valor ha sido consumido.
notifyAll();
return dato;
}
public synchronized void put(int valor) {
while (hayDato == true) {
try {
// espera a que se consuma el dato.
wait();
} catch (InterruptedException e) { }
}
dato = valor;
hayDato = true;
// notificar que ya hay dato.
notifyAll();
}
}
En el método put, antes de almacenar el valor en dato hay que asegurarse de que el valor anterior ha sido consumido. Si todavía hay valor (hayDato es true) se suspende la ejecución del thread mediante el método wait. Invocando wait (que es un método de la clase Object) se suspende el thread indefinidamente hasta que alguien le envíe una "señal" con el método notify o notifyAll. Cuando esto se produce (veremos que el notify lo produce el método get) el método continua, asume que el dato ya se ha consumido, almacena el valor en dato y envia a su vez un notifyAll para notificar a su vez que hay un dato disponible.
Por su parte, el método get chequea si hay dato disponible (no lo hay si hayDato es false) y si no lo hay espera hasta que le avisen (método wait). Una vez ha sido notificado (desde el método put) cambia el flag y devuelve el dato, pero antes notifica a put de que el dato ya ha sido consumido, y por tanto se puede almacenar otro.
La sincronización se lleva a cabo usando los métodos wait y notifiAll.
Existe además otro componente básico en el ejemplo. Los objetos productor y consumidor utilizan un recurso compartido que es el objeto contenedor. Si mientras el productor llama al método put y este se encuentra cambiando las variables miembro dato y hayDato, el consumidor llamara al método get y este a su vez empezará a cambiar estos valores, que podrían producirse resultados inesperados (este ejemplo es sencillo pero fácilmente pueden imaginarse otras situaciones más complejas).
Interesa, por tanto que mientras se esté ejecutando el método put nadie más acceda a las variables miembro del objeto. Esto se consigue con la palabra synchronized en la declaración del método. Cuando la máquina virtual inicia la ejecución de un método con este modificador, adquiere un bloqueo en el objeto sobre el que se ejecuta el método que impide que nadie más inicie la ejecución en ese objeto de otro método, que también esté declarado como syncrhonized. En nuestro ejemplo, cuando comienza el método put se bloquea el objeto de tal forma que si alguien intenta invocar el método get o put (ambos son synchronized) quedará en espera hasta que el bloqueo se libere (cuando termine la ejecución del método). Este mecanismo garantiza que los objetos compartidos mantienen la consistencia.
Este método de gestionar los bloqueos implica que:
- Es responsabilidad del programador pensar y gestionar los bloqueos (A veces es una pesada responsabilidad).
- Los métodos synchronized son más costosos en el sentido de que adquirir y liberar los bloqueos consume tiempo (este es el motivo por el que no están sincronizados por defecto todos los métodos).
- Conviene evitar en lo posible el uso de objetos compartidos. Resultan difíciles de manejar.