Table of Contents
Una introducción
La publicación de Java 19 en septiembre de este año supuso el primer lanzamiento público del esperado Proyecto Loom en el ecosistema JVM (Java Virtual Machine). Una breve descripción para los que no lo sepan: El Proyecto Loom es un esfuerzo de años de los desarrolladores del lenguaje de programación Java con el objetivo de permitir el uso de hilos virtuales en el código Java. Los hilos virtuales se denominan así porque, aunque tienen la apariencia y el comportamiento de lo que los desarrolladores consideran tradicionalmente hilos, no son hilos reales del SO (sistema operativo), sino que se agregan y ejecutan sobre dichos hilos del SO.
La principal ventaja de esto es que los hilos del sistema operativo son «pesados» y están limitados a un límite relativamente pequeño antes de que sus requisitos de memoria abrumen al sistema operativo, mientras que los hilos virtuales son «ligeros» y pueden utilizarse en cantidades mucho mayores. Aunque la funcionalidad del Proyecto Loom se encuentra todavía en fase de vista previa en la versión Java 19 – y la funcionalidad «completa» no llegará hasta al menos la versión Java 21 en 2023 (o incluso más adelante) – el entusiasmo por el Proyecto Loom es bastante grande, y ya hay movimientos en marcha en los principales ecosistemas Java para acomodar los hilos virtuales a su máximo potencial.
Comenzando a tejer
Naturalmente, la primera pregunta que uno se hace es cómo se comparan los hilos virtuales con los hilos tradicionales en el ecosistema de la JVM. Para empezar, los hilos virtuales ofrecen una escalabilidad mejorada: como se ha mencionado anteriormente, el lanzamiento de una gran cantidad de hilos tradicionales puede hacer que un programa se cuelgue por quedarse sin memoria, mientras que este límite no existe (al menos a ese nivel) para los hilos virtuales. Además, los hilos virtuales proporcionan una ventaja de rendimiento en comparación con los hilos tradicionales durante las operaciones de «alto tráfico». Para demostrarlo, he creado las siguientes pruebas JMH (Java Microbenchmark Harness):
import org.openjdk.jmh.annotations.Benchmark
import org.openjdk.jmh.annotations.BenchmarkMode
import org.openjdk.jmh.annotations.Fork
import org.openjdk.jmh.annotations.Mode
import org.openjdk.jmh.annotations.OutputTimeUnit
import org.openjdk.jmh.annotations.Param
import org.openjdk.jmh.annotations.Scope
import org.openjdk.jmh.annotations.State
import org.openjdk.jmh.infra.Blackhole
import java.util.concurrent.*
import kotlin.concurrent.thread
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Fork(1)
open class ThreadVsLoom {
@Param(value = ["10", "100", "1000", "10000"])
private var repeat: Int = 0
// Simulating work in increasingly-heavier loads
@Param(value = ["0", "10", "100"])
private var delay: Long = 0
@Benchmark
fun traditionalThreads(blackhole: Blackhole) {
(0 until repeat).map { i ->
thread {
Thread.sleep(delay)
blackhole.consume(i)
}
}.forEach { it.join() }
}
@Benchmark
fun virtualThreads(blackhole: Blackhole) {
(0 until repeat).map { i ->
Thread.startVirtualThread {
Thread.sleep(delay)
blackhole.consume(i)
}
}.forEach { it.join() }
}
}
Cuando este proyecto Kotlin basado en Maven – Gradle 7.6, que soporta Java 19, no ha sido liberado al momento de escribir este artículo – fue ejecutado en mi computadora (Windows 10 con una CPU AMD 2.30Ghz y 16GB de RAM), se produjeron los siguientes resultados:
Como muestran los resultados de la prueba, la operación de prueba tardó mucho más en ejecutarse para los hilos tradicionales en comparación con los hilos virtuales. Esto se debe al hecho de que los hilos tradicionales están «vinculados» en una relación de uno a uno con los hilos del sistema operativo, por lo que el cambio de contexto entre hilos tradicionales es una operación mucho más costosa en comparación con el cambio de contexto entre hilos virtuales; una explicación más detallada de los mecanismos para los hilos tradicionales y virtuales se puede encontrar aquí.
El elefante en la habitación: Kotlin Coroutines
Desde su lanzamiento de la versión 1.0 en otoño de 2018, la biblioteca de coroutines de Kotlin se ha establecido como una alternativa tanto a la arquitectura tradicional de multihilos de Java como al paradigma de la programación reactiva (hasta el punto de que los desarrolladores del ecosistema Spring hicieron de las coroutines un ciudadano de primera clase en la biblioteca reactiva WebFlux). Sin embargo, con la introducción gradual del Proyecto Loom, la disponibilidad de un nuevo mecanismo de multithreading que tiene más rendimiento que los hilos tradicionales plantea la cuestión de si se debe sustituir el código dependiente de las coroutinas por código que aproveche los hilos virtuales. Después de todo, las coroutinas de Kotlin provocan una «coloración de las funciones» (es decir, sólo pueden llamarse dentro de un contexto de coroutina o dentro de una función que tenga como prefijo la palabra clave «suspend») -una cuestión que no está presente en los hilos virtuales de Java- y una dependencia menos en la base de código de un proyecto hace que la tarea de mantener las dependencias actualizadas por motivos de higiene del proyecto sea un elemento menos. Ya se ha vertido mucha tinta digital sobre este tema, y el consenso parece ser «probablemente no».
Por un lado, las coroutines y los hilos virtuales de Kotlin están diseñados para tareas diferentes, a saber, «concurrencia» y «paralelismo», respectivamente. Un artículo más detallado sobre la diferencia está disponible aquí; en resumen, la «concurrencia» es la capacidad de llevar a cabo múltiples tareas en un tiempo determinado, mientras que el «paralelismo» es la capacidad de llevar a cabo múltiples tareas *al* tiempo. Por poner un ejemplo: las tareas A, B, C y D podrían llevarse a cabo de forma concurrente si existe un punto en cada una de las tareas para «suspender» esas tareas y trabajar en una tarea diferente antes de volver a la(s) tarea(s) suspendida(s); esas mismas tareas podrían llevarse a cabo de forma paralela si hay suficientes hilos para alojar y ejecutar cada una de esas tareas de forma independiente y simultánea. Dada la reducción de los costes de recursos y rendimiento que suponen los hilos virtuales para la programación paralela, esta diferencia puede llegar a ser irrelevante en el gran esquema de las cosas para la mayoría de los desarrolladores, pero la biblioteca de coroutines de Kotlin también ofrece funcionalidad adicional más allá de la simple realización de trabajo concurrente, por ejemplo, el mecanismo Flow y los canales.
¡Por la ciencia!
En cualquier caso, la disponibilidad de los hilos virtuales en Java 19 ofrece la oportunidad de realizar comparaciones entre hilos virtuales y coroutines de Kotlin en pruebas de rendimiento similares a las pruebas entre hilos tradicionales y virtuales descritas anteriormente. Sin embargo, es posible ir más allá: la presencia de una implementación de ExecutorService diseñada para hilos virtuales (es decir, Executors.newVirtualThreadPerTaskExecutor()) presenta una oportunidad: dado que se puede convertir una implementación de ExecutorService en un contexto de despacho para las coroutinas de Kotlin a través de la función de extensión ExecutorService.asCoroutineDispatcher(), ¿por qué no intentar «cruzar los flujos» y combinar las funcionalidades de los hilos virtuales y las coroutinas? Para probar esto, sería necesario asegurarse primero de que el enfoque híbrido seguiría garantizando la seguridad de los hilos. Para verificar esto, el enfoque híbrido tendría que ser sometido a una serie de pruebas que reflejen los enfoques que los autores de Kotlin recomiendan para mantener la seguridad de los hilos:
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.*
import java.util.concurrent.atomic.*
import kotlin.coroutines.CoroutineContext
private const val ROUNDS = 5
private const val REPETITIONS = 10000
private const val DELAY_AMT = 10L
fun main() {
runControlTest()
runPoolTest("Naive") { coroutineContext ->
var counter = 0
runBlocking(coroutineContext) {
repeat(REPETITIONS) {
launch {
delay(DELAY_AMT)
counter++
}
}
}
counter
}
runPoolTest("Atomic Integer") { coroutineContext ->
val counter = AtomicInteger(0)
runBlocking(coroutineContext) {
repeat(REPETITIONS) {
launch {
delay(DELAY_AMT)
counter.incrementAndGet()
}
}
}
counter.get()
}
runPoolTest("Confinement") { coroutineContext ->
val counterContext = newSingleThreadContext("CounterContext")
var counter = 0
runBlocking(coroutineContext) {
repeat(REPETITIONS) {
launch {
withContext(counterContext) {
counter++
}
}
}
}
counter
}
runPoolTest("Mutex") { coroutineContext ->
val mutex = Mutex()
var counter = 0
runBlocking(coroutineContext) {
repeat(REPETITIONS) {
launch {
mutex.withLock {
counter++
}
}
}
}
counter
}
}
private fun runControlTest() {
executeTest("Control") {
val counter = AtomicInteger(0)
runBlocking {
repeat(REPETITIONS) {
launch {
delay(DELAY_AMT)
counter.incrementAndGet()
}
}
}
counter.get()
}
}
private fun runPoolTest(title: String, block: (CoroutineContext) -> Int) {
Executors.newVirtualThreadPerTaskExecutor().use { threadPool ->
executeTest(title) {
block.invoke(threadPool.asCoroutineDispatcher())
}
}
}
private fun executeTest(title: String, block: () -> Int) {
println("Test: $title")
val successes = (0 until ROUNDS).sumOf { i ->
val result = block.invoke()
println("Round ${i + 1} result: $result")
if (result == REPETITIONS) 1 else 0L
}
println("Successes: $successes/$ROUNDS\n")
}
La ejecución de esta prueba proporcionó resultados que indicaban que la seguridad de los hilos se seguía manteniendo en el enfoque híbrido:
Test: Control
Round 1 result: 10000
Round 2 result: 10000
Round 3 result: 10000
Round 4 result: 10000
Round 5 result: 10000
Successes: 5/5
Test: Naive
Round 1 result: 9934
Round 2 result: 9753
Round 3 result: 9712
Round 4 result: 9128
Round 5 result: 9319
Successes: 0/5
Test: Atomic Integer
Round 1 result: 10000
Round 2 result: 10000
Round 3 result: 10000
Round 4 result: 10000
Round 5 result: 10000
Successes: 5/5
Test: Confinement
Round 1 result: 10000
Round 2 result: 10000
Round 3 result: 10000
Round 4 result: 10000
Round 5 result: 10000
Successes: 5/5
Test: Mutex
Round 1 result: 10000
Round 2 result: 10000
Round 3 result: 10000
Round 4 result: 10000
Round 5 result: 10000
Successes: 5/5
Así, se podría proceder a las pruebas de rendimiento:
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.openjdk.jmh.annotations.Benchmark
import org.openjdk.jmh.annotations.BenchmarkMode
import org.openjdk.jmh.annotations.Fork
import org.openjdk.jmh.annotations.Level
import org.openjdk.jmh.annotations.Mode
import org.openjdk.jmh.annotations.OutputTimeUnit
import org.openjdk.jmh.annotations.Param
import org.openjdk.jmh.annotations.Scope
import org.openjdk.jmh.annotations.Setup
import org.openjdk.jmh.annotations.State
import org.openjdk.jmh.annotations.TearDown
import org.openjdk.jmh.infra.Blackhole
import java.util.concurrent.*
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Fork(1)
open class LoomVsCoroutines {
@Param(value = ["10", "100", "1000", "10000", "100000"])
private var repeat: Int = 0
// Simulating work in increasingly-heavier loads
@Param(value = ["0", "10", "100"])
private var delay: Long = 0
private lateinit var executorService: ExecutorService
private lateinit var executorCoroutineDispatcher: ExecutorCoroutineDispatcher
@Setup(Level.Iteration)
fun setup() {
executorService = Executors.newVirtualThreadPerTaskExecutor()
executorCoroutineDispatcher = executorService.asCoroutineDispatcher()
}
@TearDown(Level.Iteration)
fun teardown() {
executorService.close()
}
@Benchmark
fun loom(blackhole: Blackhole) {
(0 until repeat).map { i ->
executorService.submit {
Thread.sleep(delay)
blackhole.consume(i)
}
}.forEach { it.get() }
}
@Benchmark
fun coroutines(blackhole: Blackhole) {
runBlocking {
(0 until repeat).map { i ->
launch {
delay(delay)
blackhole.consume(i)
}
}.forEach { it.join() }
}
}
@Benchmark
fun hybrid(blackhole: Blackhole) {
runBlocking(executorCoroutineDispatcher) {
(0 until repeat).map { i ->
launch {
delay(delay)
blackhole.consume(i)
}
}.forEach { it.join() }
}
}
}
Al ejecutarlo en mi ordenador, se produjeron los siguientes resultados:
En general, el rendimiento se mantiene similar entre los tres enfoques hasta que la cantidad de procesos lanzados alcanza los seis dígitos, después de lo cual el mecanismo de hilos virtuales empieza a ser notablemente más lento que las coroutinas de Kotlin, mientras que el enfoque híbrido se comportó entre los enfoques de coroutinas e hilos virtuales en las pruebas más pesadas. Al volver a ejecutar la prueba «más pesada» de 100ms de retraso para 100000 procesos y adjuntar el perfilador GC (recolector de basura) a las pruebas JMH (es decir, pasando la opción -prof gc a las pruebas) se obtuvo la siguiente información:
Benchmark (delay) (repeat) Mode Cnt Score Error Units
LoomVsCoroutines.coroutines 100 100000 avgt 5 185.955 ± 2.496 ms/op
LoomVsCoroutines.coroutines:·gc.alloc.rate 100 100000 avgt 5 215.787 ± 3.041 MB/sec
LoomVsCoroutines.coroutines:·gc.alloc.rate.norm 100 100000 avgt 5 44184945.652 ± 580.547 B/op
LoomVsCoroutines.coroutines:·gc.churn.G1_Eden_Space 100 100000 avgt 5 215.239 ± 4.958 MB/sec
LoomVsCoroutines.coroutines:·gc.churn.G1_Eden_Space.norm 100 100000 avgt 5 44073520.476 ± 1389017.407 B/op
LoomVsCoroutines.coroutines:·gc.churn.G1_Survivor_Space 100 100000 avgt 5 0.209 ± 0.191 MB/sec
LoomVsCoroutines.coroutines:·gc.churn.G1_Survivor_Space.norm 100 100000 avgt 5 42882.682 ± 39067.969 B/op
LoomVsCoroutines.coroutines:·gc.count 100 100000 avgt 5 45.000 counts
LoomVsCoroutines.coroutines:·gc.time 100 100000 avgt 5 377.000 ms
LoomVsCoroutines.loom 100 100000 avgt 5 236.616 ± 22.316 ms/op
LoomVsCoroutines.loom:·gc.alloc.rate 100 100000 avgt 5 371.661 ± 34.181 MB/sec
LoomVsCoroutines.loom:·gc.alloc.rate.norm 100 100000 avgt 5 96711951.023 ± 127890.736 B/op
LoomVsCoroutines.loom:·gc.churn.G1_Eden_Space 100 100000 avgt 5 373.982 ± 50.011 MB/sec
LoomVsCoroutines.loom:·gc.churn.G1_Eden_Space.norm 100 100000 avgt 5 97301115.701 ± 6349254.933 B/op
LoomVsCoroutines.loom:·gc.churn.G1_Survivor_Space 100 100000 avgt 5 1.096 ± 1.248 MB/sec
LoomVsCoroutines.loom:·gc.churn.G1_Survivor_Space.norm 100 100000 avgt 5 284628.786 ± 317457.123 B/op
LoomVsCoroutines.loom:·gc.count 100 100000 avgt 5 54.000 counts
LoomVsCoroutines.loom:·gc.time 100 100000 avgt 5 822.000 ms
LoomVsCoroutines.hybrid 100 100000 avgt 5 197.307 ± 1.643 ms/op
LoomVsCoroutines.hybrid:·gc.alloc.rate 100 100000 avgt 5 516.078 ± 4.407 MB/sec
LoomVsCoroutines.hybrid:·gc.alloc.rate.norm 100 100000 avgt 5 112129742.369 ± 245613.881 B/op
LoomVsCoroutines.hybrid:·gc.churn.G1_Eden_Space 100 100000 avgt 5 518.893 ± 79.958 MB/sec
LoomVsCoroutines.hybrid:·gc.churn.G1_Eden_Space.norm 100 100000 avgt 5 112736312.220 ± 16726375.440 B/op
LoomVsCoroutines.hybrid:·gc.churn.G1_Survivor_Space 100 100000 avgt 5 6.895 ± 4.176 MB/sec
LoomVsCoroutines.hybrid:·gc.churn.G1_Survivor_Space.norm 100 100000 avgt 5 1497801.004 ± 895068.779 B/op
LoomVsCoroutines.hybrid:·gc.count 100 100000 avgt 5 68.000 counts
LoomVsCoroutines.hybrid:·gc.time 100 100000 avgt 5 690.000 ms
Los resultados sugieren que, si bien la mayor rotación de objetos es un factor para los hilos virtuales en comparación con las coroutinas, es probable que haya otros factores en juego (por ejemplo, el código para la gestión de hilos virtuales que podría optimizarse más), ya que el enfoque híbrido de hilos virtuales y coroutinas tenía una tasa de asignación de GC significativamente más alta que el enfoque de hilos virtuales, y sin embargo su rendimiento era significativamente más rápido que este último en cargas más pesadas. Desgraciadamente, el perfilador JFR (Java Flight Recorder) no pudo proporcionar ninguna información, ya que el archivo .jfr producido para el enfoque de hilos virtuales tenía un tamaño de más de doscientos megabytes (comparado con poco más de un megabyte para cada uno de los enfoques de corutinas e híbridos), aunque el hecho de que hubiera tal discrepancia entre el archivo del enfoque de hilos virtuales y los archivos de los dos enfoques restantes sugeriría que hay algunas ineficiencias en el código de hilos virtuales que los desarrolladores del Proyecto Loom deberían abordar.
Reflexiones de despedida
A pesar del rendimiento más lento de los hilos virtuales en comparación con las coroutinas de Kotlin, es importante recordar que el código del Proyecto Loom es muy nuevo y «verde» en comparación con la biblioteca de coroutinas de Kotlin. La versión más reciente de Java (versión 19) debutó la funcionalidad estrictamente como una característica de vista previa – y se someterá al menos a otra versión de Java (o más) en modo de vista previa antes de que finalmente se actualice a la producción – lo que significa que todavía hay mucho trabajo por hacer, incluyendo la recepción de comentarios de la comunidad de Java y la rectificación de cualquier problema que identifiquen. Esto significa que el rendimiento de la funcionalidad de los hilos virtuales está destinado a mejorar en el futuro, incluso en comparación con las coroutines de Kotlin. En cualquier caso, los hilos virtuales proporcionarán una herramienta más para los desarrolladores en el ecosistema de la JVM, y será muy interesante ver cómo crece y evoluciona esta funcionalidad en los próximos años.
Author
-
Software engineer with more than 10 years of experience working with different technologies such as Java, Docker, Kubernetes, React, CDK, Kotlin.... With high ability to deal with different environments and technologies.
Ver todas las entradas