Está en la página 1de 4

Introducción y filas:

Un pool es similar a un set, con dos diferencias relevantes. No provee un método de contains()
para poner a prueba la afiliación, y permite que el mismo ítem aparezca más de una vez. Pool
tiene métodos de get() y set(), y está presente en muchos sistemas concurrentes. Los pools
pueden venir en diferentes variedades: Pueden ser encerrados o no encerrados, totales, parciales,
o sincrónicos; o pueden proveer diferentes garantías de equidad.

Los pools que son encerrados tienen un número limitado de ítems (capacidad), mientras que los
no encerrados no la tienen. Los pools encerrados son útiles para asegurar que los hilos de
productores y consumidores estén flojamente sincronizados para evitar que los productores no se
adelanten a los consumidores. Los pools no encerrados son útiles cuando no es necesario tener un
límite en qué tanto los productores se aventajan a los consumidores.

Los métodos de pools totales no esperan a que ciertas condiciones se cumplan. Tiene sentido
tenerlos cuando el hilo del productor o consumidor tiene algo mejor que hacer que esperar a que
el llamado del método tenga un efecto. En los métodos de pools parciales, las llamadas esperan a
que las condiciones se mantengan. El método de pool parcial tiene sentido cuando el productor o
consumidor no tiene nada mejor que hacer más que esperar a que el pool esté no vacío o no lleno.
El método de pool es sincrónico si espera a que otro método se sobrelape en su intervalo de
llamado. Se utilizan en comunicación en lenguajes de programación como CSP y Ada, donde los
hilos se reúnen para intercambiar información.

Los pools proveen diferentes garantías de equidad, pidiendo ser “first-in-first-out” (una fila), “last-
in-last-out” (pila), o con otras propiedades más débiles. La importancia de la equidad al hacer
amortiguamiento con un pool es evidente para cualquier persona que haya estado en una línea
telefónica de servicio al cliente.

Se considera un pool que provee equidad “first-in-first out” (FIFO). Una fila secuencial <T> es una
secuencia ordenada de ítems (de tipo T) que provee un método enq(x) que pone un ítem al final
de la fila (la cola) y un método deq() que remueve y regresa el ítem al otro extremo de la fila (la
cabeza). Una fila concurrente es linealizable a una fila secuencial. Las filas son pools donde enq()
implementa a put(), y deq() implementa a get().

