UD2. Programación mulihilo.pdf
Document Details
Full Transcript
UD 2. Programación MULTIHILO 1. HILOS. 1.1. Introducción ¿Puedes descargarte una imagen desde tu navegador web, seguir navegando por Internet e iniciar la descarga de un nuevo archivo? ¿Cuántos procesos son necesarios? Pues bien, ¿Cómo es capaz de hacer el navegador web...
UD 2. Programación MULTIHILO 1. HILOS. 1.1. Introducción ¿Puedes descargarte una imagen desde tu navegador web, seguir navegando por Internet e iniciar la descarga de un nuevo archivo? ¿Cuántos procesos son necesarios? Pues bien, ¿Cómo es capaz de hacer el navegador web varias tareas a la vez? Seguro que estarás pensando en la programación concurrente, y así es; pero un nuevo enfoque de la concurrencia, denominado "programación multihilo". Entonces… ¿el multiproceso? MULTIPROCESO Consiste en la ejecución de varios procesos diferentes de forma simultánea para la realización de una o varias tareas relacionadas o no entre sí. En este caso, cada uno de estos procesos es una aplicación independiente. El caso más conocido es aquel en el que nos referimos al Sistema Operativo (Windows, Linux, MacOS,...) y decimos que es multitarea puesto que es capaz de ejecutar varias tareas o procesos (o programas) al mismo tiempo. MULTIHILO Cuando se ejecutan varias tareas relacionadas o no entre sí dentro de una misma aplicación. En este caso no son procesos diferentes sino que dichas tareas se ejecutan dentro del mismo proceso del Sistema Operativo. A cada una de estas tareas se le conoce como hilo o thread (en algunos contextos también como procesos ligeros). En ambos casos estaríamos hablando de lo que se conoce como Programación Concurrente. Hay que tener en cuenta que en ninguno de los dos casos (multiproceso y multihilo) la ejecución es realmente simultánea, ya que el Sistema Operativo es quién hace que parezca así, pero los ejecuta siguiendo lo que se conoce como algoritmos de planificación. Los programas realizan actividades o tareas, y para ello pueden seguir uno o más flujos de ejecución. Dependiendo del número de flujos de ejecución, podemos hablar de dos tipos de programas: Programa de flujo único Programa de flujo múltiple Programa de flujo único Es aquel que realiza las actividades o tareas que lleva a cabo una a continuación de la otra, de manera secuencial, lo que significa que cada una de ellas debe concluir por completo, antes de que pueda iniciarse la siguiente. Programa de flujo múltiple Es aquel que coloca las actividades a realizar en diferentes flujos de ejecución, de manera que cada uno de ellos se inicia y termina por separado, pudiéndose ejecutar éstos de manera simultánea o concurrente La programación multihilo o multithrearing consiste en desarrollar programas o aplicaciones de flujo múltiple. Cada uno de esos flujos de ejecución es un thread o hilo. En el ejemplo anterior sobre el navegador web un hilo se encargaría de la descarga de la imagen otro de continuar navegando otro de iniciar una nueva descarga. La utilidad de la programación multihilo resulta evidente en este tipo de aplicaciones. El navegador puede realizar "a la vez" estas tareas, por lo que no habrá que esperar a que finalice una descarga para comenzar otra o seguir navegando 1.2 conceptos sobre hilos Un hilo o thread es cada una de las tareas que puede realizar de forma simultánea una aplicación. Por defecto, toda aplicación dispone de un único hilo de ejecución, al que se conoce como hilo principal. Si dicha aplicación no despliega ningún otro hilo, sólo será capaz de ejecutar una tarea al mismo tiempo en ese hilo principal. Así, para cada tarea adicional que se quiera ejecutar en esa aplicación, se deberá lanzar un nuevo hilo o thread. Para ello, todos los lenguajes de programación, como Java, disponen de una API para crear y trabajar con ellos. Cuando se ejecuta un programa, el Sistema Operativo crea un proceso y también crea su primer hilo, hilo principal, el cual puede a su vez crear hilos adicionales. Desde este punto de vista, un proceso no se ejecuta, sino que solo es el espacio de direcciones donde reside el código que es ejecutado mediante uno o más hilos. Por lo tanto podemos hacer las siguientes observaciones: Un hilo no puede existir independientemente de un proceso. Un hilo no puede ejecutarse por si solo. Dentro de cada proceso puede haber varios hilos ejecutándose. Un único hilo es similar a un programa secuencial; por si mismo no nos ofrece nada nuevo. 1.3 estados de un hilo 1.4 Recursos compartidos por los hilos Un hilo lleva asociados los siguientes elementos: Un identificador único. Un contador de programa propio. Un conjunto de registros. Una pila (variables locales). Por otra parte, un hilo puede compartir con otros hilos del mismo proceso los siguientes recursos: Código. Datos (como variables globales). Otros recursos del sistema operativo, como los ficheros abiertos y las señales. ¿qué pasa si uno de ellos la corrompe? En el caso de procesos, el sistema operativo normalmente protege a un proceso de otro y si un proceso corrompe su espacio de memoria los demás no se verán afectados… buffer overflow. El hecho de que los hilos compartan recursos (por ejemplo, pudiendo acceder a las mismas variables) implica que sea necesario utilizar esquemas de bloqueo y sincronización, lo que puede hacer más difícil el desarrollo de los programas y así como su depuración. 1.5 Ventajas y usos de hilos Como consecuencia de compartir el espacio de memoria, los hilos aportan las siguientes ventajas sobre los procesos: Se consumen menos recursos en el lanzamiento y la ejecución de un hilo que en el lanzamiento y ejecución de un proceso. Se tarda menos tiempo en crear y terminar un hilo que un proceso. La conmutación entre hilos del mismo proceso o cambio de contexto es bastante más rápida que entre procesos. Es por esas razones, por lo que a los hilos se les denomina también procesos ligeros. ¿cuándo se aconseja utilizar hilos? La aplicación maneja entradas de varios dispositivos de comunicación. La aplicación debe poder realizar diferentes tareas a la vez. Interesa diferenciar tareas con una prioridad variada. Por ejemplo, una prioridad alta para manejar tareas de tiempo crítico y una prioridad baja para otras tareas. La aplicación se va a ejecutar en un entorno multiprocesador. Los hilos son idóneos para programar aplicaciones de entornos interactivos y en red, así como simuladores y animaciones. (Por ejemplo en Java, AWT o la biblioteca gráfica Swing usan hilos) Por ejemplo, imagina la siguiente situación: Debes crear una aplicación que se ejecutará en un servidor para atender peticiones de clientes. Esta aplicación podría ser un servidor de bases de datos, o un servidor web. Cuando se ejecuta el programa éste abre su puerto y queda a la escucha, esperando recibir peticiones. Si cuando recibe una petición de un cliente se pone a procesarla para obtener una respuesta y devolverla, cualquier petición que reciba mientras tanto no podrá atenderla, puesto que está ocupado. La solución será construir la aplicación con múltiples hilos de ejecución. Solución: En este caso, al ejecutar la aplicación se pone en marcha el hilo principal, que queda a la escucha. Cuando el hilo principal recibe una petición, creará un nuevo hilo que se encarga de procesarla y generar la consulta, mientras tanto el hilo principal sigue a la escucha recibiendo peticiones y creando hilos. De esta manera un gestor de bases de datos puede atender consultas de varios clientes, o un servidor web puede atender a miles de clientes. Si el número de peticiones simultáneas es elevado, la creación de un hilo para cada una de ellas puede comprometer los recursos del sistema. (DOS o DDOS) 1.6 Algoritmos de planificación de hilos En entornos multitarea, un algoritmo de planificación indica la forma en que el tiempo de procesamiento debe repartirse entre todas las tareas que deben ejecutarse en un momento determinado. Existen diferentes algoritmos de planificación, cada uno con sus ventajas e inconvenientes, pero todos intentan cumplir con los siguientes puntos: Debe ser imparcial y eficiente Debe minimizar el tiempo de respuesta al usuario, sobre todo en aquellos procesos o tareas más interactivas Debe ejecutar el mayor número de procesos Debe mantener un equilibrio en el uso de los recursos del sistema FCFS: First Come First Served El primer proceso que llegue al procesador se ejecuta antes y de forma completa. Hasta que su ejecución no termina no podrá pasarse a ejecutar otro proceso. RR: Round Robin Se le conoce también como algoritmo de turno rotatorio. En este caso se designa una cantidad corta de tiempo (quantum) de procesamiento a todas las tareas. Las que necesiten más tiempo de proceso deberán esperar a que vuelva a ser su turno para seguir ejecutándose. SPF: Shortest Process First En este algoritmo, de todos los procesos listos para ser ejecutados, lo hará primero el más corto SRT: Shortest Remaining Time De todos los procesos listos para ejecución, se ejecutará aquel al que le quede menos tiempo para terminar. Varias colas con realimentación Es un algoritmo más complejo que todos los anteriores y, por tanto, más realista. Se utiliza en entornos donde se desconoce el tiempo de ejecución de un proceso al inicio de su ejecución. En este caso, el sistema dispone de varias colas que a su vez pueden disponer de diferentes políticas unas de otras. Los procesos van pasando de una cola a otra hasta que terminan su ejecución. En algunos casos, el algoritmo puede adaptarse modificando el número de colas, su política,... 2. Elementos relacionados con la programación de hilos librerías y clases El paquete java.util.concurrent incluye una serie de clases que facilitan el desarrollo de aplicaciones multihilo y aplicaciones complejas, ya que están concebidas para utilizarse como bloques de diseño. Concretamente estas utilidades están dentro de java.util.concurrent. En este paquete están definidos los siguientes elementos: Clases de sincronización. Semaphore, CountDownLatch, CyclicBarrier y Exchanger. Interfaces para separar la lógica de la ejecución, como por ejemplo: Executor, ExecutorService, Callable y Future. Interfaces para gestionar colas de hilos. BlockingQueque, LinkedBlokingQueque, nArrayBlockingQueque, SynchronousQueque, PriorityBlockingQueque y DelayQueque. https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/package-summary.htm l 3. Gestión de hilos 3.1 Creación, ejecución y finalización de hilos En Java, un hilo se representa mediante una instancia de la clase java.lang.thread. Este objeto thread se emplea para iniciar, detener o cancelar la ejecución del hilo de ejecución. Los hilos o threads se pueden implementar o definir de dos formas: Extendiendo la clase thread. Mediante la interfaz Runnable. En ambos casos, se debe proporcionar una definición del método run(), ya que este método es el que contiene el código que ejecutará el hilo, es decir, su comportamiento. Podemos utiliza la clase Thread heredando de ella. Es quizás la forma más cómoda porque una clase que hereda de Thread se convierte automáticamente en un hilo. Tiene una pega: esa clase ya no podrá heredar de ninguna otra, por lo que si la arquitectura de nuestra aplicación lo requiere ya no podríamos, ya que Java no permite la herencia múltiple. Para definir y crear un hilo extendiendo la clase thread, haremos lo siguiente: Crear una nueva clase que herede de la clase thread. Redefinir en la nueva clase el método run() con el código asociado al hilo. Las sentencias que ejecutará el hilo. Crear un objeto de la nueva clase thread. Éste será realmente el hilo. Una vez creado el hilo, para ponerlo en marcha o iniciarlo: Invocar al método start() del objeto thread (el hilo que hemos creado). Si tenemos la limitación de la herencia simple, podemos implementar el interface Runnable de forma que la clase que nosotros estamos implementado podrá además heredar sin ninguna limitación. Sólo cambia un poco la forma de trabajar directamente con la clase hilo, Thread. Es el procedimiento más general y también el más flexible. Para definir y crear hilos implementando la interfaz Runnable seguiremos los siguientes pasos: Declarar una nueva clase que implemente a Runnable. Redefinir (o sombrear) en la nueva clase el método run() con el código asociado al hilo. Lo que queremos que haga el hilo. Crear un objeto de la nueva clase. Crear un objeto de la clase thread pasando como argumento al constructor, el objeto cuya clase tiene el método run(). Este será realmente el hilo. Una vez creado el hilo, para ponerlo en marcha o iniciarlo: Invocar al método start() del objeto thread (el hilo que hemos creado). ¿Qué pasa si ejecutas varias veces el código de los ejemplos? ¿Siempre ocurre lo mismo? 3.2 sincronización de hilos El API de Java proporciona una serie de métodos en la clase Thread para la sincronización de los hilos en una aplicación: join() Se espera la terminación del hilo que invoca a este método antes de continuar Thread.sleep(int) El hilo que ejecuta esta llamada permanece dormido durante el tiempo especificado como parámetro (en ms) isAlive() Comprueba si el hilo permanece activo todavía (no ha terminado su ejecución) yield() Sugiere al scheduler que sea otro hilo el que se ejecute (no se asegura 3.2 sincronización de hilos 3.2.1 join() join() Se espera la terminación del hilo que invoca a este método antes de continuar El hilo principal espera a que ambos hilos se hayan ejecutado para continuar (o para lo que sea) En este caso, además, los hilos se ejecutan uno detrás del otro. 3.2 sincronización de hilos 3.2.2 Thread.sleep(int) Thread.sleep(int) El hilo que ejecuta esta llamada permanece dormido durante el tiempo especificado como parámetro (en ms) En este caso el hilo duerme (detiene su ejecución) durante el tiempo especificado (en ms). Durante ese momento podrán ejecutarse otros hilos 3.2 sincronización de hilos 3.2.3 isAlive() isAlive() Comprueba si el hilo permanece activo todavía (no ha terminado su ejecución) isAlive() está indicando que el hilo está vivo (ha iniciado su ejecución y aún no ha muerto, puede estar en cualquier estado intermedio, incluso durmiendo) Sin nada de P4. Enfermer@ - Panda metropolitano hilos, ineficiente Con hilos, P4. Enfermer@ - Panda metropolitano eficiente 4. Compartición de información (comunicación) entre hilos. Recursos compartidos Hay ocasiones en las que distintos hilos de un programa necesitan establecer alguna relación entre sí y compartir recursos o información. Se pueden presentar las siguientes situaciones: Dos o más hilos compiten por obtener un mismo recurso, por ejemplo dos hilos que quieren escribir en un mismo fichero o acceder a la misma variable para modificarla. Dos o más hilos colaboran para obtener un fin común y para ello, necesitan comunicarse a través de algún recurso. Por ejemplo un hilo produce información que utilizará otro hilo. En cualquiera de estas situaciones, es necesario que los hilos se ejecuten de manera controlada y coordinada, para evitar posibles interferencias que pueden desembocar en programas que se bloquean con facilidad y que intercambian datos de manera equivocada. ¿Cómo conseguimos que los hilos se ejecuten de manera coordinada? Utilizando sincronización y comunicación de hilos: Sincronización. Es la capacidad de informar de la situación de un hilo a otro. El objetivo es establecer la secuencialidad correcta del programa. Comunicación. Es la capacidad de transmitir información desde un hilo a otro. El objetivo es el intercambio de información entre hilos para operar de forma coordinada.. En Java la sincronización y comunicación de hilos se consigue mediante: Monitores. Se crean al marcar bloques de código con la palabra synchronized. Semáforos. Podemos implementar nuestros propios semáforos, o bien utilizar la clase Semaphore incluida en el paquete java.util.concurrent. Notificaciones. Permiten comunicar hilos mediante los métodos wait(), notify() y notifyAll() de la clase java.lang.Object 4.1 Información compartida entre hilos Las secciones críticas son aquellas secciones de código que no pueden ejecutarse concurrentemente, pues en ellas se encuentran los recursos o información que comparten diferentes hilos, y que por tanto pueden ser problemáticas. Un ejemplo sencillo que ilustra lo que puede ocurrir cuando varios hilos actualizan una misma variable es el clásico "ejemplo de los jardines". En él, se pone de manifiesto el problema conocido como la "condición de carrera", que se produce cuando varios hilos acceden a la vez a un mismo recurso, por ejemplo a una variable, cambiando su valor y obteniendo de esta forma un valor no esperado de la misma. En este problema se supone que se desea controlar el número de visitantes a unos jardines. La entrada y la salida a los jardines se pueden realizar por dos puntos que disponen de puertas giratorias. Se desea poder conocer en cualquier momento el número de visitantes a los jardines, por lo que se dispone de un computador con conexión en cada uno de los dos puntos de entrada que le informan cada vez que se produce una entrada o una salida. Asociamos el proceso P1 a un punto de entrada y el proceso P2 al otro punto de entrada. Ambos procesos se ejecutan de forma concurrente y utilizan una única variable x para llevar la cuenta del número de visitantes. El incremento o decremento de la variable se produce cada vez que un visitante entra o sale por una de las puertas. Así, la entrada de un visitante por una de las puertas hace que se ejecute la instrucción x:=x+1 mientras que la salida de un visitante hace que se ejecute la instrucción x:=x-1 En el ejemplo del "problema de los jardines", el recurso que comparten diferentes hilos es la variable contador cuenta. Las secciones de código donde se opera sobre esa variable son dos secciones críticas, los métodos incrementaCuenta() y decrementaCuenta(). La forma de proteger las secciones críticas es mediante sincronización. La sincronización se consigue mediante: Exclusión mutua. Asegurar que un hilo tiene acceso a la sección crítica de forma exclusiva y por un tiempo finito. Por condición. Asegurar que un hilo no progrese hasta que se cumpla una determinada condición. En Java, la sincronización para el acceso a recursos compartidos se basa en el concepto de monitor. un método acceda a una variable miembro que esté compartida deberemos proteger dicha sección crítica, usando synchronized. Se puede poner todo el método synchronized o marcar un trozo de código más pequeño. Ejemplo: Sacar dinero de una cuenta en común 5.1 El modelo productor-consumidor El problema del modelo productor-consumidor es un problema clásico de la sincronización y supone un patrón común a diversos tipos de aplicaciones. El planteamiento inicial de este caso es el siguiente: disponemos de dos hilos, donde uno de ellos produce datos y el otro los consume. El problema se produce cuando el consumidor y el productor no realizan estas tareas de forma coordinada. Como ejemplo veamos el siguiente programa, en el que dispondremos de un objeto compartido en el que una clase productora escribe valores que son leídos por una clase consumidora. Objeto compartido La siguiente clase ObjetoCompartido tendrá un atributo entero, que guardará un valor, y un booleano, que indica la disponibilidad de este. También implementa un método get, que devuelve el valor que contiene el objeto y establece a falso la disponibilidad de este, puesto que ha sido consumido. El método set establece este valor e indica que está disponible. Clase Productor Por su parte, las clases Productor y Consumidor serán dos clases que implementen la interfaz Runnable y se inicializarán con un objeto compartido (de la clase ObjetoCompartido). Ambas clases en su método run() accederán a dicho objeto compartido, de modo que el Productor escriba (produzca) valores en él y el Consumidor lea (consuma) dichos valores. Concretamente, para ejemplificar esto el productor escribirá mediante un bucle cinco valores, con una pausa entre escrituras de 500 ms, mientras que el consumidor leerá estos cinco valores en un bucle, con pausas de 100 ms. Clase Productor Clase Consumidor Clase Principal Finalmente, disponemos de una clase principal, ModeloProductorConsumidor que creará un hilo a partir de cada una de estas clases y los lanzará: Salida Salida El comportamiento deseado consistiría en que el hilo consumidor fuese consumiendo los números a medida que el hilo productor los fuese generando. En el caso contrario, en el que el productor produjese a mayor velocidad que el consumidor, y dado que en nuestro objeto compartido solamente cabe un objeto, habría valores que se perderían, puesto que se generaría el siguiente valor antes de que fuese leído. La solución natural a este problema consistirá en que el productor espere a que el consumidor consuma los datos antes de producir nuevos datos. Del mismo modo, el consumidor deberá esperar a que los datos se hayan producido para poder consumirlos Modificación de la Clase ObjetoCompartido, para solucionar el problema utilizando monitores La librería java.util.concurrent Introducción La librería java.util.concurrent es un conjunto de clases y utilidades proporcionadas por Java para facilitar la programación concurrente y el manejo de threads. Esta librería ofrece estructuras de datos concurrentes, como colas, listas y mapas, así como clases para la ejecución de tareas en paralelo, control de la concurrencia, sincronización y gestión de hilos, lo que permite desarrollar aplicaciones seguras y eficientes en entornos multihilo. La librería java.util.concurrent ofrece un conjunto muy amplio de clases e interfaces orientadas a gestionar la programación concurrente de una forma más sencilla y óptima. Entre todas ellas se encuentran: API ExecutorService Esta API se encarga de separar las tareas de creación de threads, su ejecución y su administración, encapsulando la funcionalidad y mejorando el rendimiento. Interfaz Callable Esta interfaz nos resuelve el problema de los Threads con los valores de retorno. Interfaz BlockingQueue La interfaz BlockingQueue y varias clases que la implementan con diferentes estructuras de datos, con la característica común de que los accesos pueden llegar a ser bloqueantes si la lista está vacía o ha llegado al tope de su capacidad. Esto nos será de gran utilidad para abordar diferentes problemas de sincronización sin la necesidad de utilizar monitores en el acceso a objetos compartidos. Interfaz Callable La interfaz Callable La interfaz Callable es una versión mejorada de la interfaz Runnable, introducida en Java 1.5, que permite retornar un valor al finalizar su ejecución. Cuando utilizamos la interfaz Runnable, disponemos de un método run() que no acepta parámetros ni devuelve ningún valor. Esto no es un problema, siempre y cuando no necesitemos proporcionar ni obtener datos del Thread. En cambio, cuando hemos necesitado proporcionar valores y obtener resultados de la ejecución de un hilo, hemos optado por la comunicación mediante objetos compartidos. Por su parte, la interfaz genérica Callable proporciona el método call(), que devuelve un valor de tipo genérico. La sintaxis general de una clase que implemente esta interfaz es: Como principal diferencia respecto a Runnable, podemos ver que en la definición de la clase debemos indicar ya un tipo de datos genérico, que será el mismo que el del valor de retorno del método call, el cual, como vemos, utiliza ahora un return para devolver dicho valor. Con la anterior definición podríamos utilizar el método call directamente Paso 1. Uso Pero con ello no estaríamos ejecutando directo del el método de forma asíncrona. Una primera aproximación a ello sería utilizar directamente la método call clase FutureTask, introducida en Java 5, y que se puede utilizar para realizar tareas asíncronas, ya que implementa la interfaz Runnabley, por tanto, puede lanzarse como un hilo. La forma de hacer esto sería mediante la creación de un objeto de tipo FutureTask, de forma genérica, a partir del objeto creado de la clase Callable. Paso 2. Creación de un objeto de Este tipo genérico será el mismo que hayamos definido para nuestra tipo FutureTask clase Callable. Dado que FutureTask implementa Runnable, podemos crear ahora un Thread a partir de esta clase y lanzarlo: Con esto el programa principal espera a que finalicen los hilos que ha generado. Una vez haya Paso 3. finalizado FutureTask, vamos a poder acceder al valor devuelto Valor devuelto por el método callmediante el método get: (se utiliza mucho más la API ExecutorService) Ejemplo de Sumatorio Realiza el ejemplo del sumatorio entre dos números utilizando tantos hilos de ejecución como procesadores tenga disponible nuestro ordenador, definiendo para ello el método de Suma como Callable, y utilizando la clase FutureTask La API ExecutoService La API EXECUTOR SERVICE ExecutorService es una API de Java que nos permite simplificar la ejecución de tareas asíncronas. Para ello nos ofrece un conjunto de hilos preparados para asignarles tareas. La forma más sencilla para crear un ExecutorService es utilizando la clase Executors, la cual proporciona varios métodos de factoría y utilidades para la creación de múltiples API de ejecución asíncrona. Método newFixedThreadPool() Por ejemplo, el método de factoría newFixedThreadPool crea un servicio de ejecución asíncrona con un conjunto de Threads de longitud fija. Para crear un servicio de este tipo que ponga a nuestra disposición hasta 10 hilos de ejecución haríamos: Aparte de este método, disponemos de muchos otros, como newCachedThreadPool(), que genera los hilos a medida que se van necesitando. Clase Executors (API) La forma de trabajar con esta API es relativamente simple, ya que solamente requiere de instancias de objetos de tipo Runnable o Callable y ella se encarga del resto de las tareas. Así pues, una vez disponemos del servicio ExecutorService instanciado, tenemos a nuestra disposición diferentes métodos para asignarle tareas. Pero antes de abordar estos métodos vamos a ver la interfaz Future, para entenderlos mejor. A. Interfaz Future La interfaz Future, definida en el paquete java.utill.concurrent, representa el resultado de una operación asíncrona. El valor de retorno, del tipo genérico indicado, no lo obtendremos de forma inmediata, sino que se obtendrá en el momento en que finalice la ejecución de la tarea asíncrona. En este momento, el objeto Future tendrá disponible dicho valor de retorno. La interfaz Future proporcionará los mecanismos para saber si ya se dispone del resultado, para esperar a tener resultados y para consultar dichos resultados, así como para cancelar la función asíncrona si todavía no ha terminado. boolean isDone() Devuelve cierto si la tarea ha terminado TipoGenérico get() Retorna el valor devuelto por la tarea, siempre que este esté disponible. En caso de no estar disponible, espera a que lo esté TipoGenérico get(longt, TimeUnit u) Retorna el valor devuelto por la tarea, siempre que este esté disponible. En caso de no estar disponible, espera como mucho el tiempo t indicado en unidades de tiempo u boolean cancel(booleaninterrupcion) Intenta cancelar una tarea que está en ejecución. El booleano que le proporcionamos indica si debe interrumpirse para cancelar la tarea boolean isCancelled() Devuelve cierto si la tarea ha sido cancelada antes de completarse. b. Asignación de tareas al Executorservice Método Descripción Uso void execute() Método heredado de la interfaz Executor, ExecutorService s=...; que simplemente ejecuta la tarea indicada, s.execute(tareaRunnable); pero sin la posibilidad de obtener su resultado o estado. Future submit() Envía una tarea, bien de tipo Callable o ExecutorService s=...; Future Runnable al servicio, retornando un tipo resultado = Future genérico T. s.submit(tarea); Future invokeAny() Asigna una colección de tareas al servicio ExecutorService s=...; y devuelve el resultado de cualquiera de... ellas. Future resultado = s.invokeAny(ListaTareas); List Asigna una colección de tareas al servicio, ExecutorService s=...; ejecutándolas todas, y devuelve una lista... List resultados = InvokeAll() s.invokeAll(ListaTareas); de Futures con los resultados. C. Finalización del servicio Una vez que dejemos de utilizar el servicio y que hayamos obtenido todos los resultados, utilizaremos el método shutdown para finalizar el servicio servicio.shutdown(); Ejemplo de uso Colas Concurrentes: BlockingQueue Colas Concurrentes: BlockingQueue Al igual que las listas y las pilas, se trata de una estructura de datos compleja. La principal característica de las colas es que tienen una estructura FIFO (First In – First Out). El concepto es similar al de hacer cola, en el supermercado o en el cajero del banco, donde el primero que llega es el primero en ser atendido, y el resto de clientes van incorporándose al final de la cola. En Java este comportamiento lo ofrece la interfaz Queue, que posee varias clases que la implementan, como ArrayQueue, ArrayDeque o AbstractQueue, entre otras Por otra parte, la interfaz BlockingQueue ofrece un comportamiento similar, con la diferencia de que además este comportamiento ofrece mecanismos de acceso seguros en operaciones concurrentes (thread-safe), lo que nos será de gran utilidad a la hora de abordar problemas de sincronización, como el de los productores-consumidores. Mediante esta interfaz, los hilos productores deberán esperar a producir si la cola está llena, y los consumidores esperar a consumir mientras la cola esté vacía. Todo el comportamiento que generábamos mediante monitores en el objeto compartido nos lo gestionará ahora la cola. Esta interfaz aporta dos métodos a las colas: put y take, que serían los equivalentes a add y remove, pero con la particularidad de que las operaciones se realizan de forma segura. Implementaciones de BlockingQueue ArrayBlockingQueue. Implementa una cola mediante un array, por lo que tendrá una longitud fija. Las operaciones de put y take asegurarán que no se sobreescriban las entradas. LinkedBlockingQueue. Implementa una cola mediante una lista enlazada, donde cada elemento de la lista es un nodo nuevo, con lo que en principio podría crecer indefinidamente (hasta llegar a Integer.MAX_VALUE). Su constructor admite un entero para especificar el número de elementos máximo que puede almacenar. Las operaciones de put y take asegurarán que no se sobreescriban las entradas. PriorityBlockingQueue Implementa una cola mediante un heap binario basado en un array, lo que permite consumir los elementos en un orden específico DelayQueue Implementa una cola que contiene elementos que implementan la interfaz Delayed. Los objetos que implementan esta interfaz requieren de un retraso (delay) para operar sobre ellos. Estas colas son útiles, por ejemplo, cuando un consumidor únicamente puede consumir elementos después de un tiempo. LinkedTrasnferQueue ncluye el método transfer, que permite a un productor esperar a que se consuma un ítem, de modo que los consumidores puedan manejar el flujo de mensajes producidos. SynchronousQueue Contiene como mucho un elemento, de modo que representa una forma sencilla de intercambiar datos entre dos hilos ConcurrentLinkedQueueep Se trata de una cola no bloqueante, apta para la programación de sistemas reactivos Ejemplo de colas concurrentes con el productor-consumidor Adaptar el problema del productor-consumidor utilizando una cola concurrente (interfaz BloquingQueue), para que presente un comportamiento de tipo FIFO. Esta interfaz nos permitirá además acceder de forma segura a sus elementos en operaciones concurrentes (thread-safe) mediante los métodos put (almacenar) y take (recuperar). Esta cola será el objeto compartido entre productor y consumidor, de modo que no hará falta utilizar monitores. Produciremos y consumiremos 15 elementos de tipo entero sobre una cola de 3 hilos como máximo. 05. Aspectos a tener en cuenta 5.1. Problema de la Cena de los Filósofos Este problema clásico ilustra el concepto de deadlock y cómo se puede evitar utilizando técnicas de sincronización. Imagina que hay cinco filósofos sentados alrededor de una mesa redonda, y entre cada par de filósofos hay un tenedor. Cada filósofo necesita dos tenedores para comer, pero solo puede tomar uno a la vez. Si todos los filósofos intentan tomar el tenedor de su izquierda al mismo tiempo, se produce un deadlock, ya que cada filósofo está esperando que el otro suelte el tenedor que necesita. Para evitar el deadlock, se pueden utilizar diferentes estrategias de sincronización, como el algoritmo del conserje o el algoritmo de asignación asimétrica de tenedores. Estos algoritmos garantizan que los filósofos puedan tomar tenedores de manera segura sin provocar un deadlock. 5.2. Sistema de Control de Semáforos Los semáforos son herramientas de sincronización que permiten la exclusión mutua y la coordinación entre hilos. Un semáforo puede tener un valor entero y admite dos operaciones principales: wait (esperar) y signal (señal). Un ejemplo clásico de uso de semáforos es el control del acceso a recursos compartidos. Por ejemplo, supongamos que hay un recurso compartido entre múltiples hilos, como una impresora. Para evitar que varios hilos intenten imprimir al mismo tiempo y generen un conflicto, se puede utilizar un semáforo para garantizar que solo un hilo tenga acceso a la impresora en un momento dado. 5.3. Deadlock y Condiciones de Bernstein El deadlock ocurre cuando dos o más hilos quedan atrapados esperando que el otro libere un recurso que necesitan. Esto puede ocurrir cuando hay una competencia por recursos limitados y los hilos no los liberan de manera adecuada. Las condiciones de Bernstein, también conocidas como condiciones de Coffman, son un conjunto de condiciones necesarias para que se produzca un deadlock en un sistema concurrente. Estas condiciones incluyen la exclusión mutua, la espera circular y la retención y espera. Evitar estas condiciones es fundamental para prevenir deadlocks en sistemas multihilo. ALGORITMO/PROBLEMA APLICACIÓN REAL Sistemas operativos concurrentes: representa la Problema de la Cena de los necesidad de sincronizar el acceso a recursos Filósofos compartidos para evitar deadlocks y maximizar la eficiencia del sistema. Sistemas de gestión de recursos en sistemas Sistema de Control de operativos: se utilizan para coordinar el acceso a Semáforos recursos compartidos como archivos, impresoras o conexiones de red. Sistemas de bases de datos: se aplican técnicas Deadlock y Condiciones de de control de concurrencia para evitar Bernstein situaciones de deadlock y garantizar la integridad de los datos en entornos de múltiples usuarios. Código de cena de filósofos con semáforos 1. Definición de la clase Philosopher: Philosopher es una clase que extiende Thread y representa a un filósofo en el problema de la cena de los filósofos. Tiene tres atributos: id para identificar al filósofo, leftFork para el tenedor izquierdo y rightFork para el tenedor derecho, ambos representados por semáforos. 2. Método run de la clase Philosopher: El método run es el punto de entrada del hilo. Aquí se define el comportamiento de un filósofo. El filósofo entra en un bucle infinito para simular el proceso continuo de pensar, comer y esperar. Dentro del bucle, el filósofo piensa, luego intenta adquirir los tenedores izquierdo y derecho (semáforos), come y finalmente suelta los tenedores. 3. Clase Principal: Esta clase es la entrada principal del programa. Define el número de filósofos y crea un arreglo de semáforos forks para representar los tenedores. Inicializa cada tenedor con un semáforo con un permiso (indicando que está disponible para ser usado). Luego, crea un arreglo de filósofos y para cada filósofo, crea dos semáforos leftFork y rightFork que corresponden al tenedor izquierdo y derecho respectivamente, y luego inicia el hilo del filósofo. 4. Uso de semáforos para la exclusión mutua: Cada filósofo intenta adquirir los tenedores izquierdo y derecho utilizando la operación acquire() de los semáforos. Si un tenedor no está disponible, el hilo entra en estado de espera hasta que el semáforo asociado al tenedor se libere (cuando otro filósofo lo suelta). 5. Prevenir deadlocks: El uso de semáforos ayuda a prevenir deadlocks al garantizar que cada filósofo pueda adquirir ambos tenedores de manera segura antes de empezar a comer. Si un tenedor no está disponible, el filósofo espera pacientemente hasta que esté libre, evitando así situaciones de bloqueo mutuo.