Capítulo 4. Visión general de los sistemas concurrentes

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Los sistemas distribuidos comprenden múltiples piezas independientes de código que se ejecutan en paralelo, o concurrentemente, en muchos nodos de procesamiento repartidos por múltiples ubicaciones. Cualquier sistema distribuido es, por definición, un sistema concurrente, aunque cada nodo procese eventos de uno en uno. Por supuesto, el comportamiento de los distintos nodos debe coordinarse para que la aplicación se comporte como se desea.

Como describí en el Capítulo 3, coordinar nodos en un sistema distribuido está plagado de peligros. Por suerte, nuestra industria ha madurado lo suficiente como para proporcionar marcos de software complejos y potentes que ocultan muchos de estos peligros de los sistemas distribuidos a nuestras aplicaciones (la mayor parte del tiempo, al menos). La mayor parte de este libro se centra en describir cómo podemos utilizar estos marcos para construir sistemas distribuidos escalables.

Este capítulo, sin embargo, se ocupa del comportamiento concurrente de nuestros sistemas en un único nodo. Escribiendo explícitamente nuestro software para que realice múltiples acciones de forma concurrente, podemos optimizar el procesamiento y la utilización de recursos en un único nodo y, por tanto, aumentar nuestra capacidad de procesamiento tanto localmente como en todo el sistema.

Utilizaré como ejemplo las capacidades de concurrencia de Java 7.0 , ya que se encuentran en un nivel de abstracción inferior a las introducidas en Java 8.0. Saber cómo funcionan los sistemas concurrentes "más cerca de la máquina" es un conocimiento básico esencial a la hora de construir sistemas concurrentes y distribuidos. Una vez que comprendas los mecanismos de nivel inferior para construir sistemas concurrentes, será más fácil explotar de forma óptima los enfoques más abstractos. Y aunque este capítulo es específico de Java, los problemas fundamentales de los sistemas concurrentes no cambian cuando escribes sistemas en otros lenguajes. Existen mecanismos para gestionar la concurrencia en todos los lenguajes de programación convencionales. "Modelos de concurrencia" ofrece más detalles sobre enfoques alternativos y cómo se implementan en los lenguajes modernos.

Un último punto. Este capítulo es un manual de concurrencia. No te enseñará todo lo que necesitas saber para construir sistemas concurrentes complejos y de alto rendimiento. También te será útil si tu experiencia escribiendo programas concurrentes está oxidada, o si has estado expuesto al código concurrente en otro lenguaje de programación. La sección de lecturas adicionales al final del capítulo señala una cobertura más completa de este tema para quienes deseen profundizar en él.

¿Por qué la concurrencia?

Piensa en una cafetería muy concurrida. Si todo el mundo pide un café sencillo, el camarero puede entregar rápida y sistemáticamente cada bebida. De repente, la persona que tienes delante pide una infusión helada cuádruple de soja, vainilla y sin azúcar. Todos los de la cola suspiran y empiezan a leer sus redes sociales. En dos minutos la cola está fuera de la puerta.

El procesamiento de peticiones en las aplicaciones web es análogo a nuestro ejemplo del café. En una cafetería, solicitamos la ayuda de un nuevo barista para que prepare cafés simultáneamente en una máquina diferente, a fin de mantener controlada la longitud de la cola y servir rápidamente a los clientes. En software, para que las aplicaciones respondan, necesitamos procesar de algún modo las peticiones en nuestro servidor de forma solapada, gestionando las peticiones de forma concurrente.

En los buenos tiempos de la informática, cada CPU sólo podía ejecutar una única instrucción de máquina en cada instante. Si nuestra aplicación de servidor se ejecuta en una CPU de este tipo, ¿por qué necesitamos estructurar nuestros sistemas de software para ejecutar potencialmente múltiples instrucciones de forma concurrente? Todo parece un poco inútil.

En realidad, hay una muy buena razón. Prácticamente todos los programas hacen algo más que ejecutar instrucciones de la máquina. Por ejemplo, cuando un programa intenta leer de un archivo o enviar un mensaje por la red, debe interactuar con el subsistema de hardware (disco, tarjeta de red) que es periférico a la CPU. La lectura de datos de un disco duro magnético tarda unos 10 milisegundos (ms). Durante este tiempo, el programa debe esperar a que los datos estén disponibles para su procesamiento.

Ahora, incluso una CPU antigua como una Intel 80386 de 1988 puede ejecutar más de 10 millones de instrucciones por segundo (mips). 10 ms es una centésima de segundo. ¿Cuántas instrucciones podría ejecutar nuestro 80386 en una centésima de segundo? Haz las cuentas. (¡Insinúa que es mucho!) De hecho, mucha capacidad de procesamiento desperdiciada.

Así es como los sistemas operativos como Linux pueden ejecutar varios programas en una sola CPU. Mientras un programa espera un evento de E/S, el sistema operativo programa otro programa para que se ejecute. Estructurando explícitamente nuestro software para que tenga múltiples actividades que puedan ejecutarse en paralelo, el sistema operativo puede programar tareas que tengan trabajo que hacer mientras otras esperan la E/S. Veremos con más detalle cómo funciona esto con Java más adelante en este capítulo.

En 2001, IBM presentó el primer procesador multinúcleo del mundo, un chip con dos CPUs: véase la Figura 4-1 para una ilustración simplificada. Hoy en día, incluso mi portátil tiene 16 CPUs, o "núcleos", como se les conoce comúnmente. Con un chip multinúcleo, un sistema de software estructurado para tener múltiples actividades paralelas puede ejecutarse simultáneamente en cada núcleo, hasta el número de núcleos disponibles. De este modo, podemos utilizar plenamente los recursos de procesamiento de un chip multinúcleo, y aumentar así la capacidad de procesamiento de nuestra aplicación.

Simplified view of a multicore processor
Figura 4-1. Vista simplificada de un procesador multinúcleo

La forma principal de estructurar un sistema de software como actividades concurrentes es utilizar hilos. Prácticamente todos los lenguajes de programación de tienen su propio mecanismo de hilos. La semántica subyacente de todos estos mecanismos es similar -sólo hay unos pocos modelos principales de hilos de uso generalizado-, pero obviamente la sintaxis varía según el lenguaje. En las siguientes secciones, explicaré cómo se soportan los hilos en Java, y cómo debemos diseñar nuestros programas para que sean seguros (es decir, correctos) y eficientes cuando se ejecutan en paralelo. Armados con este conocimiento, saltar a las características de concurrencia soportadas en otros lenguajes no debería ser demasiado arduo.

Hilos

Todo proceso software tiene por defecto un único hilo de ejecución . Éste es el hilo que gestiona el sistema operativo cuando programa el proceso para su ejecución. En Java, por ejemplo, la función main() que especifiques como punto de entrada a tu código define el comportamiento de este hilo. Este único hilo tiene acceso al entorno del programa y a recursos como manejadores de archivos abiertos y conexiones de red. A medida que el programa llama a los métodos de los objetos instanciados en el código, la pila de tiempo de ejecución del programa se utiliza para pasar parámetros y gestionar los ámbitos de las variables. Cosas estándar del tiempo de ejecución del lenguaje de programación, que todos conocemos y amamos. Se trata de un proceso secuencial.

En tus sistemas, puedes utilizar funciones del lenguaje de programación para crear y ejecutar hilos adicionales. Cada hilo es una secuencia de ejecución independiente y tiene su propia pila de ejecución para gestionar la creación de objetos locales y las llamadas a métodos. Cada hilo también tiene acceso a los datos globales y al entorno del proceso. En la Figura 4-2 se muestra una representación sencilla de este esquema.

