You are on page 1of 16

Multihilos

Introducción al multithread

Se puede definir como un hilo de ejecución, o thread, a una


secuencia de instrucciones, que puede ser gestionado de forma
independiente por un scheduler (temporizador). Un proceso
puede tener muchos hilos, los mismos que se ejecutan
concurrentemente compartiendo recursos como memoria (a
diferencia de procesos distintos que usan recursos diferentes).

El principal propósito del multithread es proporcionar ejecución


simultánea de dos o más partes de un programa para maximizar
el uso del CPU. Cada thread ejecuta una parte del proceso
principal, por lo que se puede segmentar.

Históricamente el multithread se implementaba con primitivas de


sistema operativo disponibles a programadores avanzados. El
lenguaje ADA popularizó primitivas de concurrencia y las hizo
disponibles a un público más amplio.

Java hace que la concurrencia esté disponible a través del


lenguaje y APIs. Los programas de Java pueden tener múltiples
hilos de ejecución, donde cada hilo gestiona un grupo de recursos
propios (como la pila de llamadas), mientras comparte otros con
la aplicación completa, tales como la memoria.

La JVM crea hilos para correr un programa e hilos para ejecutar


tareas como el garbage collection.

Estados de los subprocesos y ciclo de vida

Ambiente multithread de Java.


Entre los estados que podemos identificar en un proceso
tenemos:

 Estados New y Runnable


Todo hilo inicia su ciclo en estado New y se mantiene en este
estado hasta que el programa inicie el hilo, colocándolo en
estado runnable.

Se considera que un hilo está en estado runnable cuando


está ejecutando su tarea.

 Estado Waiting

A menudo un hilo pasa a estado waiting cuando espera que


otro hilo ejecute una tarea. Un hilo regresa al estado
runnable sólo cuando otro hilo le notifica que continúe su
ejecución

 Estado Timed Waiting

Un hilo en estado runnable puede entrar al estado timed


waiting por un intervalo específico de tiempo. Regresa a
runnable cuando el intervalo expira o cuando el evento de
espera ocurre (es decir, cuando es notificado por otro hilo, lo
que ocurra primero).

Los hilos en estado de espera no pueden usar procesador


incluso si está disponible.

Un hilo en estado runnable puede pasar a estado time-


waiting si se proporciona un intervalo de tiempo (opcional)
cuando está esperando que otro proceso ejecute la tarea.

Otra forma de colocar un hilo en estado time-waiting es


poner a dormir un hilo en runnable. Un hilo que se envió a
dormir se mantiene en estado time-waiting por un periodo
de tiempo definido (intervalo de sueño – sleep interval),
luego del cual retorna al estado runnable.

Los hilos pueden dormir cuando no se tienen tareas a


ejecutar temporalmente. Ejemplo: El respaldo automático
de un procesador de palabras debe tener intervalos de
ejecución para no desperdiciar recursos (memoria,
procesador, etc.).

 Estado blocked

Un hilo en estado runnable pasa a estado blocked cuando


intenta desarrollar una tarea que no puede completarse de
inmediato y debe esperar hasta que sea completada. Por
ejemplo, cuando un hilo solicita una operación de I/O, el
sistema operativo bloquea el hilo hasta que la solicitud de
I/O se complete, luego de lo cual vuelve a runnable.

Un hilo en estado blocked no puede usar procesador, incluso


si está disponible.

 Estado Terminated

Un hilo en runnable entra a estado terminado (o estado


muerto) cuando se completa exitosamente su tarea o
concluye por otras causas (por ejemplo, un error).
Estado runnable desde el sistema operativo

A nivel de sistema operativo, el estado runnable abarca dos


estados separados. El sistema operativo esconde estos estados
de la JVM quien sólo ve el estado runnable

Cuando un hilo pasa de estado new a runnable, está en estado


ready (listo). Un hilo en estado ready pasa a estado running
cuando el sistema operativo lo asigna a un procesador (despacha
el hilo). En la mayoría de sistemas operativos a cada hilo se le
asigna una pequeña cantidad de tiempo de procesador (quantum
o timeslice) para desarrollar su tarea. El SO determina el tamaño
del quantum. Cuando el tiempo expira el hilo regresa al estado
ready y el SO asigna otro hilo al procesador.

Las transiciones entre los estados ready y running son


manejadas por el sistema operativo y la JVM no ve estas
transiciones (asume que siempre están en estado runnable).

El proceso que un SO usa para determinar qué hilo despachar se


