Explicación al reto de concurrencia. Solución con StampedLock

La mayoría de programadores encontró un punto ciego con la sentencia »

Text
arr[size++]=e;

«. De alguna forma pensamos que el tamaño se actualizará después de la asignación. En este artículo veremos este bug básico de concurrencia y presentamos una solución con

Text
StampedLock

de Java 8.

[box style=»1″]

Éste artículo es una traducción al castellano de la entrada original publicada, en inglés, por Dr. Heinz Kabutz en su número 242 de The Javatm Specialists’ Newsletter. Puedes consultar el texto original en Javaspecialists’ Newsletter #242: Concurrency Puzzle Explained, Solved With StampedLock

Este artículo se publica traducido en adictos, con permiso del autor, por David Gómez García, (@dgomezg) consultor tecnológico en Autentia, colaborador de JavaSpecialists e instructor certificado para impartir los cursos de JavaSpecialists en español.
[/box]


Bienvenidos a la edición número 242 de The Javatm Specialists’ Newsletter, enviado desde una lluviosa Creta. Siempre nos sorprendemos con las primeras lluvias de verdad de la temporada. ¿Cómo se atreve el agua a caer desde el cielo? ¿y qué es eso gris de ahí arriba?. Nuestros olivos necesitan un sorbo de agua para engordar sus frutos para la cosecha. Lamentablemente, gracias a los fuertes vientos, buena parte de nuestra cosecha está convirtiendose en compost. Bueno, quizá el próximo año podamos llenar nuestras cubas de nuevo. Hasta entonces, ¡racionaremos los suministros!Me alegra poder dar la bienvenida a Burkina Faso a la lista de paises suscriptores confirmados, dejando el contador en 138 paises.

El canal de Slack de Java Specialists (en inglés) está que se sale. Casi 2.000 miembros ya, intercambiando grandes ideas, aprendiendo cosas nuevas. Tenemos miembros en 22 husos horarios. Lo que significa que puedes encontrar a alguien para hablar de Java en tiempo real a cualquier hora del día o de la noche. Para unirte, rellena este formulario con tus datos y automágicamente recibirás una invitación de Slack (pero por favor, comprueba tu carpeta de SPAM).

NOVEDAD Hemos actualizado nuestro curso de «Advanced Topics» que cubre Reflection, Java NIO, Estructuras de datos, Gestión de memoria y algunos otros temas útiles para dominar si eres un experto en Java. Dos días de diversión y aprendizaje extremos: «Extreme Java – Advanced Topics». Este curso también lo impartimos en castellano.

El problema de concurrencia explicado, solucionado con StampedLock

Nos hemos divertido este último mes. Propuse un reto de concurrencia que ha tenido a expertos en Java de todo el mundo rascándose la cabeza. Tanto es así, que también publiqué algunas pistas útiles.

Lo que más me sorprendió de las respuestas es la cantidad que no se había molestado en ejecutar el código. En vez de ello, lo revisaron y pulsaron en «Responder». Ninguna de estas respuestas capturó el bug que lo hacía fallar de forma consistente. Lo peor es que perdieron una oportunidad fantástica de aprender algo nuevo.

Cuando se buscan errores de concurrencia, el primer paso es tratar de reproducirlo. Esto puede resultar bastante difícil. Por ejemplo, el código que me envió Jack no fallaba en mi MacBook Pro. Lo tuve que ejecutar en mi servidor 2-4-1 con una CPU más antigua para conseguir que el bug se reprodujese. Aquí tenéis una versión con un probabilidad mayor de hacer aparecer el fallo, adaptado para mi amigo Stuart Marks:

import java.util.*;

public class MyArrayListStuart {
  private final Object READ_LOCK = new Object();
  private final Object WRITE_LOCK = new Object();
  private int[] arr = new int[10];
  private int size = 0;

  public int size() {
    synchronized (READ_LOCK) {
      return size;
    }
  }

  public int get(int index) {
    synchronized (READ_LOCK) {
      rangeCheck(index);
      return arr[index];
    }
  }

  public boolean add(int e) {
    synchronized (WRITE_LOCK) {
      if (size + 1 > arr.length)
        arr = Arrays.copyOf(arr, size + 10);

      arr[size++] = e;
      return true;
    }
  }

  public int remove(int index) {
    synchronized (WRITE_LOCK) {
      rangeCheck(index);

      int oldValue = arr[index];

      int numMoved = size - index - 1;
      if (numMoved > 0)
        System.arraycopy(arr, index + 1,
            arr, index, numMoved);
      arr[--size] = 0;

      return oldValue;
    }
  }

