11 Semaphore¶
Semaphore¶
Un semáforo es un contador que protege el acceso a uno o más recursos compartidos. El concepto de semáforo fue introducido por Edsger Dijkstra en 1965.
Cuando un hilo quiere acceder a uno de estos recursos compartidos, primero debe adquirir (acquire) el semáforo. Si el contador interno del semáforo es mayor que 0, el semáforo decrementa el contador interno y permite el acceso del hilo al recurso compartido. Un valor mayor que 0 en el contador significa que hay recursos libres que pueden ser usados, por lo que el hilo puede acceder y obtener uno de ellos.
Si, por el contrario, el contador interno del semáforo es 0, el semáforo suspende el hilo solicitante hasta que el contador sea mayor que 0. Un valor de 0 en el contador interno del semáforo significa que todos los recursos compartidos están siendo usados por otros hilos, por lo que el hilo solicitante debe esperar hasta que se libere uno de ellos.
Cuando un hilo termina de usar un recurso compartido, debe liberar el semáforo, de manera que su contador interno se incrementa y se reactiva automáticamente alguno de los hilos que estaban esperando uno de los recursos compartidos gestionados por el semáforo.
Los semáforos cuyo valor inicial del contador es 1 reciben el nombre de semáforos binarios.
La clase Semaphore
nos permite crear semáforos en nuestras aplicaciones. El constructor de dicha clase recibe el valor inicial para el contador interno del semáforo, que debería corresponder, en principio, al número de instancias del recursos compartido controlados por el semáforo. Curiosamente, Java permite proporcionar un valor que no tenga mucho sentido, como por ejemplo -1
.
Opcionalmente, el constructor puede recibir además un valor booleano (fair) para indicar si queremos que funcione el modo justo, es decir, que los hilos sean reactivados en el orden en que solicitaron el semáforo cuando haya disponible de nuevo un recurso compartido. Por defecto el valor de dicho parámetro es false
. Al usar semáforos con el modo justo activado evitaremos la inanición, aunque su rendimiento es peor que el de los semáforos no justos.
La clase Semaphore
nos proporciona distintas formas de adquirir el semáforo. La primera de ellas es mediante el método acquire()
, que como la mayoría de los métodos bloqueantes, lanzará la excepción InterruptedException
si el hilo es interrumpido mientras se encuentra esperando a poder acceder a alguna instancia del recurso, o si ya había sido marcado para interrupción antes de ejecutar acquire()
, reactivando el hilo inmediatamente.
Si queremos que no se lance la excepción en dichos casos, deberemos usar el método acquireUninterruptedly()
, que no hace caso al estado de interrupción del hilo.
Una tercera forma de adquirir el semáforo es mediante el método tryAcquire()
, que no suspende el hilo si no es posible adquirir el semáforo, retornando simplemente un valor booleano indicativo de si se ha adquirido o no. Debemos tener en cuenta que a tryAcquire()
no le afecta el hecho de que el semáforo está funcionando en modo justo o no y puede que el hilo adquiera el semáforo si justo en el momento en que llama a tryAcquire()
otro hilo libera un recurso, incluso aunque hubiera otros hilos esperando adquirir el semáforo.
Una cuarta forma de adquirir el semáforo es mediante el método tryAcquire(timeout, timeUnit)
, al que pasamos el tiempo máximo que el hilo podrá ser suspendido en espera de adquirir el semáforo, transcurrido el cuál es reactivado inmediatamente, retornando el método el valor false
. Si el hilo consigue adquirir el semáforo el método retornará true
. Al igual que con acquire()
, si el hilo es interrumpido mientras está suspendido tratando de adquirir el semáforo o ya había sido marcado para interrupción antes de llamar al método tryAcquire(time, timeUnit)
, se lanzará la excepción InterruptedException
y se reactivará inmediatamente el hilo. Sin embargo, y a diferencia de lo que ocurre con tryAcquire()
, tryAcquire(timeout, timeUnit)
sí respeta el hecho de que el semáforo sea justo, incluso aunque establezcamos tryAcquire(0, TimeUnit.SECOND
).
Para liberar un semáforo haremos uso del método release()
, que normalmente se incluye dentro de finally
para asegurarnos de que la instancia del recurso gestionada por el semáforo es liberada independientemente de que se produzca una excepción o no después de haberlo adquirido, de esta manera que dicha instancia pueda ser más adelante usada por otro hilo.
Debemos tener en cuenta que la llamada a release()
incrementará el contador interno del semáforo, incluso si éste no ha sido adquirido nunca. Esto quiere decir, implícitamente, que el valor recibido por el constructor de la clase Semaphore
NO corresponde al número máximo de instancias gestionadas por el semáforo, sino tan sólo al valor inicial del contador interno. Como consecuencia es posible que erróneamente estemos realizando release()
cuando no debiéramos, ya que Java no nos avisará, por lo que hay que ser especialmente cuidadoso.
Un aspecto curioso es que Java no exige que el hilo que libere el semáforo sea el mismo que lo adquirió, aunque lo habitual será que sea el mismo hilo.
Los métodos acquire(permits)
, tryAcquire(permits)
, acquireUninterruptedly(permits)
y release(permits)
están sobrecargados, de manera que pueden recibir un parámetro entero que represente el número de instancias del recurso compartido que se quieren adquirir o liberar, que corresponderá al valor que se decrementará o incrementará el contador interno del semáforo. Es preferible usar esta opción frente a hacer un bucle que llame repetidamente a la versión sin parámetro. Debemos tener en cuenta que en el caso de la adquisición, el hilo será suspendido hasta que estén disponibles al menos el número de recursos compartidos pasados en dicho argumento. Todos estos métodos lanzarán la excepción IllegalArgumentException
si se pasa un número negativo para el parámetro permits
.
Un aspecto muy importante de los semáforos es que implementan la sincronización necesaria para restringir el acceso al conjunto de instancias del recurso, de decir, que gestionan el número de instancias disponibles, pero no qué instancias están disponibles, es decir, que no asegura la consistencia del conjunto de recursos en sí, para lo que deberemos usar posteriormente algún otro sistema que asegure la atomicidad, como por ejemplo un ReentrantLock
. Este aspecto es estudiado con más detenimiento en el proyecto Semaphore mostrado a continuación.
La clase Semaphore
proporciona una serie de métodos informativos que permite realizar un seguimiento de su funcionamiento:
availablePermits()
: Retorna el número de recursos disponibles del semáforo (cuántos hilos más podrán pasar).hasQueuedThreads()
: Retornatrue
si hay hilos esperando un recurso protegido por el semáforo.getQueueLength()
: Retorna el número de hilos esperando un recurso protegido por el semáforo.isFair()
: Retornatrue
si el semáforo tiene activado el modo justo, lo que indica que cuando debe seleccionarse uno de los hilos esperando por un recurso, se elige el que lleve más tiempo esperando.
Los semáforos son un tipo de sincronizador que no sólo sirven para controlar el acceso a un número de recursos, sino que además permiten la coordinación de hilos, aunque no son muy útiles cuando la sincronización es compleja. En estos casos es mejor usar objetos Condition
.
En general los semáforos son más flexibles que los cerrojos, por dos motivos principales, porque nos permiten adquirir y liberar varias instancias del recurso protegido, y porque se pueden adquirir y liberar las instancias desde hilos distintos (not fully bracket). Dicha flexibilidad supone sin embargo un coste adicional en rendimiento.
Proyecto Semaphore¶
En este proyecto vamos a simular una cola de impresión única que recibe los documentos a imprimir por parte de los distintos hilos y que los imprime en alguna de las tres impresoras disponibles. Para gestionar el acceso a las impresoras usaremos un semáforo. Para comprobar la disponibilidad de las impresoras usaremos un array protegido con un objeto ReentrantLock
.
public class Main {
public static void main (String[] args){
PrintingQueue printingQueue = new PrintingQueue(3);
Thread[] printJobThreads = new Thread[10];
for (int i = 0; i < 10; i++) {
printJobThreads[i] = new Thread(new PrintJob(printingQueue, "Document #" + i), "Print job #" + i);
}
for (int i = 0; i < 10; i++) {
printJobThreads[i].start();
}
}
}
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class PrintingQueue {
private static final int NO_PRINTER = -1;
private final Semaphore semaphore;
private final Lock reentrantLock = new ReentrantLock(true);
private final Printer[] printers;
private final boolean[] printerAvailable;
public PrintingQueue(int numberOfPrinters) {
semaphore = new Semaphore(numberOfPrinters, true);
printers = new Printer[numberOfPrinters];
printerAvailable = new boolean[numberOfPrinters];
for (int i = 0; i < numberOfPrinters; i++) {
printers[i] = new Printer(i);
printerAvailable[i] = true;
}
}
public void addDocument(String document) throws InterruptedException {
try {
semaphore.acquire();
int printerNumber = selectPrinter();
if (printerNumber != NO_PRINTER) {
printers[printerNumber].printDocument(document);
}
printerAvailable[printerNumber] = true;
} finally {
// This is called even if an exception is thrown.
semaphore.release();
}
}
private int selectPrinter() {
reentrantLock.lock();
try {
for (int i = 0; i < printers.length; i++) {
if (printerAvailable[i]) {
printerAvailable[i] = false;
return i;
}
}
} finally {
reentrantLock.unlock();
}
return NO_PRINTER;
}
}
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class Printer {
private final int printerNumber;
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");
private final Random random = new Random();
public Printer(int printerNumber) {
this.printerNumber = printerNumber;
}
public void printDocument(String document) throws InterruptedException {
System.out.printf("%s -> %s: Document printing started on printer %d\n",
LocalTime.now().format(dateTimeFormatter),
Thread.currentThread().getName(), printerNumber);
System.out.printf("%s -> %s: %s...\n",
LocalTime.now().format(dateTimeFormatter),
Thread.currentThread().getName(), document);
TimeUnit.SECONDS.sleep(random.nextInt(5));
System.out.printf("%s -> %s: Printing finished on printer %d\n",
LocalTime.now().format(dateTimeFormatter),
Thread.currentThread().getName(), printerNumber);
}
}
import java.util.Objects;
public class PrintJob implements Runnable {
private final PrintingQueue printingQueue;
private final String document;
public PrintJob(PrintingQueue printingQueue, String document) {
Objects.requireNonNull(printingQueue);
Objects.requireNonNull(document);
this.printingQueue = printingQueue;
this.document = document;
}
@Override
public void run() {
try {
printingQueue.addDocument(document);
} catch (InterruptedException e) {
System.out.printf("%s -> I've been interrupted while printing document\n",
Thread.currentThread().getName());
}
}
}
Si ejecutamos el programa veremos que sólo tres documentos se pueden estar imprimiendo a la vez, y que se hará en impresoras distintas.