Los subprocesos virtuales en Project Loom operan como continuaciones montadas sobre subprocesos portadores extraídos de un ForkJoinPool. Cuando un subproceso virtual encuentra un bloque synchronized o ejecuta código nativo, bloquea su subproceso portador subyacente, impidiendo que el programador desmonte el subproceso virtual durante operaciones de E/S bloqueadas. Esto reduce efectivamente el grado de concurrencia al tamaño del grupo de portadores (típicamente igual al número de núcleos de CPU), lo que puede causar un colapso de rendimiento bajo carga, ya que los subprocesos virtuales en contención monopolizan el grupo de portadores fijo.
Una empresa de servicios financieros migró su puerta de enlace de procesamiento de órdenes heredada de un modelo tradicional de Tomcat con un subproceso por solicitud (limitado a 500 subprocesos de plataforma) a Jetty con subprocesos virtuales, esperando manejar 50,000 conexiones WebSocket concurrentes. Inmediatamente después del despliegue, a pesar de la adopción de subprocesos virtuales, la latencia se disparó a segundos y el rendimiento se estancó en apenas 800 TPS durante la volatilidad del mercado al inicio. Los volcado de subprocesos revelaron que los 24 subprocesos portadores estaban atrapados en estado BLOQUEADO dentro de bloques synchronized, mientras miles de subprocesos virtuales en cola para E/S no podían avanzar.
La primera solución considerada fue aumentar la paralelismo del ForkJoinPool a través de -Djdk.virtualThreadScheduler.parallelism a 1000. Esto proporcionaría más subprocesos portadores para absorber la carga de trabajo bloqueada, volviendo efectivamente a un comportamiento de un gran grupo de subprocesos de plataforma. Sin embargo, este enfoque simplemente enmascara el defecto arquitectónico subyacente al consumir recursos excesivos del sistema operativo y anula los beneficios de eficiencia de memoria prometidos por la virtualización de subprocesos virtuales.
La segunda solución implicó refactorizar todos los bloques synchronized que protegen las cachés de limitación de tasa compartidas para utilizar ReentrantLock en su lugar. A diferencia de los monitores intrínsecos, ReentrantLock se integra con el programador de subprocesos virtuales, permitiendo el desmontaje durante la contención o las operaciones de bloqueo sin bloquear el portador. Este enfoque preserva la naturaleza ligera de los subprocesos virtuales, pero requiere una auditoría sistemática de la base de código y un manejo cuidadoso de la semántica de interrupción de bloqueos.
La tercera solución propuesta fue reemplazar las cachés de mapas hash concurrentes con estructuras de datos puramente sin bloqueos, como los métodos de cálculo de ConcurrentHashMap o StampedLock para lecturas optimistas. Si bien esto elimina el bloqueo para muchos caminos de lectura, no aborda los escenarios que requieren acceso exclusivo a recursos externos con estado, como las secuencias de comprobación de conexión a bases de datos que requieren intrínsecamente la exclusión mutua.
El equipo seleccionó la segunda solución, priorizando una migración dirigida de cincuenta secciones críticas synchronized a ReentrantLock después de que se identificaran como puntos críticos de pinning. Esta elección abordó directamente la causa raíz al permitir que el programador desmontara subprocesos virtuales durante la contención, sin alterar la lógica comercial subyacente de la aplicación ni aumentar el uso de memoria.
Después de la refactorización y el redepliegue, el sistema logró el objetivo de 50,000 conexiones concurrentes con una latencia p99 estable por debajo de 100 ms. El grupo de subprocesos portadores se mantuvo en el tamaño predeterminado de 24 (igualando los núcleos de CPU), demostrando que los subprocesos virtuales ofrecen verdadera escalabilidad solo cuando el código evita bloquear a los portadores a través de la sincronización intrínseca.
// Antes: Bloqueando el subproceso portador synchronized (rateLimiter) { // El subproceso virtual no puede desmontarse si está bloqueado aquí externalApi.call(); } // Después: Permite el desmontaje rateLimiter.lock(); try { // El subproceso virtual se desmonta, liberando el portador externalApi.call(); } finally { rateLimiter.unlock(); }
¿Por qué ocurre el pinning específicamente con bloques sincronizados y métodos nativos, mientras que ReentrantLock permite el desmontaje?
El pinning surge porque la JVM implementa monitores intrínsecos (synchronized) utilizando registros de monitor basados en pila de subprocesos y estructuras internas de VM a nivel de C++ que están inherentemente atadas al contexto de ejecución del subproceso físico del SO. Cuando un subproceso virtual entra en un bloque sincronizado, la JVM no puede migrar de forma segura la continuación a otro portador sin corromper el estado del monitor o violar las garantías de suceder antes a nivel nativo. Por el contrario, ReentrantLock se implementa puramente en Java sobre AbstractQueuedSynchronizer, que utiliza VarHandle y primitivas LockSupport.park en las que el programador de subprocesos virtuales se interpone, permitiendo un desmontaje y remontaje seguros entre portadores sin dependencia del estado de subprocesos nativos.
¿Cómo interactúa el pinning del subproceso portador con el robo de trabajo del ForkJoinPool para crear escenarios de posible inanición?
En condiciones normales, el ForkJoinPool asume que las tareas son internas de CPU o no bloqueantes; cuando un subproceso trabajador bloquea, compensa generando o activando trabajadores adicionales hasta el límite de paralelismo. Sin embargo, un subproceso virtual bloqueado bloquea su portador sin señalizar el mecanismo de compensación del grupo de manera efectiva. Consecuentemente, si veinte subprocesos virtuales bloquean simultáneamente veinte portadores (por ejemplo, entrando en bloques sincronizados), no quedan portadores para ejecutar los miles de subprocesos virtuales listos en la cola del programador. Esto crea una inversión de prioridades donde el trabajo desbloqueado no puede progresar a pesar de las tareas disponibles, contraviniendo efectivamente el tamaño utilizable del grupo y de forma catastrófica.
¿Puede el uso agresivo de variables ThreadLocal causar pinning de subprocesos portadores en entornos de subprocesos virtuales?
Las variables ThreadLocal no inducen el pinning porque la implementación de subprocesos virtuales migra el mapa de thread-locals entre portadores durante operaciones de montaje y desmontaje. Sin embargo, los candidatos frecuentemente pasan por alto que ThreadLocal plantea una catástrofe distinta en gestión de memoria: con millones de subprocesos virtuales de corta duración tocando thread-locals, cada subproceso portador acumula entradas en su ThreadLocalMap por cada subproceso virtual que ha albergado. Dado que estos mapas solo se limpian tras la eliminación explícita o la recolección de basura de la clave (el subproceso virtual), esto genera un crecimiento de memoria ilimitado en los subprocesos portadores de larga duración. Esto constituye efectivamente una fuga de memoria no relacionada con el pinning, pero igualmente fatal para implementaciones de subprocesos virtuales a gran escala, requiriendo la migración a ScopedValue (JEP 446) para una limpieza adecuada.