Comparing a single threaded and multithreaded process
Figura 4-2. Comparación de un proceso monohilo y multihilo

En Java, podemos definir un hilo mediante una clase que implemente la interfaz Runnable y defina el método run(). Veamos un ejemplo sencillo:

class NamingThread implements Runnable {

private String name;
       
public NamingThread(String threadName) {
      name = threadName ;
           System.out.println("Constructor called: " + threadName) ;
       }
        
       public void run() {
      //Display info about this  thread
           System.out.println("Run called : " + name);
           System.out.println(name + " : " + Thread.currentThread());
           // and now terminate  ....
    }
}

Para ejecutar el hilo, necesitamos construir un objeto Thread utilizando una instancia de nuestro Runnable y llamar al método start() para invocar el código en su propio contexto de ejecución. Esto se muestra en el siguiente ejemplo de código, junto con el resultado de ejecutar el código en negrita. Observa que este ejemplo tiene dos hilos: el hilo main() y el hilo NamingThread. El hilo principal inicia el NamingThread, que se ejecuta de forma asíncrona, y luego espera 1 segundo para dar a nuestro método run() en NamingThread tiempo suficiente para completarse:

public static void main(String[] args) {
      
  NamingThread name0 = new NamingThread("My first thread");
    
  //Create the thread
  Thread t0 = new Thread (name0);
    
  // start the threads
  t0.start();
      
  //delay the main thread for a second (1000 milliseconds)
  try {
    Thread.currentThread().sleep(1000);
  } catch (InterruptedException e) {}

      //Display info about the main thread and terminate
      System.out.println(Thread.currentThread());
    
}

===EXECUTION OUTPUT===
Constructor called: My first thread
Run called : My first thread
My first thread : Thread[Thread-0,5,main]
Thread[main,5,main]

Para ilustrarlo, también llamamos a al método estático currentThread(), que devuelve una cadena que contiene:

  • El identificador del hilo generado por el sistema.

  • La prioridad del hilo, que por defecto es 5 para todos los hilos. Más adelante veremos las prioridades de los hilos.

  • El identificador del subproceso padre: en este ejemplo, ambos subprocesos padres son main thread.

Ten en cuenta que para instanciar un hilo, llamamos al método start(), no al método run() que definimos en Runnable. El método start() contiene la magia interna del sistema para crear el contexto de ejecución para que se ejecute un hilo independiente. Si llamamos directamente a run(), el código se ejecutará, pero no se creará un nuevo hilo. El método run() se ejecutará como parte del hilo main, como cualquier otra invocación de método Java que conoces y amas. Seguirás teniendo un código monohilo.

En el ejemplo, utilizamos sleep() para pausar la ejecución del hilo main y asegurarnos de que no termina antes que el NamimgThread. Este enfoque, es decir, coordinar dos hilos retrasándolos durante un periodo de tiempo absoluto (1 segundo en el ejemplo) no es un mecanismo muy robusto. ¿Qué ocurre si por alguna razón -una CPU más lenta, un gran retraso en la lectura del disco, una lógica compleja adicional en el método- nuestro hilo no termina en el plazo previsto? En este caso, main terminará primero, lo cual no es lo que pretendemos. En general, si utilizas tiempos absolutos para la coordinación de hilos, lo estás haciendo mal. Casi siempre. Como el 99,99999% de las veces.

Un mecanismo sencillo y robusto para que un hilo espere hasta que otro haya terminado su trabajo es utilizar el método join(). Podríamos sustituir el bloque try-catch del ejemplo anterior por:

t0.join();

Este método hace que la hebra llamante (en este caso, main) se bloquee hasta que la hebra referenciada por t0 termine. Si la hebra referenciada ha terminado antes de la llamada a join(), entonces la llamada al método retorna inmediatamente. De este modo podemos coordinar, o sincronizar, el comportamiento de varios subprocesos. La sincronización de varios subprocesos es, de hecho, el tema principal del resto de este capítulo.

Orden de ejecución de los hilos

El programador del sistema (en Java, éste vive en la máquina virtual Java [JVM]) controla el orden de ejecución de los hilos. Desde la perspectiva del programador, el orden de ejecución es no determinista. Acostúmbrate a este término, lo utilizaré a menudo. El concepto de no determinismo es fundamental para entender el código multihilo.

Lo ilustraré basándome en el ejemplo anterior de NamingThread. En lugar de crear un único NamingThread, crearé y pondré en marcha unos cuantos. Tres, de hecho, como se muestra en el siguiente ejemplo de código. De nuevo, la salida de muestra de la ejecución del código está en negrita debajo del propio código:

      NamingThread name0 = new NamingThread("thread0");
      NamingThread name1 = new NamingThread("thread1");
      NamingThread name2 = new NamingThread("thread2");
    
      //Create the threads
      Thread t0 = new Thread (name0);
      Thread t1 = new Thread (name1);
      Thread t2 = new Thread (name2);    
      
      // start the threads
      t0.start();  1
      t1.start();  1
      t2.start();  1

===EXECUTION OUTPUT===
Run called : thread0
thread0 : Thread[Thread-0,5,main]  2
Run called : thread2  3
Run called : thread1
thread1 : Thread[Thread-1,5,main]  4
thread2 : Thread[Thread-2,5,main]
Thread[main,5,main]

La salida mostrada es una muestra de una sola ejecución. Puedes ver que el código inicia tres subprocesos secuencialmente, a saber, t0, t1 y t2 (ver 1). Observando la salida, vemos que el hilo t0 se completa (ver 2) antes de que se inicien los demás. A continuación se llama al método run() de t2(ver 3) seguido del método run() de t1, aunque t1 se inició antes que t2. El hilo t1 se ejecuta hasta completarse (ver 4) antes que t2, y finalmente el hilo principal y el programa terminan.

Éste es sólo un posible orden de ejecución. Si volvemos a ejecutar este programa, es casi seguro que veremos una traza de ejecución diferente. Esto se debe a que el programador de la JVM está decidiendo qué subproceso ejecutar y durante cuánto tiempo. En pocas palabras, una vez que el programador ha asignado a un subproceso un espacio de tiempo de ejecución en una CPU, puede interrumpir el subproceso después de un período de tiempo especificado y programar otro para que se ejecute. Esta interrupción se conoce como preferencia. La preferencia garantiza que cada subproceso tenga la oportunidad de progresar. Por lo tanto, los subprocesos se ejecutan de forma independiente y asíncrona hasta su finalización, y el programador decide qué subproceso se ejecuta cuándo basándose en un algoritmo de programación.

La programación de hilos es mucho más que esto, y explicaré el algoritmo básico de programación utilizado más adelante en este capítulo. Por ahora, hay una implicación importante para los programadores; independientemente del orden de ejecución de los hilos -que tú no controlas-, tu código debe producir resultados correctos . ¿Te parece fácil? Sigue leyendo.

Problemas con los hilos

El problema básico de la programación concurrente es coordinar la ejecución de múltiples hilos de forma que, sea cual sea el orden en que se ejecuten, produzcan la respuesta correcta. Dado que los hilos pueden iniciarse y adelantarse de forma no determinista, cualquier programa medianamente complejo tendrá esencialmente un número infinito de posibles órdenes de ejecución. Estos sistemas no son fáciles de probar.

Hay dos problemas fundamentales que todos los programas concurrentes deben evitar. Son las condiciones de carrera y los bloqueos, temas que se tratan en las dos subsecciones siguientes.

Condiciones de la carrera