  private void rangeCheck(int index) {
    if (index >= size)
      throw new IndexOutOfBoundsException(
          "Index: " + index + ", Size: " + size);
  }

  private static volatile boolean goAdd;

  public static void main(String[] args) {
    for (int i = 0; i < 100000; i++) {
      goAdd = false;
      MyArrayListStuart list = new MyArrayListStuart();
      new Thread(new Main(list, true)).start();
      new Thread(new Main(list, false)).start();
      new Thread(new Main(list, false)).start();
      new Thread(new Main(list, false)).start();
    }
  }

  static class Main implements Runnable {
    MyArrayListStuart list;
    boolean update;

    public Main(MyArrayListStuart list,
                boolean update) {
      this.list = list;
      this.update = update;
    }

    @Override
    public void run() {
      if (update) {
        while(!goAdd);
        goAdd = false;
        for (int i = 1; i < 1000; i++) {
          list.add(i);
        }
        for (int i = 1; i < 250; i++) {
          list.remove(7);
        }
      } else {
        // wait until we're certain
        // index 6 has a value
        while (list.size() < 7) {goAdd = true;}
        for (int i = 1; i < 1000; i++) {
          int x;
          if ((x = list.get(6)) != 7) {
            System.out.println(x +
                " and " + list.size());
          }
        }
      }
    }
  }
}

El fallo de concurrencia se manifiesta más fácilmente con el código modificado. Lo podemos reproducir de forma consistente. Es casi imposible hacer esto con errores de tipo happens-before. Esos suelen mostrar su fea cara en producción, no en un pequeño test como el nuestro. Los errores de visibilidad suelen aparecer y no se recupera uno de ellos, por ejemplo, si tenemos un bucle cerrado en el que leemos un campo desprotegido. En nuestro caso, el error es mucho más básico.

La segunda pista estaba en que ocurría incluso sin el

Text
remove()

. Así que podiamos ignorar tranquilamente el método

Text
remove()

y centrarnos en el método

Text
add()

.

Una tercera pista estaba en que el redimensionado no tenía nada que ver con el error. Incluso si dimensionamos desde el principio el array con un tamaño de 1.000, también podía fallar.

La primera respuesta correcta la dió Andreas Senft. Reconoció que se trataba simplemente de un error de concurrencia común que no tenía nada que ver con cachés, happens-before, visibilidad, etc… Simple y llanamente una condición de carrera en el método

Text
add()

. Si miramos dentro de

Text
add()

, veremos la sentencia

Text
arr[size++] = e;

. La mayoría de programadores leen esto como «Asigna e en el elemento del array y actualiza el tamaño». En sus cabezas, esto es lo que ocurre:

	arr[size] = e;
	size = size + 1;

Aunque sería más acertado verlo de la siguiente forma:

  int temp = size;
  size = size + 1;
  arr[temp] = e;

Ahora es obvio que

Text
size

se actualiza primero y después se asigna el elemento en el array. Si, entre la llamada a

Text
size = size + 1;

y

Text
arr[temp] = e;

el hilo deja su tiempo de ejecución de CPU, el hilo de lectura podría tratar de leer un elemento que no existe todavía, dado que está bloqueado en un monitor diferente. Si reemplazamos el código por el siguiente, entonces los errores se producirán con menos frecuencia (pero todavía es incorrecto):

	arr[size] = e;
	size++;

Demostrar que es también incorrecto será más complicado. El test que teníamos en el artículo anterior no será suficiente.

El problema con el código estaba en que tenemos distintos locks guardando campos que mantienen una invariante entre ellos. La motivación detrás de esto era tratar de evitar que los bloqueos de escritura bloqueen los hilos que sólo quieren leer. Pensé que éste sería un buen caso de uso para el

Text
StampedLock

de Java 8. Si todo esto te resulta nuevo, ¿puedo sugerirte apuntarte a mi curso Extreme Java – Concurrency and performance for Java 8? (que también impartimos en español). ¡Aprenderás en tres días más de lo que crees que es posible!

IntList con StampedLock

Text
StampedLock

se añadió con Java 8 para darnos la posibilidad de realizar lecturas optimistas sobre campos. Esto es útil cuando tenemos varios campos con una invariante entre todos ellos. En nuestro ejemplo, esos son los campos

Text
arr

y

Text
size

.

Text
IntList