denomina “thread scheduling” y depende de prioridades de los
hilos.

Prioridades y Programación de hilos

Cada hilo en Java tiene una prioridad (de MIN_PRIORITY a


MAX_PRIORITY) que ayuda al SO a determinar su orden de
ejecución. De manera predeterminada, cada hilo recibe la
prioridad NORM_PRIORITY (una constante de 5) y hereda la
prioridad del hilo que lo creó.

A mayor prioridad, más importante el programa. Al programa se


le asignará tiempo de procesador antes que otros hilos de menor
prioridad, sin embargo, las prioridades de hilos no garantizan el
orden en que se ejecutan.

El programador de hilos determina cuál será el proceso que


correrá, manteniendo los hilos de mayor prioridad corriendo. De
existir más de un proceso con la misma prioridad, se asegura la
ejecución alternada de los procesos hasta que se completen.

Cuando un hilo de mayor prioridad pasa al estado ready, el


sistema operativo generalmente sustituye el hilo actual, en
ejecución, para dar preferencia al hilo de mayor prioridad (una
operación conocida como programación preferente).

Dependiendo del sistema operativo, los hilos de mayor prioridad


podrían posponer, tal vez de manera indefinida, la ejecución de
los hilos de menor prioridad. Para evitar este problema, los
sistemas operativos usan una técnica llamada aging que
incrementa gradualmente la prioridad de un hilo para asegurar
que eventualmente se ejecute.

Creación y ejecución de hilos

Para especificar una tarea que pueda ejecutarse


concurrentemente con otras, es necesario implementar la
interface Runnable, la misma que declara un método único run.
Considerar que las interfaces tienen métodos no implementados
(vacíos).

import java.util.Random;

public class TareaImprimir implements Runnable


{
private final int tiempoInactividad; // tiempo de inactividad
public void run()
{
....
} // fin del metodo run
} // fin de la clase TareaImprimir

El hilo que ejecuta un objeto Runnable llama al método run para


realizar una tarea. El programa no terminará hasta que su último
hilo finalice su ejecución.
Thread subproceso1 = new Thread(new TareaImprimir("tarea1"));
subproceso1.start(); // invoca al metodo run

No es posible predecir el orden en que los hilos serán atendidos,


incluso si se conoce su orden de inicio o creación.

Se recomienda el uso de la interface Executor para gestionar la


ejecución de objetos Runnable. Cuando un Executor empieza la
ejecución de un Runnable, invoca a su método run, el cual se
inicia en un nuevo hilo.

Un objeto Executor típicamente crea y gestiona un grupo de hilos,


llamado un pool de hilos (thread pool).

ExecutorService ejecutorSubprocesos =
Executors.newCachedThreadPool();

ejecutorSubprocesos.execute(tarea1);

El interface Executor declara un único método llamado execute, el


cual acepta un Runnable como argumento. El Executor asigna
cada Runnable a uno de los hilos disponibles en el pool de hilos.
Si no existen hilos disponibles, el Executor crea un nuevo hilo o
espera a que alguno se vuelva disponible para asignarlo.

El uso de Executor tiene muchas ventajas, entre las cuales


tenemos: la reutilización de hilos existentes para eliminar la
sobrecarga crear un nuevo hilo por cada tarea, mejorar el
desempeño optimizando el número de hilos para que el
procesador se mantenga ocupado, etc.

La interface ExecutorService (del paquete java.util.concurrent)


extiende a la interface Executor y declara otros métodos para
gestionar el ciclo de vida de un Executor.

Un objeto que implemente la interface ExecutorService puede ser


creado usando métodos estáticos declarados en la clase
Executors (del paquete java.util.concurrent).
El método newCachedThreadPool de Executors retorna un
ExecutorService que crea nuevos hilos cuando se requiere.

El método execute, de un ExecutorService, ejecuta su Runnable


en algún momento en el futuro. El método retorna
inmediatamente después de cada invocación y no espera a que
termine cada tarea.

El método shutdown de ExecutorService notifica al objeto


ExecutorService para que deje de aceptar nuevas tareas, pero
continúa ejecutando las tareas existentes, terminando cuando
aquellas tareas finalicen ejecución.

Ver los archivos:

TareaImprimir.java
CreadorSubproceso.java
EjecutorTareas.java

Sincronización de hilos

Cuando varios hilos comparten un objeto y pueden modificarlo