La ejecución no determinista de hilos implica que las sentencias de código que componen los hilos:

  • Se ejecutará secuencialmente según se defina dentro de cada hilo.

  • Pueden solaparse en cualquier orden entre hilos. Esto se debe a que el número de sentencias que se ejecutan por cada ranura de ejecución de los hilos lo determina el programador.

Por lo tanto, cuando se ejecutan muchos hilos en un único procesador, su ejecución se intercala. La CPU ejecuta algunos pasos de un hilo, luego realiza algunos pasos de otro, y así sucesivamente. Si estamos ejecutando en una CPU multinúcleo, entonces podemos ejecutar un hilo por núcleo. Sin embargo, las sentencias de la ejecución de cada hilo siguen intercalándose de forma no determinista.

Ahora bien, si cada hilo simplemente hace lo suyo y es completamente independiente, esto no es un problema. Cada hilo se ejecuta hasta que termina, como en nuestro ejemplo trivial de Naming​Thread. ¡Esto es pan comido! ¿Por qué se supone que estas cosas de los hilos son complejas?

Por desgracia, los hilos totalmente independientes no son como se comportan la mayoría de los sistemas multihilo. Si vuelves a la Figura 4-2, verás que varios hilos comparten los datos globales dentro de un proceso. En Java se trata tanto de datos globales como estáticos.

Los hilos pueden utilizar estructuras de datos compartidas para coordinar su trabajo y comunicar el estado entre hilos. Por ejemplo, podemos tener subprocesos gestionando solicitudes de clientes web, un subproceso por solicitud. También queremos mantener un total de cuántas solicitudes procesamos cada día. Cuando un subproceso completa una solicitud, incrementa un objeto global RequestCounter que todos los subprocesos comparten y actualizan después de cada solicitud. Al final del día, sabemos cuántas solicitudes se han procesado. Una solución sencilla y elegante. Bueno, ¿quizá?

El código siguiente muestra una implementación muy sencilla que imita el escenario del ejemplo del contador de peticiones. Crea 50.000 hilos para actualizar un contador compartido. Ten en cuenta que utilizamos una función lambda por brevedad para crear los hilos, y un retraso (realmente mala idea) de 5 segundos en main para permitir que los hilos terminen:1

public class RequestCounter {
  final static private int NUMTHREADS = 50000;
  private int count = 0;
    
  public  void inc() {
    count++;
  }
    
  public int getVal() {
    return this.count;
  }
    
  public static void main(String[] args) throws InterruptedException {
    final RequestCounter counter = new RequestCounter();
          
    for (int i = 0; i < NUMTHREADS; i++) {
      // lambda runnable creation 
      Runnable thread = () -> {counter.inc(); };
        new Thread(thread).start();
    }
          
    Thread.sleep(5000);
    System.out.println("Value should be " + NUMTHREADS + "It is: " +     counter.getVal());
  }
}

Lo que puedes hacer en casa es clonar este código del repositorio GitHub del libro, ejecutarlo unas cuantas veces y ver qué resultados obtienes. En 10 ejecuciones mi media fue de 49.995. Ni una sola vez obtuve la respuesta correcta de 50.000. Qué raro.

¿Por qué?

La respuesta está en cómo se ejecutan en una máquina las sentencias abstractas de alto nivel del lenguaje de programación, en Java en este caso. En este ejemplo, para realizar un incremento de un contador , la CPU debe:

  • Carga el valor actual en un registro.

  • Incrementa el valor del registro.

  • Vuelve a escribir los resultados en la posición de memoria original.

Este simple incremento es en realidad una secuencia de tres operaciones a nivel de máquina.

Como muestra la Figura 4-3, a nivel de máquina estas tres operaciones son independientes y no se tratan como una única operación atómica. Por atómica entendemos una operación que no puede interrumpirse y que, por tanto, una vez iniciada, se ejecutará hasta su finalización.

Como la operación de incremento no es atómica a nivel de máquina, un subproceso puede cargar el valor del contador en un registro de la CPU desde la memoria, pero antes de volver a escribir el valor incrementado, el programador se adelanta al subproceso y permite que comience otro subproceso. Este hilo carga el valor antiguo del contador desde la memoria y vuelve a escribir el valor incrementado. Finalmente, la hebra original se ejecuta de nuevo y vuelve a escribir su valor incrementado, que resulta ser el mismo que el que ya está en la memoria.

Esto significa que hemos perdido una actualización. De nuestras 10 pruebas del código del contador anterior, vemos que esto ocurre una media de 5 veces en 50.000 incrementos. Por tanto, estos sucesos son raros, pero aunque ocurra 1 vez cada 10 millones, seguirás teniendo un resultado incorrecto.

Increments are not atomic at the machine level
Figura 4-3. Los incrementos no son atómicos a nivel de máquina

Cuando perdemos actualizaciones de esta forma, se denomina condición de carrera. Las condiciones de carrera pueden producirse siempre que varios hilos realicen cambios en algún estado compartido, en este caso un simple contador. Esencialmente, diferentes intercalaciones de los hilos pueden producir resultados diferentes.

Las condiciones de carrera son errores insidiosos y malignos, porque su aparición suele ser rara, y pueden ser difíciles de detectar, ya que la mayoría de las veces la respuesta será correcta. Intenta ejecutar el ejemplo de código del contador multihilo con 1.000 hilos en lugar de 50.000, y verás esto en acción. Obtuve la respuesta correcta nueve de cada diez veces.

Así que esta situación puede resumirse como "mismo código, resultados ocasionalmente diferentes". Como ya he dicho, ¡las condiciones de carrera son malvadas! Por suerte, erradicarlas es sencillo si tomas algunas precauciones.

La clave está en identificar y proteger las secciones críticas. Una sección crítica es una sección de código que actualiza estructuras de datos compartidas y, por tanto, debe ejecutarse atómicamente si es accedida por varios hilos. El ejemplo de incrementar un contador compartido es un ejemplo de sección crítica. Otro es eliminar un elemento de una lista. Necesitamos eliminar el nodo cabeza de la lista y mover la referencia a la cabeza de la lista desde el nodo eliminado al siguiente nodo de la lista. Ambas operaciones deben realizarse atómicamente para mantener la integridad de la lista. Esta es una sección crítica.

En Java, la palabra clave synchronized define una sección crítica. Si se utiliza para decorar un método, cuando varios subprocesos intentan llamar a ese método en el mismo objeto compartido, sólo a uno se le permite entrar en la sección crítica. Todos los demás se bloquean hasta que el hilo sale del método sincronizado, momento en el que el programador elige el siguiente hilo para ejecutar la sección crítica. Decimos que la ejecución de la sección crítica es serializada, ya que sólo un hilo a la vez puede estar ejecutando el código dentro de ella.

Por tanto, para solucionar el contraejemplo, basta con identificar el método inc() como una sección crítica y convertirlo en un método sincronizado, es decir

synchronized public void inc() {
    count++;
  }

Pruébalo tantas veces como quieras. Siempre obtendrás la respuesta correcta. Un poco más formalmente, esto significa que cualquier intercalación de los hilos que nos proponga el programador siempre producirá los resultados correctos.

La palabra clave synchronized también puede aplicarse a bloques de sentencias dentro de un método. Por ejemplo, podríamos reescribir el ejemplo anterior como

public void inc() {
        synchronized(this){
           count++;   
        }
}