es el resultado de un trabajo en colaboración entre Peter Levart, Henri Tremblay, Hanko Gergely, Alex Snaps y yo mismo. Cada uno de nosotros ha ayudado a refinar un poquito la solución final. Es un trabajo en curso así que, por favor, decidme si encontráis una forma de mejorarlo todavía más. Casi seguro que tiene jugosos bugs aún por descubrir.

Para empezar tenemos

Text
size()

. Dado que lo que nos preocupa es la visibilidad del campo

Text
size

, lo único que necesitamos es llamar al método

Text
tryOptimisticRead()

. Éste método realiza una lectura volatile sobre el campo dentro de un

Text
StampedLock

, que tiene el mismo efecto que entrar en un bloque

Text
synchronized

, y por tanto garantiza que veremos el valor correcto del campo

Text
size

.

El método

Text
get()

es el más complejo. Intentamos leer el elemento del array en una variable local entre las llamadas a

Text
tryOptimisticRead()

y

Text
validate()

, asegurándonos que no causamos un

Text
ArrayIndexOutOfBoundsException

en el proceso. Si somos lo suficientemente rápidos, podemos hacerlo antes de que otro hilo obtenga el lock de escritura. Verás que intentamos la lectura optimista tres veces y, si no lo conseguimos, cambiamos a usar un lock de lectura pesimista. Este cambio puede ser bueno, pero también puede penalizarnos si lo hacemos demasiado.

La función

Text
trimToSize()

se supone que nos muestra como podemos utilizar

Text
tryOptimisticRead()

para evitar obtener un lock de escritura si no lo necesitamos.

El método

Text
add()

es sencillo, casi como utilizar un

Text
ReentrantLock

.

Tenemos dos funciones de borrado. El método

Text
remove()

simple es análogo a

Text
add()

, utilizando un lock pesimista para la lectura exclusiva. El método

Text
removeWithUpgrade()

obtiene un lock de lectura y, si el índice no está fuera de rango, intentamos convertirlo en un lock de escritura. Un

Text
ReentrantReadWriteLock

produciría un deadlock, pero con

Text
StampedLock

lo conseguiremos. Este lock condicional de escritura es útil cuando tenemos una alta probabilidad de no necesitar el lock de escritura. De todas formas, en nuestro caso esperamos que la mayor parte de las veces el índice estará dentro del rango. Por eso, el método

Text
remove()

sería probablemente una solución mejor. Más rápido y más fácil de comprender y de mantener.

Vamos al código. Divertíos. Discutiremos esto en el canal #newsletter del grupo JavaSpecialists.slack.com. Por favor, rellena tus datos aquí para unirte (sin cuotas, sin obligaciones, excepto «ser agradables»).

import java.util.*;
import java.util.concurrent.locks.*;

public class IntList {
  private static final int OPTIMISTIC_SPIN = 3;
  private final StampedLock sl = new StampedLock();
  private int[] arr = new int[10];
  private int size = 0;

  /**
   * The size() method cannot be used to perform compound
   * operations, such as getting the last element of a list.
   * This code could fail with an IndexOutOfBoundsException:
   *
   * <pre>
   *   Thread1:
   *     while(true) {
   *       list.get(list.size()-1);
   *     }
   *
   *   Thread2:
   *     for (int i = 0; i < 2000; i++) {
   *       for (int j = 0; j < 100; j++) {
   *         list.add(j);
   *       }
   *       for (int j = 0; j < 50; j++) {
   *         list.remove(0);
   *       }
   *     }
   * </pre>
   */
  public int size() {
    // Internally, the tryOptimisticRead() is reading a volatile
    // field.  From a memory visibility perspective, reading a
    // volatile field is like entering a synchronized block.
    // We will thus not have an issue with stale values of size.
    // Note 1: volatile != synchronized.  Volatile does not
    // magically make compound operations on fields mutually
    // exclusive. Race conditions are more probable on volatile
    // fields.
    // Note 2: We could also have made size volatile.  From a
    // visibility perspective the tryOptimisticRead() will work,
    // but not if size was a long or if it could have
    // intermediate values that broke invariants.
    sl.tryOptimisticRead();
    return this.size;
  }

  public int get(int index) {
    for (int i = 0; i < OPTIMISTIC_SPIN; i++) {
      long stamp = sl.tryOptimisticRead();
      int size = this.size;
      int[] arr = this.arr;
      if (index < arr.length) {
        int r = arr[index];
        if (sl.validate(stamp)) {
          rangeCheck(index, size);
          return r;
        }
      }
    }
    long stamp = sl.readLock();
    try {
      rangeCheck(index, this.size);
      return this.arr[index];
    } finally {
      sl.unlockRead(stamp);
    }
  }