simultáneamente, pueden ocurrir errores sin que se pueda
determinar que el objeto compartido se manipuló
incorrectamente. El problema se resuelve dando acceso exclusivo
a un hilo a la vez, para que manipule los datos compartidos.
Durante este tiempo otros hilos que quieran modificar los datos
se quedarán en espera. Este proceso se conoce como
sincronización de hilos y coordina el acceso a datos compartidos
por varios hilos concurrentes.

Mediante la sincronización de hilos, se puede asegurar que cada


hilo que acceda a un objeto compartido excluya a todos los otros
hilos de hacerlo al mismo tiempo. Esto es llamado exclusión
mutua.
Una manera común de realizar la sincronización es mediante los
monitores. Cada objeto tiene un monitor y un bloqueo de monitor
(o bloqueo intrínseco). El monitor asegura que el bloqueo de
monitor de un objeto se mantenga por un hilo a la vez (para hacer
exclusión mutua).

Si una operación requiere que un hilo mantenga un bloqueo


mientras se ejecuta, el hilo debe adquirir el bloqueo antes de
proceder con la operación.

Otros hilos que traten de realizar una operación, permanecerán


bloqueados hasta que el primer hilo libere los recursos. En este
punto, los hilos en espera tratarán de adquirir el bloqueo.

Para especificar que un hilo debe mantener un bloqueo de


monitor al ejecutar un bloque de código, este debe colocarse en
una sentencia synchronized. Se dice que dicho código está
protegido por el bloqueo de monitor y debe obtener el bloqueo
antes de su ejecución.

Las sentencias sincronizadas se declaran mediante la palabra


clave synchronized:

synchronized(parámetro_objeto)
{
sentencias
} // fin de la sentencia synchronized

Ejemplo

synchronized (color) {
int colorActual = color.getColor();
}

En donde parámetro_objeto es el objeto cuyo bloqueo de monitor


se va a adquirir. Es this, si corresponde al objeto en el que
aparece la sentencia synchronized.
Java también permite ejecutar métodos synchronized. Antes de
ejecutar, un método synchronized (no estático) debe adquirir el
bloqueo sobre el objeto que es usado para llamar al método.

De forma similar, un método estático synchronized debe adquirir


el bloqueo sobre la clase que es usada para llamar al método.

public synchronized void agregar(int valor)


{
...
}

arregloSimpleCompartido.agregar(i);

El método awaitTermination de ExecutorService obliga a un


programa a esperar que los hilos terminen. Devuelve el control a
su llamador cuando todas las tareas en ejecución en el
ExecutorService se completan o cuando el tiempo de espera
especificado transcurre. Si todas las tareas se completan antes
que el tiempo de espera transcurra, el método devuelve true, de
lo contrario, el devuelve false.

Se pueden asociar varias operaciones de forma que se vea como


una operación atómica usando sentencia synchronized o métodos
synchronized.

Cuando usted comparte datos inmutables a través de hilos, usted


debería declarar los campos de datos correspondientes como
final para indicar que los valores de variables no pueden cambiar
después que ellos son inicializados.

Ver los archivos:

ArregloSimple1.java
EscritorArreglo1.java
PruebaArregloCompartido1.java
ArregloSimple2.java (synchronized)
EscritorArreglo2.java
PruebaArregloCompartido2.java

Relación Productor/Consumidor sin sincronización

En una relación multihilo Productor/Consumidor, un hilo productor


genera datos y los coloca en un objeto compartido llamado buffer.

Un hilo Consumidor lee datos desde el buffer.

Las operaciones en un buffer de datos compartido por un


productor y un consumidor deben realizarse únicamente si el
buffer está en el estado correcto.

Si el buffer no está lleno, el productor puede producir; si el buffer


no está vacío, el consumidor puede consumir.

Si el buffer está lleno cuando el productor intenta escribir dentro


de él, el productor debe esperar hasta que haya espacio.

Si el buffer está vacío o el último valor fue ya leído, el consumidor


debe esperar para que un nuevo dato esté disponible.

Ver los archivos:


Bufer.java
Productor1.java
Consumidor1.java
BuferSinSincronizacion.java
PruebaBuferCompartido1.java

Relación Productor/Consumidor: ArrayBlockingQueue

Una forma de sincronizar hilos productores y consumidores es


usar el paquete de concurrencia de Java para encapsular la
sincronización. Un ArrayBlockingQueue (java.util.concurrent) es
una clase buffer completamente implementada del paquete
java.util.concurrent, misma que implementa la interface
BlockingQueue.
Un ArrayBlockingQueue extiende el interface Queue y declara
métodos put y take. El método put ubica un elemento al final al
BlockingQueue, esperando a que la cola se llene. El método take
elimina el elemento de la cabeza de la cola, esperando a que la
cola se vacíe. Estos métodos permiten la implementación de un
buffer compartido (relación productor/consumidor)

