Los hilos virtuales y el problema del “pinning”

Los hilos virtuales (JEP 444) llegaron a Java en forma estable con la JDK 21, pero en realidad aún no son del todo confiables, al menos no son compatibles con todas las librerías existentes, entonces para usarlo en ambientes productivos y que el resultado sea el esperado debe tenerse en cuenta el posible problema que puede traer uso, nos referimos al tema del anclaje o pinning. En esta entrada hablaremos de este tema.

¿Cuál es la promesa de los virtuales?

El desarrollo de los hilos virtuales en Java tiene lugar bajo la sombrilla del proyecto Loom, y junto a los hilos virtuales vienen en camino de igual manera la concurrencia estructurada (structured concurrency) y los valores de ámbito (scoped values). Por si no estás muy al tanto de las últimas novedades de la JDK, en Java 21 se liberó en GA (versión estable) la primera parte de este proyecto que son los hilos virtuales en sí.

Nota: El eje central de Loom fue la creación de Continuation, que es el corazón de los hilos virtuales, en otra entrada o video del canal de Youtube hablaremos de esto.

Un hilo virtual tiene como principal diferencia con respecto a un hilo de plataforma (Date una vistazo a este video que explicamos que son los hilos de plataforma, y a este otro donde explicamos como funcionan) que en lugar de estar mappeados 1 a 1 con los hilos del sistema operativo se mapean 1 a muchos. Adicionalmente los hilos virtuales son manejados por la propia JVM y no dependen directamente del planificador de hilos del sistema operativo.

Java Virtual threads

El planificador de hilos de la JVM es el encargado de controlar los hilos virtuales y gestionar su ejecución. Los hilos virtuales se “montan” en un hilo de plataforma cuando se van a ejecutar y cuando entran en un estado waiting, time-waiting o blocked** son desmontados del hilo de plataforma (también llamado carrier thread).

Ventajas de los hilos virtuales

Algunas ventajas relevantes de los hilos virtuales son:

Escalabilidad masiva: Los hilos virtuales permiten manejar millones de hilos concurrentes gracias a su diseño ligero, superando las limitaciones de los hilos de plataforma que dependen 1:1 de los hilos nativos del sistema operativo. Esto los hace ideales para aplicaciones de alta concurrencia.

Uso eficiente de recursos: Los hilos virtuales no reservan memoria fija para su pila y se desmontan de los carrier threads cuando están en espera. Esto libera recursos del sistema operativo, reduciendo significativamente el consumo de memoria y mejorando la eficiencia.

Simplicidad en el diseño: Permiten escribir código concurrente de forma sincrónica más legible, eliminando la necesidad de paradigmas complejos como la programación reactiva.

Compatibilidad con APIs bloqueantes: Funcionan con las APIs tradicionales de Java sin necesidad de rediseñar su arquitectura. No hay que tocar nada en las implementaciones actuales.

Concurrencia estructurada: La API de Structured Concurrency organiza las tareas concurrentes jerárquicamente, mejorando el manejo de errores, la cancelación y la claridad del flujo de ejecución, ideal para sistemas concurrentes complejos.

El problema de los pinned threads

Cuando un hilo virtual puede ser desmontado de su hilo portador o carried thread el planificador libera el hilo de plataforma para que puede ser usado para ejecutar algún otro hilo virtual; sin embargo hay un problema con ciertas situaciones que pueden darse que evitan esta liberación, dejando lamentablemente atado el hilo virtual a su hilo de plataforma asignado. Esta situación puede conducir a cuellos de botella limitando la escalabilidad prometida de los hilos virtuales.

Veamos un par de ejemplos que conducen a esta situación de pinned threads

Código con sincronización nativa

El uso de la cláusula synchronized por si sola no es suficiente para causar un anclaje del hilo, pero si dentro de la misma ejecutamos una operación bloqueante que pueda conducir el hilo virtual a un estado waiting o time-waiting podemos llegar a esta condición en la que el hilo no se podrá liberar de su carrier.

En código:

public class PinnedSynchronized {

    public static void main(String[] args) throws InterruptedException {

        final CounterWithSynchronized counter = new CounterWithSynchronized();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread thread1 = Thread.ofVirtual().start(task);
        Thread thread2 = Thread.ofVirtual().start(task);

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            System.out.println("Error: " + e);
        }

        System.out.println("Final counter value: " + counter.getCount());
    }
}

class CounterWithSynchronized {

    private int count = 0;

    public void increment() {
        synchronized (this) {
            try {
                Thread.sleep(100); // This simulates a blocking call within the synchronized block
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            count++;
        }
    }

    public synchronized int getCount() {
        return count;
    }
}

Si ejecutamos el programa anterior y le damos una mirada en una herramienta de análisis como VisualVM veremos esto:

La JVM ofrece una flag que nos permite detectar hilos pinneados, al ejecutar el programa podemos pasar la bandera:

-Djdk.tracePinnedThreads=full

El resultado de la salida será similar al siguiente cuando hay hilos pinneados:

VirtualThread[#32]/runnable@ForkJoinPool-1-worker-2 reason:MONITOR
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
    java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
    java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:640)
    java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:817)
    java.base/java.lang.Thread.sleepNanos(Thread.java:494)
    java.base/java.lang.Thread.sleep(Thread.java:527)
    com.sacavix.concurrency.virtualthreads.pinned.CounterWithSynchronized.increment(PinnedSynchronized.java:36) <== monitors:1
    com.sacavix.concurrency.virtualthreads.pinned.PinnedSynchronized.lambda$main$0(PinnedSynchronized.java:11)
    java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

La parte interesante es donde dice:

java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)

La Continutation no ha podido ceder el control (en otro momento hablaremos de continuation).

Solución para evitar el “pinning” en hilos virtuales

Para evitar que nuestros hilos virtuales queden anclados podemos seguir una serie de prácticas que te resumo a continuación:

  • Evita el uso de bloques synchronized, aunque no siempre producen pinning, son el causante del mismo de conjunto con otras condiciones.
  • Emplea cuando necesites bloqueos a ReentrantLock.
  • Evita el uso de JNI, las llamadas a código nativo usando JNI pueden conducir a la condición de anclaje.
  • Ten cuidado con la librerías que uses en tu proyecto, verifica que sean 100% compatibles con los hilos virtuales.
  • Prueba siempre antes de ir a producción, emplea la flag de la JVM que comentamos anteriormente para ver si en alguno de los flujos de tu aplicación puedes tener esta condición.

Dejo esta nota tomada del sitio de Oracle al respecto:

Pinning does not make an application incorrect, but it might hinder its scalability. Try avoiding frequent and long-lived pinning by revising synchronized blocks or methods that run frequently and guarding potentially long I/O operations with java.util.concurrent.locks.ReentrantLock.

https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD

El futuro de los pinned threads

En la JEP 491 se está trabajando en darle solución a este problema, se espera esté disponible en Java 24.

Espero te haya servido este artículo, si crees es útil este tipo de contenido ayúdame suscribiéndote al canal de Youtube, es gratuito.