  public void trimToSize() {
    long stamp = sl.tryOptimisticRead();
    int currentSize = size;
    int[] currentArr = arr;
    if (sl.validate(stamp)) {
      // fast optimistic read to accelerate trimToSize() when
      // there is no work to do
      if (currentSize == currentArr.length) return;
    }
    stamp = sl.writeLock();
    try {
      if (size  arr.length)
        arr = Arrays.copyOf(arr, size + 10);

      arr[size++] = e;
      return true;
    } finally {
      sl.unlockWrite(stamp);
    }
  }

  // just to illustrate how an upgrade could be coded
  public int removeWithUpgrade(int index) {
    long stamp = sl.readLock();
    try {
      while (true) {
        rangeCheck(index, size);
        long writeStamp = sl.tryConvertToWriteLock(stamp);
        if (writeStamp == 0) {
          sl.unlockRead(stamp);
          stamp = sl.writeLock();
        } else {
          stamp = writeStamp;
          return doActualRemove(index);
        }
      }
    } finally {
      sl.unlock(stamp);
    }
  }

  public int remove(int index) {
    long stamp = sl.writeLock();
    try {
      rangeCheck(index, size);
      return doActualRemove(index);
    } finally {
      sl.unlock(stamp);
    }
  }

  private int doActualRemove(int index) {
    int oldValue = arr[index];

    int numMoved = size - index - 1;
    if (numMoved > 0)
      System.arraycopy(arr, index + 1, arr, index, numMoved);
    arr[--size] = 0;

    return oldValue;
  }

  private static void rangeCheck(int index, int size) {
    if (index >= size)
      throw new IndexOutOfBoundsException(
          "Index: " + index + ", Size: " + size);
  }
}
  

He ejecutado algunas pruebas de rendimiento comparando éste con una versión correctamente sincronizada del

Text
MyArrayList

que usamos en el número anterior. El método

Text
get()

es basicamente el doble de rápido y usa bastante menos CPU de sistema gracias a la reducción del número de cambios de contexto voluntarios. Los métodos

Text
add()

y

Text
remove()

son ligeramente más lentos, pero como esperaba,

Text
size()

es ridículamente rápido. He añadido éste como un ejercicio a mi curso de concurrencia. Veamos cómo de bien se las apañan programadores Java buenos y experimentados cuando vean

Text
StampedLock

por primera vez 🙂

Saludos

Heinz.

Comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

He leído y acepto la política de privacidad

Información básica acerca de la protección de datos

  • Responsable: IZERTIS S.A.
  • Finalidad: Envío información de carácter administrativa, técnica, organizativa y/o comercial sobre los productos y servicios sobre los que se nos consulta.
  • Legitimación: Consentimiento del interesado
  • Destinatarios: Otras empresas del Grupo IZERTIS. Encargados del tratamiento.
  • Derechos: Acceso, rectificación, supresión, cancelación, limitación y portabilidad de los datos.
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad

Heinz es el creador de "The Java Specialists' Newsletter".
Doctor en Informática, Heinz ha participado en el desarrollo de grandes aplicaciones en Java, ha formado a miles de desarrolladores profesionales y es ponente habitual en las principales conferencias sobre Java.
Reconocido como Java Champion por Sun Microsystems -los creadores de Java- por su trabajo en mejorar Java, Heinz imparte cursos de JavaSpecialists.eu por todo el mundo, de forma remota y presencial. Es autor de dichos cursos, incluidos 'Java Specialists Master', 'DesignPatterns and Concurrency Specialists' y 'Performance and Concurrency for Java 8'.

¿Quieres publicar en Adictos al trabajo?

Te puede interesar

10/06/2025

Iván Suarez Romero

Aprende cómo migrar tu sitio Joomla 3 a Joomla 5 de forma segura, manteniendo el diseño, la funcionalidad y compatibilidad con extensiones. Una guía paso a paso con recomendaciones, imágenes y buenas prácticas para actualizar sin sorpresas.

04/06/2025

Gonzalo Matarrubia González

Descubre qué es Yocto Project, sus ventajas, usos reales en Izertis y cómo crear tu propia distribución Linux para Raspberry Pi paso a paso, de forma sencilla y flexible.

30/05/2025

Roberto José

¿Trabajas con Drupal y SonarQube 9.9? En este artículo exploramos cómo adaptar el análisis estático para evitar falsos positivos, desactivar reglas conflictivas del Quality Profile y delegar el estilo a PHP CodeSniffer. Una guía práctica para mejorar la integración sin depender aún de SonarQube 10.