ArrayBlockingQueue almacena datos compartidos en un arreglo


cuyo tamaño es especificado como un argumento del constructor.
Una vez creado el tamaño es fijo y no se puede expandir.

Ver los archivos:


Productor2.java
Consumidor2.java
BuferBloqueo.java
PruebaBuferBloqueo.java

Relación Productor/Consumidor con sincronización

Se puede implementar un buffer compartido usando la palabra


synchronized y métodos de la clase Object (sólo como ejemplo
pues se recomienda usar clases implementadas).

Para lograr un acceso sincronizado al buffer, es necesario definir


los métodos get y set como synchronized. Esto requiere que el
hilo obtenga un bloqueo de monitor sobre el objeto Buffer e
implemente un mecanismo para que el resto de hilos esperen
hasta que se cumplan ciertas condiciones.

Los métodos de Object, wait, notify and notifyAll se usan para la


sincronización.

Si un hilo tiene un bloqueo de monitor sobre un objeto, pero


determina que no puede continuar su ejecución hasta que ciertas
condiciones se cumplan, el hilo puede llamar al método wait del
objeto synchronized, para liberar el bloqueo del objeto y ponerse
en estado waiting. Otros hilos tratarán de entrar a las sentencias
y métodos synchronized del objeto.

Cuando un hilo que ejecuta una sentencia o método synchronized


se completa, o satisface una condición (que otro hilo puede estar
esperando), puede llamar al método notify para permitir que un
hilo en espera pueda pasar a runnable nuevamente. En este
punto el hilo que pasó a runnable puede intentar readquirir el
bloqueo de monitor en el objeto.

Incluso si el hilo readquiere el bloqueo de monitor del objeto,


podría no ser capaz de culminar su tarea es esta ocasión, lo cual
hará que vuelva al estado waiting y libere el bloqueo
implícitamente.

Si un hilo llama a notifyAll en un objeto synchronized, todos los


hilos en espera para adquirir el bloqueo de monitor, se vuelven
elegibles para readquirir el bloqueo y pasar a estado runnable.

BuferSincronizado1.java
PruebaBuferCompartido2.java

Relación Productor/Consumidor: búferes delimitados

En el tema anterior se estableció una relación sincronizada, sin


embargo, el desempeño de la aplicación podría no ser óptimo. Si
dos hilos operan a velocidades distintas, uno de ellos podría
desperdiciar tiempo esperando excesivamente. Si el productor
genera valores más rápido que lo que el consumidor puede
procesar, el primero deberá esperar hasta que el segundo libere
el buffer, lo cual constituye una pérdida de rendimiento.

Incluso cuando los hilos operan a velocidades casi iguales,


pueden desincronizarse ocasionalmente causando que el otro
espere. No se puede asumir la velocidad relativa de los hilos
concurrentes pues ella depende de varios factores, como su
complejidad, interacción con el sistema operativo, red, etc.
Incluso las velocidades son cambiantes a lo largo del tiempo.

Para minimizar el tiempo de espera de los hilos que comparten


recursos y operan a una velocidad promedio similar, se pueden
implementar buffers delimitados (bounded buffer). Estos
proporcionan un número fijo de celdas en las cuales el productor
puede colocar valores, y desde donde el consumidor, los
recupera.

Si el productor temporalmente genera valores más rápido de los


que el consumidor puede procesar, el productor escribirá valores
adicionales en celdas extras (si están disponibles). Esto permite
al productor ejecutar sus tareas incluso si el consumidor no está
listo para recibir los valores que se están generando. De igual
forma si el consumidor consume más rápido que lo que puede
consumir, puede leer valores adicionales, manteniéndolo
ocupado.

Si un productor y consumidor tienen una velocidad promedio


similar, un buffer delimitado ayuda a reducir los efectos de un
cambio ocasional de velocidad de ejecución de sus hilos.

La clave para utilizar un buffer delimitado con un productor y un


consumidor que operan aproximadamente a la misma velocidad,
es proporcionar un buffer con suficientes ubicaciones para
manejar la producción “extra” anticipada.

La manera más simple para implementar un buffer delimitado es


usar un ArrayBlockingQueue para el buffer, de forma que todos
los detalles de sincronización pueden ser manejados.

Ver los archivos:


BuferCircular.java
PruebaBuferCircular.java