Bajo cuerda, todo objeto Java tiene un bloqueo de monitor, a veces conocido como bloqueo intrínseco, como parte de su representación en tiempo de ejecución. El monitor es como el cuarto de baño de un autobús de larga distancia: sólo una persona puede (¡y debe!) entrar a la vez, y el bloqueo de la puerta impide que otros entren cuando está en uso.

En nuestro entorno de ejecución Java totalmente sanitario, un hilo debe adquirir el bloqueo de monitoreo para entrar en un método sincronizado o en un bloque sincronizado de sentencias. Sólo un hilo puede poseer el bloqueo en cualquier momento y, por tanto, la ejecución se serializa. Así es, básicamente, como Java y lenguajes similares implementan las secciones críticas.

Como regla general, debes mantener las secciones críticas lo más pequeñas posible, de modo que se minimice el código serializado. Esto puede repercutir positivamente en el rendimiento y, por tanto, en la escalabilidad. Volveré sobre este tema más adelante, pero en realidad estoy hablando de nuevo de la ley de Amdahl, tal y como se introdujo en el Capítulo 2. Los bloques sincronizados son las partes serializadas de un sistema, tal y como las describe Amdahl, y cuanto más tiempo se ejecuten, entonces menor será el potencial de escalabilidad del sistema.

Bloqueos

Para garantizar resultados correctos en código multihilo, expliqué que tenemos que restringir el no determinismo inherente para serializar el acceso a las secciones críticas. Esto evita las condiciones de carrera. Sin embargo, si no tenemos cuidado, podemos escribir código que restrinja tanto el no determinismo que nuestro programa se detenga. Y nunca continúa. Esto se conoce formalmente como punto muerto.

Un bloqueo se produce cuando dos o más subprocesos se bloquean para siempre y ninguno puede continuar. Esto ocurre cuando los subprocesos necesitan acceso exclusivo a un conjunto compartido de recursos y los subprocesos adquieren bloqueos en órdenes diferentes. Esto se ilustra en el siguiente ejemplo, en el que dos subprocesos necesitan acceso exclusivo a las secciones críticas A y B. El subproceso 1 adquiere el bloqueo de la sección crítica A, y el subproceso 2 adquiere el bloqueo de la sección crítica B. Ambos se bloquean para siempre, ya que no pueden adquirir los bloqueos que necesitan para continuar.

Dos hilos comparten el acceso a dos variables compartidas mediante bloques sincronizados:

  • Hilo 1: entra en la sección crítica A.

  • Hilo 2: entra en la sección crítica B.

  • Hilo 1: se bloquea al entrar en la sección crítica B.

  • Hilo 2: se bloquea al entrar en la sección crítica A.

  • Ambos hilos esperan eternamente.

Un bloqueo, también conocido como abrazo mortal , hace que un programa se detenga. No hace falta tener mucha imaginación para darse cuenta de que esto puede provocar todo tipo de resultados indeseables. Estoy escribiendo alegremente mientras mi vehículo autónomo me lleva al bar. De repente, el código del vehículo se bloquea. No acabará bien.

Los bloqueos se producen en circunstancias más sutiles que el simple ejemplo anterior. El ejemplo clásico es el problema de los filósofos comensales. La historia es la siguiente

Cinco filósofos se sientan alrededor de una mesa compartida en . Como son filósofos, pasan mucho tiempo pensando profundamente. Entre un pensamiento profundo y otro, reponen sus funciones cerebrales comiendo de un plato de comida que tienen delante. Por lo tanto, un filósofo o está comiendo o está pensando o está pasando de un estado a otro.

Además, los filósofos deben ser todos físicamente muy amigos, muy diestros y vacunados contra la COVID-19, ya que comparten los palillos para comer. Sólo hay cinco palillos en la mesa, colocados entre cada filósofo. Cuando un filósofo desea comer, sigue el protocolo de coger primero su palillo izquierdo y luego el derecho. Cuando están listos para volver a pensar, primero devuelven el palillo derecho y luego el izquierdo.

La Figura 4-4 muestra a nuestros filósofos, cada uno identificado por un número único. Como cada uno está comiendo o pensando simultáneamente, podemos modelar a cada filósofo como un hilo.

The dining philosophers problem
Figura 4-4. El problema de los filósofos comensales

El código se muestra en el Ejemplo 4-1. Los palillos compartidos están representados por instancias de la clase Java Object. Como sólo un objeto puede mantener el bloqueo de monitoreo sobre un objeto en cualquier momento, se utilizan como condiciones de entrada a las secciones críticas en las que los filósofos adquieren los palillos que necesitan para comer. Después de comer, los palillos se devuelven a la mesa y se libera el bloqueo sobre cada uno de ellos para que los filósofos vecinos puedan comer cuando estén listos.

Ejemplo 4-1. El hilo filosofal
public class Philosopher implements Runnable {

  private final Object leftChopStick;
  private final Object rightChopStick;

  Philosopher(Object leftChopStick, Object rightChopStick) {
    this.leftChopStick = leftChopStick;
    this.rightChopStick = rightChopStick;
  }
  private void LogEvent(String event) throws InterruptedException {
    System.out.println(Thread.currentThread()
                                  .getName() + " " + event);
    Thread.sleep(1000);
  }

  public void run() {
    try {
      while (true) {
        LogEvent(": Thinking deeply"); 
        synchronized (leftChopStick) {
          LogEvent( ": Picked up left chopstick");
          synchronized (rightChopStick) {
            LogEvent(": Picked up right chopstick – eating");
            LogEvent(": Put down right chopstick");
          }
          LogEvent(": Put down left chopstick. Ate too much");
        }
      } // end while
    } catch (InterruptedException e) {
       Thread.currentThread().interrupt();
  }
 }
}

Para dar vida a los filósofos descritos en el Ejemplo 4-1, debemos instanciar un hilo para cada uno y dar a cada filósofo acceso a sus palillos vecinos. Esto se hace mediante la llamada al constructor de hilos en 1 en el Ejemplo 4-2. En el bucle for creamos cinco filósofos y los iniciamos como hilos independientes, en los que cada palillo es accesible a dos hilos, uno como palillo izquierdo y otro como derecho.

Ejemplo 4-2. Versión filósofos-muertos de comedor
private final static int NUMCHOPSTICKS = 5 ;
private final static int NUMPHILOSOPHERS = 5; 
public static void main(String[] args) throws Exception {
 
  final Philosopher[] ph = new Philosopher[NUMPHILOSOPHERS];
  Object[] chopSticks = new Object[NUMCHOPSTICKS];
 
  for (int i = 0; i < NUMCHOPSTICKS; i++) {
    chopSticks[i] = new Object();
  }
 
  for (int i = 0; i < NUMPHILOSOPHERS; i++) {
    Object leftChopStick = chopSticks[i];
    Object rightChopStick = chopSticks[(i + 1) % chopSticks.length];
    
    ph[i] = new Philosopher(leftChopStick, rightChopStick);  1
          
    Thread th = new Thread(ph[i], "Philosopher " + i);
    th.start();
  }
}

La ejecución de este código produce el siguiente resultado en mi primer intento. Si ejecutas el código es casi seguro que verás salidas diferentes, pero el resultado final será el mismo:

Philosopher 3 : Thinking deeply
Philosopher 4 : Thinking deeply
Philosopher 0 : Thinking deeply
Philosopher 1 : Thinking deeply
Philosopher 2 : Thinking deeply
Philosopher 3 : Picked up left chopstick
Philosopher 0 : Picked up left chopstick
Philosopher 2 : Picked up left chopstick
Philosopher 4 : Picked up left chopstick
Philosopher 1 : Picked up left chopstick