Filas parciales encerradas: Se asume que es ilegal agregar un valor nulo a la fila. Informalmente,
los métodos enq() y deq() operan en lados opuestos de la fila, siempre que la fila no esté llena o
vacía. Los llamados enq() o deq() concurrentes probablemente interferirían. Se implementa una
fila encerrada como una lista vinculada (figuras 10.2 a 10.6). La fila tiene una cabeza y cola (los
primeros y últimos nodos en la fila). La fila siempre tiene un centinela actuando como marcador de
posición pero cuyo valor es irrelevante. La fila reemplaza constantemente al nodo centinela. Se
usan dos distintos locks, enqLock y deqLock para asegurar que máximo un enqueuer y máximo un
dequeuer (agregan o remueven elementos de una fila) puedan manipular los campos de objetos
de la fil. El enqLock está asociado con la condición notFullCondition, que se usa para notificar a los
enqueuers cuando la fila ya no está llena. El deqLock está asociado con la condición
notEmptyCondition, que notifica a los enqueuers en espera cuando la fila ya no está vacía. Es
necesario tener en cuenta el número de espacios vacíos porque la fila es encerrada. El tamaño del
campo es un AtomicInteger (cuenta el # de objetos en la fila), disminuye por llamados de deq(), y
aumenta por llamados de enq().

El método enq() funciona de la siguiente forma: Un hilo adquiere el enqLock y lee el campo “size”.
Mientras ese campo=capacidad, la fila estará llena y el enqueuer debe esperar a que un dequeuer
haga espacio en la fila. El enqueuer espera al campo notFullCondition, liberando el bloqueo de
enqueue temporalmente y hasta que la condición sea señalada. Cada que el hilo despierta, revisa
si hay espacio, y si no; se va a dormir. Cuando el número de espacios vacíos>0, el enqueuer puede
proceder, pero los demás enqueuers están bloqueados y un dequeuer concurrente solamente
puede aumentar el número de espacios disponibles (la sincronización del método enq() es
simétrica). Se debe revisar que no esté el bug “lost-wakeup” en esta implementación porque el
enqueuer encuentra una fila llena en dos pasos separados: Primero ve que size es la capacidad de
la fila, y luego espera a la condición notFullCondition, y por ende; el dequeuer no puede señalizar
entre los dos pasos del enqueuer.

El método deq() procede de la siguente forma: Lee el campo “next” de la cabeza, y revisa si el
campo “next” del centinela está vacío. Si es así, la fila está vacía y hay que esperar hasta que haya
un objeto en fila. El dequeuer espera a la condición notEmptyCondition, que temporalmente libera
el deqLock y bloquea hasta que la condición haya sido señalizada. Cada vez que el hilo despierta,
revisa si la fila está vacía, y si es así; regresa a dormir. Hay que considerar que la cabeza y cola
abstractas de la fila no siempre son los mismos ítems que head y tail. Un ítem se agrega
logícamente a la fila tan pronto el campo “next” del último nodo sea redirigido al nuevo ítem
(punto de linealización de enq()), incluso si el enqueuer no ha actualizado “tail”. Un hilo de
desempate (dequeuing) puede adquirir el deqLock, leer, y regresar el nuevo valor del nodo,
redirigiendo “head” hacia el nuevo nodo antes de que el enqueuer rediriga “tail” al nodo recién
insertado. Cuando el dequeuer establece que la fila no está vacía, la fila permanece no vacía
mientras dure el llamado de deq(), porque todos los demás dequeuers han sido bloqueados. Si el
dequeuer encontrara que el tamaño previo era la capacidad de la fila, entonces habría enqueuers
esperando la condición notEmptyCondition, así que el dequeuer adquiere enqLock y señaliza a
todos los hilos de ese tipo para que despierten.

Una desventaja de esta implementación es que los llamados enq() y deq() concurrentes interfieren
entre ellos pero no a través de bloqueos. Todos los llamados de métodos aplican llamados
getAndIncrement() o getAndCDecrement() al campo “size”. Estos métodos son más costosos que
los read-writes ordinarios, pudiendo causar un cuello de botella secuencial. Una forma de reducir
estas interacciones es diviendo este campo en dos contadores: enqSideSize es un campo entero
disminuido por deq() y deqSideSize es un campo entero aumentado por enq(). Un hilo con
llamados de enq() pone a prueba enqSideSize, y siempre que sea menor que la capacidad;
procede. Cuando el campo alcanza la capacidad, el hilo bloquea deqLock y agrega deqSideSize a
enqSideSize, sincronizando esporádicamente cuando el estimado del tamaño del enqueuer se
vuelve muy grande.

Una fila total no encerrada: Ahora se describe una fila que puede tener una cantidad no
encerrada de ítems. El método enq() siempre pone en fila su ítem, y deq() arroja EmptyException
si no hay algún ítem para remover de la fila. La representación es idéntica a la de la fila encerrada,
excepto porque no hay necesidad de contar el número de ítems en la fila o de proveer condiciones
de espera. Este tipo de fila no puede llegar a un punto muerto, porque cada método adquiere
solamente un bloqueo (enqLock() o deqLock()). Un nodo centinela en la fila nunca será borrado, y
cada llamado de enq() tendrá éxito tan pronto adquiera el bloqueo. Un método deq() puede fallar
si la fila está vacía. De igual forma que en implementaciones previas de las filas, un ítem se pone
en fila cuando el llamado de enq() fija el campo “next” del último nodo al nuevo nodo, incluso
antes de que enq() reajuste “tail” para que se refiera al nuevo nodo. La cabeza y cola de la fila no
necesariamente son los ítems referenciados por “head” and “tail”, sino que son los sucesores del
nodo referenciado por “head” y el último ítem alcanzable desde la cabeza; respectivamente.

Una fila libre de bloqueos no encerrada: La implementación de este tipo de filas evita que los
llamados de métodos estén famélicos al tener hilos más rápidos que ayudan a los hilos más lentos.
La fila consiste de dos campos AtomicReference<Node>, donde “head” se refiere al primer nodo
en la fila, y “tail” al último nodo de la fila. El primer nodo de la fila es un nodo centinela de valor
irrelevante. El método de enq() puede ser “flojo”, requiriendo dos pasos que no se ejecutan
automáticamente. Todos los demás llamados de métodos deben estar listos para encontrar un
llamado a medias de enq() para completar la tarea.

Viendo los pasos a detalle (figura 10.11), primero un enqueuer crea un nuevo nodo con el nuevo
valor que va a ser puesto en fila. Lee “tail”, y encuentra el último nodo. Para verificar que
efectivamente es el último nodo, revisa si tiene sucesor. Si es así, el hil intenta adjuntar el nuevo
nodo llamando compareAndSet(), y si tiene éxito el hilo repite este llamado para avanzar “tail” al
nuevo nodo. Incluso si falla este segundo llamado de compareAndSet(), el hilo puede regresar
porque el llamado solamente falla si otro hilo “ayudó” al avanzar “tail”. Si la cola tiene un sucesor,
entonces el método intenta ayudar a otros hilos avanzando “tail” para que se refiera directamente
a su sucesor. Este método de enq() es total y no espera un dequeuer. Un método enq() exitoso se
linealiza cuando el hilo ejecutante (de un hilo de apoyo concurrente) llama compareAndSet() para
redirigir el campo “tail” al nuevo nodo. El método deq() es similar a su contraparte total de la fila
no encerrada, excepto por un problema sutil en el caso libre de bloqueos. Antes de avanzar
“head”, se debe asegurar que “tail” no esté refiriéndose al nodo centinela que está a punto de ser
removido de la fila. Esto se resuelve agregando una prueba: Si head=tail, y el nodo centinela al que
se refieren tiene un campo “next” que no sea nulo, entonces “tail” está atrasándose. Entonces,
deq() intenta hacer que “tail” sea consistente moviéndolo al sucesor del nodo centinela y actualiza
“head” para remover al centinela Si este método arroja un valor, entonces su linealización ocurre
cuando completa un llamado compareAndSet(). El no tener bloqueos mejora sustancialmente el
desempeño de la implementación de filas, y los algoritmos libres de bloqueos tienden a ser
mejores que aquellos que sí tienen bloqueos (incluso los mejores de su tipo).

Recuperación de memoria y el problema ABA: Las implementaciones previas dependen del


recolector de basura de Java para reciclar nodos una vez que han salido de la lista. Suele ser más
eficiente que una clase haga su propio manejo de memoria, sobre todo si crea y libera muchos
objetos pequeños. Una manera más natural de reciclar nodos en una forma libre de bloqueos es
que cada hilo mantenga su “free list” privado de entradas de fila no utilizadas. Cuando un hilo para
meter cosas en fila necesita un nuevo nodo. Si la lista libre está vacía, simplemente aloca un nodo
usando el operador “new”. Cuando un hilo para sacar cosas de la fila está listo para retirar un
nodo, lo conecta de vuelta la lista del hilo local, y por ende no hay necesidad de sincronización
costosa. Esto funciona siempre que cada hilo realice prácticamente el mismo número de procesos
de meter y sacar de fila. No obstante, la fila libre de bloqueos no funciona si los nodos están
reciclados de una forma sencilla. Esto ocurre en el problema “ABA” (se presenta frecuentemente
en algoritmos de memoria dinámica que usan operaciones de sincronización condicional como
compareAndSet()). Típicamente, una referencia que va a ser modificada por compareAndSet()
cambia de A→B y luego de vuelta a A. Por ende, el llamado de compareAndSet() es exitoso a pesar
de que su efecto en la estructura de datos ha cambiado y no tiene el efecto deseado
originalmente. Una forma sencilla de resolver este problema es etiquetar cada referencia atómica
con una “estampa” única. Un objeto AtomicStampedReference<T> encapsula una referencia a un
objeto de tipo T y una “estampa” entera. El problema ABA puede ocurrir en varios escenarios de
sincronización, no solamente en aquellos que involucran sincronización condicional.

Una fila sincrónica naïve: Uno o más hilos productores producen ítems que van a ser removidos,
en orden FIFO por uno o más hilos consumidores. Los productores y consumidores se reúnen
entre ellos: Un productor que pone un ítem en la fila bloquea hasta que ese ítem sea removido por
un consumidor, y viceversa. Esta sincronización de reunión está presente en lenguajes como CSP y
Ada. “Ítem” es el primer ítem que espera a salir de la fila, “enqueuing” es un valor booleano para
que los enqueuers se sincronicen entre ellos, “lock” es el bloqueo usado para la exclusión mutua y
“condition” es usado para bloquear métodos parciales. El método deq() meramente espera a que
el ítem sea no nulo, graba el ítem, fija el campo de ítem a nulo, y notifica a otros hilos en espera
antes de regresar el ítem. Aunque el diseño de la fila es simple, requiere un alto costo de
sincronización.

Estructuras de datos duales: Para reducir los gastos generales de sincronización de la fila
sincrónica, se considera una implementación de fila sincrónica alternativa que separa enq(), y
deq() en dos pasos. Primero pone un objeto de reserva en la fila, indicando que el dequeuer está
esperando a un enqueuer con el cual reunirse. Luego el enqueuer descubre la reserva, la cumple
depositando un ítem en ella y notificando al dequeuer al fijar la bandera de reserva. Similarmente,
un enqueuer puede esperar por una pareja de reunión creando su propia reserva y girando en su
bandera de reserva. Esta estructura se conoce como estructura de datos dual, ya que los métodos
ocurren en dos pasos: la reserva y el cumplimiento. Los hilos en espera pueden girar en una
bandera en caché local, lo cual es esencial para escalabilidad. Asegura la equidad de forma natural,
y esta estructura de datos es linealizable, ya que cada llamado de método parcial puede ser
ordenado cuando es cumplido. La fila es implementada como una lista de nodos, donde un nodo
representa un ítem esperando a salir de la fila, o una reserva esperando a ser cumplida
(dependiendo del campo “type”). En cualquier momento, todos los nodos de la fila tienen el
mismo tipo, consistiendo en ítems esperando a ser sacados de la fila o reservas esperando a ser
cumplidas. Cuando un ítem entra en la fila, el campo “ítem” del nodo tiene al ítem, lo cual regresa
a nulo cuando el ítem sale de la fila. Cuando una reserva está en la fila, el campo “ítem” del nodo
es nulo y se reinicia cuando la reserva es cumplida.

Notas del capítulo: La fila parcial emplea una mezcla de técnicas adaptadas de Doug Lea [99] y de
un algoritmo de Maged Michael y Michael Scott. La fila sin bloqueo es una versión ligeramente
simplificada de un algoritmo de cola de Maged Michael y Michael Scott

También podría gustarte