Relación Productor/Consumidor: Las interfaces Lock y


Condition
Las interfaces Lock y Condition, que fueron introducidas en Java
SE 5, y dan un control más preciso sobre la sincronización de
hilos, pero son más complejas.

Cualquier objeto puede contener una referencia a un objeto que


implemente la interface Lock (java.util.concurrent.locks). Un hilo
llama al método lock de Lock (análogo a ingresar un bloque
synchronized) para adquirir el bloqueo.

Una vez que un bloqueo ha sido obtenido por un hilo, el objeto


Lock no permitirá que otro hilo obtenga el Lock hasta que el
primer hilo libere el bloqueo (mediante el método unlock).

Si varios hilos están tratando de llamar al método lock del mismo


objeto Lock al mismo tiempo, sólo un hilo puede obtener el
bloqueo mientras los demás se colocan en estado waiting.
Cuando un hilo llama al método unlock, el bloqueo del objeto es
liberado y uno de los hilos en espera intenta bloquear el objeto

La clase ReentrantLock (java.util.concurrent.locks) es una


implementación básica de la interface Lock. El constructor de
ReentrantLock toma un argumento booleano que especifica si el
bloqueo tiene una política de equidad. Si el argumento es
verdadero, la política de equidad de ReentrantLock es “el hilo que
espera más tiempo adquirirá el bloqueo cuando esté disponible”.
La política de equidad evita que ocurra el aplazamiento indefinido
(starvation).

Si el argumento es falso, no hay garantía de qué hilo adquirirá el


bloqueo cuando esté disponible.

Si un hilo que posee un Lock determina que no puede continuar


con su tarea hasta que una condición se cumpla, el hilo puede
esperar sobre un objeto Condition. Los objetos Lock permiten
declarar explícitamente objetos Condition en los que los hilos
podrían necesitar esperar.
Los objetos Condition son asociados con un específico Lock y son
creados llamando al método newCondition, que devuelve un
objeto Condition.

Para esperar sobre una Condition, el hilo puede llamar al método


await de Condition, el cual inmediatamente libera el Lock
asociado y coloca el hilo en waiting. Otros hilos pueden entonces
tratar de obtener el Lock.

Cuando un hilo en estado runnable cumple una tarea y determina


que un hilo en estado waiting puede continuar, el hilo runnable
puede llamar al método signal (de Condition, análogo a notify)
para permitir que el hilo en espera (de Condition) regrese al
estado runnable.

En este punto, el hilo que pasa de waiting a runnable puede


intentar readquirir el Lock.

Si múltiples hilos están en un estado waiting de Condition, cuando


signal es llamado, la implementación implícita de Condition
señala al hilo con más tiempo de espera para que pase al estado
runnable.

Si un hilo llama al método signalAll de Condition, entonces todos


los hilos en el estado waiting esperando por aquella Condition
pasan al estado runnable y se convierten en elegibles para
readquirir el bloqueo. Sólo un proceso adquirirá el bloqueo, los
otros esperarán hasta que el bloqueo esté disponible
nuevamente. Cuando un hilo termina su interacción con un
objeto compartido, debe llamar al método unlock para liberar el
Lock.

Los bloqueos permiten interrumpir los hilos en estado waiting o


especificar un timeout para adquirir el bloqueo, lo cual no es
posible synchronized. Además, un objeto Lock no es restringido a
adquirir y liberar los Locks en el mismo bloque de código (que es
el caso con Synchronized).
Es posible indicar a los hilos en estado waiting que una condición
específica se cumple, llamando a los métodos signal o signalAll
del objeto Condition.

Con synchronized, no hay forma de declarar explícitamente, la


condición sobre la que los hilos están esperando, por ende, no
hay forma de notificar a los hilos que esperan una condición, sin
notificar a otros que esperan sobre una condición distinta.

Ver los archivos:

BuferSincronizado2.java
PruebaBuferCompartido3.java

Interfaces Callable y Future

La interface Callable (java.util.concurrent) declara un solo


método llamado call. La interface es similar a Runnable,
permitiendo que una acción sea realizada concurrentemente en
un hilo separado, pero call permite que el hilo devuelva un valor
o lance una checked exception.

El método submit de ExecutorService ejecuta un Callable pasado


como su argumento.

El método submit devuelve un objeto de tipo Future (del paquete


java.util.concurrent) que representa la ejecución Callable.

La interface Future declara el método get para devolver el


resultado de Callable y proporciona otros métodos para manejar
una ejecución de Callable.

You might also like