Diez líneas de salida, y luego... ¡nada! Tenemos un punto muerto. Se trata de un clásico punto muerto de espera circular. Imagina el siguiente escenario:

  • Cada filósofo se entrega a una larga sesión de reflexión.

  • Simultáneamente, todos deciden que tienen hambre y cogen el palillo izquierdo.

  • Ningún filósofo puede comer (proceder) como ninguno puede coger su palillo derecho.

Los verdaderos filósofos en esta situación encontrarían alguna forma de proceder dejando uno o dos palillos hasta que uno o más de sus colegas pudieran comer. A veces podemos hacer esto en nuestro software utilizando tiempos de espera en las operaciones bloqueantes. Cuando expira el tiempo de espera, un subproceso libera la sección crítica y vuelve a intentarlo, dando a otros subprocesos bloqueados la oportunidad de continuar. Sin embargo, esto no es lo óptimo, ya que los hilos bloqueados perjudican el rendimiento y establecer valores de tiempo de espera es una ciencia inexacta.

Por lo tanto, es mucho mejor diseñar una solución sin bloqueos. Esto significa que uno o más hilos siempre podrán avanzar. Con los bloqueos por espera circular, esto puede conseguirse imponiendo un protocolo de asignación de recursos a los recursos compartidos, de modo que los hilos no soliciten siempre los recursos en el mismo orden.

En el problema de los filósofos comensales, podemos hacerlo asegurándonos de que uno de nuestros filósofos coge primero su palillo derecho. Supongamos que ordenamos al Filósofo 4 que lo haga. Esto nos lleva a una posible secuencia de operaciones como la siguiente

  • El filósofo 0 coge el palillo izquierdo (chopStick[0] ) y luego el derecho (chopStick[1] )

  • Filósofo 1 coge el palillo izquierdo (chopStick[1] ) y luego el derecho (chopStick[2] )

  • El Filósofo 2 coge el palillo izquierdo (chopStick[2] ) y luego el derecho (chopStick[3] )

  • El Filósofo 3 coge el palillo izquierdo (chopStick[3] ) y luego el derecho (chopStick[4] )

  • El Filósofo 4 coge el palillo derecho (chopStick[0] ) y luego el izquierdo (chopStick[4] )

En este ejemplo, el Filósofo 4 debe bloquear, ya que el Filósofo 0 ya ha adquirido el acceso a chopstick[0]. Con el Filósofo 4 bloqueado, el Filósofo 3 tiene asegurado el acceso a chopstick[4] y puede proceder a satisfacer su apetito.

El arreglo para la solución de los filósofos comedores se muestra en el Ejemplo 4-3.

Ejemplo 4-3. Resolver el bloqueo de los filósofos comensales
if (i == NUMPHILOSOPHERS - 1) {
  // The last philosopher picks up the right chopstick first
  ph[i] = new Philosopher(rightChopStick, leftChopStick); 
} else {
  // all others pick up the left chopstick first 
  ph[i] = new Philosopher(leftChopStick, rightChopStick);
}

Más formalmente, estamos imponiendo una ordenación en la adquisición de recursos compartidos, tal que así:

chopStick[0] < chopStick[1] < chopStick[2] < chopStick[3] < chopStick[4]

Esto significa que cada hilo siempre intentará adquirir chopstick[0] antes que chopstick[1], y chopstick[1] antes que chopstick[2], y así sucesivamente. Para el Filósofo 4, esto significa que intentará adquirir chopstick[0] antes que chopstick[4], rompiendo así la posibilidad de un punto muerto de espera circular.

Los bloqueos son un tema complicado y esta sección sólo ha arañado la superficie. Verás bloqueos en muchos sistemas distribuidos. Por ejemplo, una petición de usuario adquiere un bloqueo sobre algunos datos de una tabla de la base de datos Alumnos, y a continuación debe actualizar filas de la tabla Clases para reflejar la asistencia de los alumnos. Simultáneamente, otra petición de usuario adquiere bloqueos en la tabla Clases, y a continuación debe actualizar cierta información en la tabla Alumnos. Si estas peticiones se intercalan de tal forma que cada petición adquiere bloqueos de forma solapada, tenemos un bloqueo.

Estados del hilo

Los sistemas multihilo tienen un programador del sistema que decide qué hilos se ejecutan y cuándo. En Java, el programador se conoce como programador preferente basado en prioridades. En pocas palabras, esto significa que elige ejecutar el subproceso de mayor prioridad que desee ejecutarse.

Cada hilo tiene una prioridad (por defecto 5, rango de 0 a 10). Un hilo hereda su prioridad de su hilo padre. Los hilos de mayor prioridad se programan con más frecuencia que los de menor prioridad, pero en la mayoría de las aplicaciones basta con tener todos los hilos con la prioridad por defecto.

El programador hace pasar los hilos por cuatro estados distintos, en función de su comportamiento. Éstos son:

Creado
Se ha creado un objeto hilo pero no se ha invocado su método start(). Una vez invocado start(), el hilo entra en estado ejecutable.
Ejecutable
Un hilo puede ejecutarse. El programador elegirá qué subproceso(s) se ejecutará(n) de acuerdo con el principio FIFO (primero en entrar, primero en salir): se puede asignar un subproceso en cualquier momento a cada núcleo del nodo. Los subprocesos se ejecutan hasta que se bloquean (por ejemplo, en una sentencia synchronized ), ejecutan una sentencia yield(), suspend(), o sleep(), el método run() termina, o son adelantados por el programador. La espera se produce cuando una hebra de mayor prioridad se puede ejecutar, o cuando expira un periodo de tiempo específico del sistema, conocido como intervalo de tiempo. La prioridad basada en el tiempo permite al programador garantizar que todos los subprocesos tengan la oportunidad de ejecutarse: ningún subproceso hambriento de ejecución puede acaparar la CPU.
Bloqueado
Un hilo está bloqueado si está esperando a que se produzca un bloqueo, un evento de notificación (por ejemplo, que expire el temporizador de reposo, que se ejecute el método resume() ), o si está esperando a que se complete una solicitud de red o de disco. Cuando se produce el evento específico que un hilo bloqueado está esperando, vuelve al estado ejecutable.
Terminado
El método run() de un hilo ha completado o ha llamado al método stop(). El hilo dejará de estar programado.

La Figura 4-5 ilustra este esquema. El programador mantiene efectivamente una cola FIFO en estado ejecutable para cada prioridad de hilo. Los hilos de alta prioridad se utilizan normalmente para responder a eventos (por ejemplo, un temporizador de emergencia) y se ejecutan durante un breve periodo de tiempo. Los hilos de baja prioridad se utilizan para tareas en segundo plano, continuas, como la comprobación de la corrupción de archivos en el disco mediante el recálculo de sumas de comprobación. Los hilos de fondo utilizan básicamente ciclos de CPU ociosos.

Threads states and transitions
Figura 4-5. Estados y transiciones de los hilos

Coordinación del hilo

Hay muchos problemas que requieren que los hilos con diferentes funciones coordinen sus actividades. Imagina una colección de subprocesos que aceptan documentos de los usuarios, los procesan (por ejemplo, generan un PDF) y envían el documento procesado a un grupo de impresoras compartidas. Cada impresora sólo puede imprimir un documento a la vez, así que leen de una cola de impresión compartida, cogiendo e imprimiendo los documentos en el orden en que llegan.

Este problema de impresión es una ilustración del clásico problema productor-consumidor. Los productores generan y envían mensajes a través de un búfer FIFO compartido a los consumidores. Los consumidores recuperan estos mensajes, los procesan y, a continuación, piden más trabajo al búfer. En la Figura 4-6 se muestra una ilustración sencilla de este problema. Es un poco como un restaurante buffet 24 horas al día, 365 días al año: la cocina sigue produciendo, los camareros recogen la comida y la ponen en el buffet, y los comensales hambrientos se sirven. Para siempre.

The producer-consumer problem
Figura 4-6. El problema productor-consumidor

Como prácticamente todos los recursos reales, el búfer tiene una capacidad limitada. Los productores generan nuevos elementos, pero si el búfer está lleno, deben esperar a que se haya consumido algún elemento antes de poder añadir el nuevo elemento al búfer. Del mismo modo, si los consumidores consumen más deprisa de lo que producen los productores, deben esperar si no hay artículos en el buffer, y de algún modo recibir una alerta cuando lleguen nuevos artículos.

Una forma de que un productor espere a que haya espacio en el búfer, o de que un consumidor espere a un elemento, es seguir reintentando una operación. Un productor podría dormir un segundo y luego reintentar la operación de poner hasta que tenga éxito. Un consumidor podría hacer lo mismo.

Esta solución se llama polling, o espera ocupada. Funciona bien, pero como el segundo nombre indica, cada productor y consumidor están utilizando recursos (CPU, memoria, ¿quizá red?) cada vez que reintentan y fallan. Si esto no te preocupa, entonces genial, pero en los sistemas escalables siempre tratamos de optimizar el uso de recursos, y el sondeo puede ser un derroche.

Una solución mejor es que los productores y consumidores se bloqueen hasta que su operación deseada, put u get respectivamente, pueda tener éxito. Los hilos bloqueados no consumen recursos y, por tanto, son una solución eficaz. Para facilitarlo, los modelos de programación de hilos proporcionan operaciones de bloqueo que permiten a los hilos enviar señales a otros hilos cuando se produce un evento. Con el problema productor-consumidor, el esquema básico es el siguiente:

  • Cuando un productor añade un elemento a la memoria intermedia, envía una señal a cualquier consumidor bloqueado para notificarle que hay un elemento en la memoria intermedia.

  • Cuando un consumidor recupera un elemento de la memoria intermedia, envía una señal a cualquier productor bloqueado para notificarle que hay capacidad en la memoria intermedia para nuevos elementos.

En Java, existen dos primitivas básicas , a saber wait() y notify(), que pueden utilizarse para implementar este esquema de señalización. Brevemente, funcionan así:

  • Un hilo puede llamar a wait() dentro de un bloque sincronizado si alguna condición que requiere que se mantenga no es cierta. Por ejemplo, un hilo puede intentar recuperar un mensaje de un búfer, pero si el búfer no tiene mensajes que recuperar, llama a wait() y se bloquea hasta que otro hilo añade un mensaje, establece la condición en true, y llama a notify() en el mismo objeto.

  • notify() despierta un hilo que haya llamado a wait() sobre el objeto.

Estas primitivas de Java se utilizan para implementar bloques vigilados. Los bloques protegidos utilizan una condición como protección que debe mantenerse antes de que un subproceso reanude la ejecución. El siguiente fragmento de código muestra cómo se utiliza la condición de guardia empty para bloquear un subproceso que intenta recuperar un mensaje de un búfer vacío:

while (empty) {
  try {
    System.out.println("Waiting for a message");
    wait();
  } catch (InterruptedException e) {}
}

Cuando otro hilo añade un mensaje a la memoria intermedia, ejecuta notify() del siguiente modo:

// Store message.
this.message = message;
empty = false;
// Notify consumer that message is available
notify();

La implementación completa de este ejemplo se ofrece en los ejemplos de código del repositorio Git del libro. Hay una serie de variaciones de los métodos wait() y notify(), pero van más allá de lo que puedo cubrir en este resumen. Por suerte, Java nos proporciona abstracciones seguras para hilos que ocultan esta complejidad a tu código.

Un ejemplo pertinente para el problema productor-consumidor es la interfaz BlockingQueue de java.util.concurrent.BlockingQueue. Una implementación de BlockingQueue proporciona un objeto a prueba de hilos que puede utilizarse como buffer en un escenario productor-consumidor. Existen 5 implementaciones diferentes de la interfaz BlockingQueue. Utilizaré una de ellas, la LinkedBlockingQueue, para implementar el productor-consumidor. Se muestra en el Ejemplo 4-4.

Ejemplo 4-4. Productor-consumidor con una LinkedBlockingQueue
class ProducerConsumer {
   public static void main(String[] args)
     BlockingQueue buffer = new LinkedBlockingQueue();
     Producer p = new Producer(buffer);
     Consumer c = new Consumer(buffer);
     new Thread(p).start();
     new Thread(c).start();
   }
 }

class Producer implements Runnable {
   private boolean active = true;
   private final BlockingQueue buffer;
   public Producer(BlockingQueue q) { buffer = q; }
   public void run() {
     
     try {
       while (active) { buffer.put(produce()); }
     } catch (InterruptedException ex) { // handle exception}
   }
   Object produce() { // details omitted, sets active=false }
 }

 class Consumer implements Runnable {
   private boolean active = true;  
   private final BlockingQueue buffer;
   public Consumer(BlockingQueue q) { buffer = q; }
   public void run() {
     
     try {
       while (active) { consume(buffer.take()); }
     } catch (InterruptedException ex) { // handle exception }
   }
   void consume(Object x) {  // details omitted, sets active=false }
 }

Esta solución exime al programador de preocuparse por la implementación de la coordinación del acceso al búfer compartido, y simplifica enormemente el código.

El paquete java.util.concurrent es un tesoro para construir soluciones Java multihilo. En las siguientes secciones, destacaré brevemente algunas de estas capacidades potentes y extremadamente útiles.

Piscinas de hilos

Muchos sistemas multihilo necesitan crear y gestionar una colección de hilos que realizan tareas similares. Por ejemplo, en el problema productor-consumidor, podemos tener una colección de hilos productores y una colección de hilos consumidores, todos ellos añadiendo y eliminando elementos simultáneamente, con acceso coordinado a la memoria intermedia compartida.

Estas colecciones se conocen como pools de hilos. Los pools de hilos se componen de varios hilos de trabajo, que suelen realizar un propósito similar y se gestionan como una colección. Podríamos crear un grupo de hilos productores que esperen a que se procese un elemento, escriban el producto final en el búfer y luego esperen a aceptar otro elemento para procesarlo. Cuando dejemos de producir elementos, el grupo puede cerrarse de forma segura, para que no se pierdan elementos parcialmente procesados por una excepción imprevista.

En el paquete java.util.concurrent, los grupos de hilos están soportados por la interfaz ExecutorService. Esta interfaz amplía la interfaz base Executor con un conjunto de métodos para gestionar y terminar los hilos del pool. En los Ejemplos 4-5 y 4-6 se muestra un ejemplo sencillo de productor-consumidor que utiliza un pool de hilos de tamaño fijo. La clase Producer del Ejemplo 4-5 es una Runnable que envía un único mensaje a la memoria intermedia y luego termina. La clase Consumer simplemente toma mensajes del búfer hasta que recibe una cadena vacía, tras lo cual termina.

Ejemplo 4-5. Productor y consumidor para la implementación del grupo de hilos
class Producer implements Runnable {
  
  private final BlockingQueue buffer;

  public Producer(BlockingQueue q) { buffer = q; }

  @Override
  public void run() {
     
  try {
    sleep(1000);
    buffer.put("hello world");
              
  } catch (InterruptedException ex) {
    // handle exception
  }
 } 
}

class Consumer implements Runnable {
  private final BlockingQueue buffer;

  public Consumer(BlockingQueue q) { buffer = q; }

  @Override
   public void run() {
      boolean active = true; 
      while (active) {
          try {
             String  s = (String) buffer.take();
             System.out.println(s);
             if (s.equals("")) active = false;
          } catch (InterruptedException ex) {
              / handle exception
          }
      } /
      System.out.println("Consumer terminating");
    }
 }

En el Ejemplo 4-6, creamos un único consumidor para tomar mensajes del buffer. A continuación, creamos un pool de hilos de tamaño fijo de tamaño 5 para gestionar nuestros productores. Esto hace que la JVM preasigne cinco hilos que pueden utilizarse para ejecutar cualquier objeto Runnable que sea ejecutado por el pool.

A continuación, en el bucle for(), utilizamos ExecutorService para ejecutar 20 productores. Como sólo hay 5 hilos disponibles en el pool de hilos, sólo se ejecutarán simultáneamente un máximo de 5 productores. Todos los demás se colocan en una cola de espera gestionada por el pool de hilos. Cuando un productor termina, el siguiente Runnable de la cola de espera se ejecuta utilizando cualquier hilo disponible en la reserva.

Una vez que hemos solicitado a todos los productores que sean ejecutados por el pool de hilos, llamamos al método shutdown() en el pool. Esto indica a ExecutorService que no acepte más tareas para ejecutar. A continuación, llamamos al método awaitTermination(), que bloquea el hilo llamante hasta que todos los hilos gestionados por el pool de hilos estén inactivos y no haya más tareas esperando en la cola de espera. Una vez que vuelve awaitTermination(), sabemos que se han enviado todos los mensajes al búfer y, por tanto, enviamos una cadena vacía al búfer que actuará como valor de terminación para el consumidor.

Ejemplo 4-6. Solución productor-consumidor basada en un pool de hilos
public static void main(String[] args) throws InterruptedException 
  {
    BlockingQueue buffer = new LinkedBlockingQueue();
    
    //start a single consumer 
    (new Thread(new Consumer(buffer))).start();

    ExecutorService producerPool = Executors.newFixedThreadPool(5);
    for (int i = 0; i < 20; i++) 
      {
        Producer producer = new Producer(buffer) ;
        System.out.println("Producer created" );
        producerPool.execute(producer);
      }

      producerPool.shutdown();
      producerPool.awaitTermination(10, TimeUnit.SECONDS);
        
      //send termination message to consumer 
      buffer.put("");        
    }

Como ocurre con la mayoría de los temas de este capítulo, hay muchas funciones más sofisticadas en el framework Executor que pueden utilizarse para crear programas multihilo. Esta descripción sólo ha cubierto lo básico. Los pools de hilos son importantes porque permiten a nuestros sistemas racionalizar el uso de recursos para los hilos. Cada hilo consume memoria; por ejemplo, el tamaño de la pila de un hilo suele rondar 1 MB. Además, cuando cambiamos de contexto de ejecución para ejecutar un nuevo hilo, se consumen ciclos de CPU. Si nuestros sistemas crean hilos de forma indisciplinada, al final nos quedaremos sin memoria y el sistema se bloqueará. Los pools de hilos nos permiten controlar el número de hilos que creamos y utilizarlos eficientemente.

Hablaré de los pools de hilos a lo largo del resto de este libro, ya que son un concepto clave para la gestión eficiente y escalable de las cargas de peticiones cada vez mayores que deben satisfacer los servidores.

Sincronización de barreras

Tenía un amigo en el instituto cuya familia, a la hora de cenar en , no permitía que nadie empezara a comer hasta que toda la familia estaba sentada a la mesa. Esto me pareció raro, pero muchos años después sirve como una buena analogía del concepto conocido como sincronización de barreras. Sólo se empezaba a comer cuando todos los miembros de la familia llegaban a la mesa.

Los sistemas multihilo a menudo necesitan seguir un patrón de comportamiento de este tipo. Imagina un sistema de procesamiento de imágenes multihilo. Llega una imagen y se pasa un segmento distinto de la imagen a cada subproceso para que realice alguna transformación sobre ella: piensa en filtros de Instagram con esteroides. La imagen sólo se procesa completamente cuando todos los subprocesos han terminado. En los sistemas de software, utilizamos un mecanismo llamado sincronización de barrera para lograr este estilo de coordinación de hilos.

El esquema general se muestra en la Figura 4-7. En este ejemplo, el subproceso main() crea cuatro subprocesos nuevos y todos proceden independientemente hasta que llegan al punto de ejecución definido por la barrera. A medida que llega cada hilo, se bloquea. Cuando todos los hilos han llegado a este punto, se libera la barrera, y cada hilo puede continuar con su procesamiento.

Barrier synchronization
Figura 4-7. Sincronización de la barrera

Java proporciona tres primitivas para la sincronización de barreras . Mostraré aquí cómo funciona una de las tres, CountDownLatch. Los conceptos básicos se aplican a otras primitivas de sincronización de barrera.

Cuando creas un CountDownLatch, pasas un valor a su constructor que representa el número de hilos que deben bloquearse en la barrera antes de que se les permita continuar a todos. Esta función se ejecuta en el subproceso que gestiona los puntos de barrera del sistema -en la Figura 4-7 sería main():

CountDownLatch  nextPhaseSignal = new CountDownLatch(numThreads);

A continuación, creas los hilos trabajadores que realizarán algunas acciones y luego se bloquearán en la barrera hasta que se completen todas. Para ello, tienes que pasar a cada hilo una referencia a CountDownLatch:

for (int i = 0; i < numThreads; i++) {
            Thread worker = new Thread(new WorkerThread(nextPhaseSignal));
            worker.start();
        }

Tras lanzar los subprocesos trabajadores, el subproceso main() llamará al método .await() para bloquearse hasta que los subprocesos trabajadores activen el cierre:

nextPhaseSignal.await();

Cada hebra trabajadora completará su tarea y, antes de salir, llamará al método .countDown() en el latch. Esto disminuye el valor del latch. Cuando el último subproceso llame a .countDown() y el valor del pestillo sea cero, todos los subprocesos que hayan llamado a .await() en el pestillo pasarán del estado bloqueado al estado ejecutable. En este momento tenemos la seguridad de que todos los trabajadores han completado su tarea asignada:

nextPhaseSignal.countDown();

Cualquier llamada posterior a .countDown() retornará inmediatamente, ya que el pestillo se ha activado efectivamente. Ten en cuenta que .countDown() no es bloqueante, lo cual es una propiedad útil para aplicaciones en las que los hilos tienen más trabajo que hacer después de alcanzar la barrera.

Este ejemplo ilustra el uso de un CountDownLatch para bloquear un único subproceso hasta que una colección de subprocesos haya completado su trabajo. Sin embargo, puedes invertir este caso de uso con un latch, si inicializas su valor a uno. Varios subprocesos podrían llamar a .await( ) y bloquearse hasta que otro subproceso llame a .countDown() para liberar a todos los subprocesos en espera. Este ejemplo es análogo a una simple compuerta, que un hilo abre para permitir que un conjunto de otros continúe.

CountDownLatch es un simple sincronizador de barrera. Es una herramienta de un solo uso, ya que el valor del inicializador no se puede restablecer. Las clases CyclicBarrier y Phaser de Java proporcionan funciones más sofisticadas. Con los conocimientos sobre el funcionamiento de la sincronización de barrera adquiridos en esta sección, te resultará fácil comprenderlas en .

Colecciones a prueba de hilos

Muchos programadores Java, una vez que se adentran en las maravillas de los programas multihilo, se sorprenden al descubrir que las colecciones del paquete java.util no son seguras para los hilos.2 ¿Por qué? La respuesta, por suerte, es sencilla. Tiene que ver con el rendimiento. Llamar a métodos sincronizados incurre en sobrecargas. Por eso, para conseguir una ejecución más rápida de los programas monohilo, las colecciones no son seguras para los hilos.

Si quieres compartir un ArrayList, Map, o tu estructura de datos favorita de java.util a través de múltiples hilos, debes asegurarte de que las modificaciones de la estructura se colocan en las secciones críticas. Este enfoque hace recaer en el cliente de la colección la carga de realizar actualizaciones de forma segura, y por tanto es propenso a errores: un programador puede olvidarse de realizar modificaciones en un bloque de synchronized.

Siempre es más seguro utilizar colecciones inherentemente seguras para hilos en tu código multihilo. Por esta razón, el marco de colecciones de Java proporciona un método de fábrica que crea una versión segura para hilos de las colecciones java.util. He aquí un ejemplo de creación de una lista a prueba de hilos:

List<String> list = Collections.synchronizedList(new ArrayList<>());

Lo que realmente ocurre aquí es que estás creando una envoltura alrededor de la clase base de colección, que tiene métodos synchronized. Éstos delegan el trabajo real en la clase original, por supuesto a prueba de hilos. Puedes utilizar este enfoque para cualquier colección del paquete java.util, y la forma general es:

Collections.synchronized....(new collection<>())

donde "...." es List, Map, Set, y así sucesivamente.

Por supuesto, al utilizar las envolturas sincronizadas, pagas la penalización de rendimiento por adquirir el bloqueo de monitoreo y serializar el acceso desde varios subprocesos. Esto significa que toda la colección está bloqueada mientras un único hilo realiza una modificación, lo que limita enormemente el rendimiento concurrente (otra vez la ley de Amdahl). Por este motivo, Java 5.0 incluyó el paquete de colecciones concurrentes, concretamente java.util.concurrent. Contiene una rica colección de clases diseñadas específicamente para un acceso multihilo eficiente.

De hecho, ya hemos visto una de estas clases: LinkedBlockingQueue. Utiliza un mecanismo de bloqueo que permite añadir y eliminar elementos de la cola en paralelo. Este mecanismo de bloqueo de grano más fino utiliza la clase java.util.concurrent.lock.Lock en lugar del enfoque de bloqueo de monitoreo. Esto permite utilizar varios bloqueos en la misma colección, lo que permite un acceso concurrente seguro.

Otra colección extremadamente útil que proporciona este bloqueo de grano fino es la ConcurrentHashMap. Proporciona métodos similares a los de la colección no segura para hilos HashMap, pero permite lecturas no bloqueantes y escrituras concurrentes basadas en un valor concurrencyLevel que puedes pasar al constructor (el valor por defecto es 16):

ConcurrentHashMap (int initialCapacity, float loadFactor, 
                     int concurrencyLevel)

Internamente, la tabla hash se divide en en segmentos bloqueables individualmente, a menudo conocidos como fragmentos. Los bloqueos se asocian a cada fragmento y no a toda la colección. Esto significa que se pueden realizar actualizaciones simultáneas de las entradas de la tabla hash en distintos fragmentos de la colección, lo que aumenta el rendimiento.

Las operaciones de recuperación son no bloqueantes por razones de rendimiento, lo que significa que pueden solaparse con múltiples actualizaciones concurrentes. Esto significa que las recuperaciones sólo reflejan los resultados de las operaciones de actualización completadas más recientemente en el momento en que se ejecuta la recuperación.

Por razones similares, los iteradores para un ConcurrentHashMap son lo que se conoce como débilmente consistentes. Esto significa que el iterador contiene una copia del mapa hash que refleja su estado en el momento en que se crea el iterador. Mientras el iterador está en uso, pueden añadirse nuevos nodos y eliminarse nodos existentes del mapa hash subyacente. Sin embargo, estos cambios de estado no se reflejan en el iterador.

Si necesitas un iterador que refleje siempre el estado actual del hashmap mientras lo actualizan varios subprocesos, entonces hay que pagar penalizaciones de rendimiento, y un ConcurrentHashMap no es el enfoque adecuado. Este es un ejemplo de favorecer el rendimiento frente a la coherencia, un clásico compromiso de diseño.

Resumen y lecturas complementarias

Me basaré en los principales conceptos introducidos en este capítulo a lo largo del resto del libro. Los hilos son componentes inherentes de las plataformas de procesamiento de datos y bases de datos que utilizamos para construir sistemas distribuidos escalables. En muchos casos, puede que no escribas código explícitamente multihilo. Sin embargo, el código que escribas será invocado en un entorno multihilo, lo que significa que debes ser consciente de la seguridad de los hilos. Muchas plataformas también exponen su concurrencia a través de parámetros de configuración, lo que significa que, para ajustar el rendimiento del sistema, tienes que comprender los efectos de cambiar las distintas configuraciones de hilos y grupos de hilos. Básicamente, no hay forma de escapar a la concurrencia en el mundo de los sistemas distribuidos escalables.

Por último, cabe mencionar que, aunque las primitivas de programación concurrente varían según los lenguajes de programación, las cuestiones fundamentales no cambian, y se necesita un código multihilo cuidadosamente diseñado para evitar condiciones de carrera y bloqueos. Tanto si te enfrentas a la bibliotecapthreads en C/C++, como al modelo de concurrencia clásico Go inspirado en CSP, los problemas que debes evitar son los mismos. Los conocimientos que has adquirido en este capítulo te pondrán sin duda en el buen camino, sea cual sea el lenguaje que utilices.

Este capítulo sólo ha rozado la superficie de la concurrencia en general y su soporte en Java. El mejor libro para seguir aprendiendo más sobre los conceptos básicos de la concurrencia es el clásico Java Concurrency in Practice(JCiP) de Brian Goetz et al. (Addison-Wesley Professional, 2006). Si entiendes todo lo que dice el libro, estarás escribiendo un código de concurrencia bastante bueno.

El soporte de Java para la concurrencia ha avanzado considerablemente desde Java 5. En el mundo de Java 12 (o la versión que esté vigente cuando leas esto), hay nuevas funciones como CompletableFutures, expresiones lambda y flujos paralelos. El estilo de programación funcional introducido en Java 8.0 facilita la creación de soluciones concurrentes sin necesidad de crear y gestionar hilos directamente. Una buena fuente de conocimientos sobre las características de Java 8.0 es Mastering Concurrency Programming with Java 8, de Javier Fernández González (Packt, 2017).

Otras fuentes excelentes son:

  • Doug Lea, Programación concurrente en Java: Design Principles and Patterns, 2ª ed. (Addison-Wesley Professional, 1996)

  • Raoul-Gabriel Urma, Mario Fusco y Alan Mycroft, Java moderno en acción: Lambdas, Streams, Programación Funcional y Reactiva (Manning, 2019)

  • El sitio web de Baeldung tiene una completa colección de artículos para aprender sobre la concurrencia en Java y sirvió de base para el ejemplo de los filósofos comedores de este capítulo.

1 La forma correcta de tratar estos problemas, es decir, la sincronización de barreras, se trata en más adelante en este capítulo.

2 Excepto Vector y HashTable, que son clases heredadas; ¡seguras para hilos y lentas!

Get Fundamentos de los sistemas escalables now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.