¿Cómo actúa el framework Fork/Join con diferentes configuraciones?
Fork/Join: The Fork Awakens

Al igual que la próxima entrega de Star Wars, el paralelismo en Java 8 ha despertado mucho entusiasmo mezclado con criticismo. El azúcar sintáctico de los flujos paralelos trajo aparejado cierto bullicio, casi como lo hizo el nuevo sable de luz que vimos en el adelanto. Ya que ahora contamos con varias formas de llevar el paralelismo a cabo en Java, quisimos evaluar los beneficios y los peligros, en lo que hace al desempeño, del procesamiento en paralelo. tras realizar más de 260 pruebas, los datos arrojaron alguna información que vamos a compartir contigo en este artículo.

 

ExecutorService vs. Fork/Join Framework vs. flujos paralelos

Un largo tiempo atrás, en una galaxia muy, muy distante… quiero decir, unos 10 años atrás, la concurrencia únicamente estaba disponible para Java por medio de librerías de terceros. Entonces apareció Java 5 y presentó la librería java.util.concurrent como parte del lenguaje, con una fuerte influencia de Doug Lea. Se hizo disponible el ExecutorService, que nos ofreció una forma sencilla de manejar los thread pools. Por supuesto, java.util.concurrent siguió evolucionando y para Java 7 se presentó el framework Fork/Join, una mejora sobre la base de los thread pools del ExecutorService. Con los flujos (streams) de Java 8, se nos brindó una forma fácil de utilizar el Fork/Join, que aún resulta algo enigmático para muchos desarrolladores. Veamos qué obtenemos de una comparación entre ellos.

Tomamos 2 tareas, una de mucho consumo de CPU y otra de E/S intensiva, y probamos 4 escenarios distintos con la misma funcionalidad básica. Otro factor importante es el número de hilos que usamos para cada implementación, así que también hicimos pruebas con eso. La máquina que empleamos contaba con 8 núcleos, así que realizamos variaciones entre 4, 8, 16 y 32 hilos para obtener una noción general de dónde nos llevan los resultados. Para cada una de las tareas, probamos asimismo una solución de un único hilo, que no verás en los gráficos ya que, bueno, ejecutarla tomó mucho pero mucho tiempo. Para averiguar más exactamente cómo realizamos las pruebas, lee la sección de “Condiciones de prueba”, que aparece más abajo. Ahora sí… ¡allá vamos!

Indexado de un archivo de 6GB con 5.8M de líneas de texto

En esta prueba, generamos un archivo de texto gigantesco, y creamos implementaciones similares para el procedimiento de indexado. He aquí los resultados:

File Indexing Test Results

** Ejecución con un solo hilo: 176.267 mseg, o sea casi 3 minutos.

** Ten en cuenta que el gráfico comienza a los 20.000 milisegundos.

1. Muy pocos hilos dejarán al procesador sin ser aprovechado, mientras demasiados producirán overhead

Lo primero que puedes ver en el gráfico es la forma que los resultados están empezando a tomar: solamente con estas 4 referencias, ya puedes tener una impresión de cómo se comporta cada implementación. El momento crucial aquí está entre los 8 y los 16 hilos, ya que algunos hilos se bloquean en E/S de archivos, y agregar más hilos que núcleos fue de ayuda para poder aprovecharlos mejor. Cuando probamos con 32 hilos, sin embargo, el desempeño empeoró por el overhead adicional.

2. ¡Los flujos paralelos son lo mejor! Casi 1 segundo mejor que su perseguidor más cercano (el uso directo de Fork/Join)

Dejando el azúcar sintáctico de lado (¡los Lambdas! No mencionamos los Lambdas), vimos como los flujos paralelos funcionaron mucho mejor que las implementaciones Fork/Join y ExecutorService. En 24,33 segundos, 6GB de texto fueron indexados. Aquí puedes confiar en que Java te brinde los mejores resultados.

3. Pero… los flujos paralelos también fueron los peores: la única variación que demoró más de 30 segundos

Este es otro recordatorio de cómo los flujos paralelos te pueden enlentecer. Digamos que esto ocurre en máquinas que ya estén corriendo aplicaciones multihilo. Teniendo menos hilos disponibles, puede que usar directamente Fork/Join sea mejor que inclinarse por los flujos paralelos: existe una diferencia de 5 segundos, que se convierte en una penalización de un 18% al compararlos.

4. No te inclines por el tamaño por defecto del pool teniendo en cuenta la E/S

Al usar el tamaño por defecto del pool para los flujos paralelos, es decir el número de núcleos que tiene la máquina (que en este caso son 8), obtuvimos resultados 2 segundos más lentos que con la versión de 16 hilos. Eso es una penalización del 7%, únicamente por ir con el tamaño por defecto del pool. La razón por la que esto ocurre está vinculada con el bloqueo de hilos de E/S. Ya que hay aún más espera, incorporar más hilos nos permite extraer más de los núcleos del procesador involucrados, mientras otros hilos esperan a ser programados en vez de permanecer ociosos.

¿Cómo cambiar el tamaño por defecto del pool Fork/Join para flujos paralelos? Puedes cambiar el tamaño del pool Fork/Join common usando un argumento de la JVM:


-Djava.util.concurrent.ForkJoinPool.common.parallelism=16

(Por defecto, todas las tareas del Fork/Join usan un pool common static del tamaño de la cantidad de núcleos que tú tienes. El beneficio aquí reside en que reduces el uso de recursos al recuperar a los hilos para otras tareas cuando no están siendo usados.)

O… puedes usar este truco y correr flujos paralelos dentro de un pool Fork/Join personalizado. Esto invalida el uso por defecto del pool Fork/Join common y te deja usar un pool que tú hayas configurado. Bastante engañoso. Para las pruebas, usamos el pool common.

5. El desempeño con único hilo fue 7,25x peor que el mejor de los resultados

El paralelismo brindó una mejora del 7,25x, y teniendo en cuenta que la máquina tenía 8 núcleos, ¡se acercó bastante a la predicción teórica del 8x! El resto lo podemos atribuir al overhead. Ahora que ya aclaramos eso, debemos decir que aún la más lenta de las implementaciones de paralelismo que probamos, que en este caso fue la de flujos paralelos con 4 núcleos (30,24 seg), funcionó 5.8x mejor que la solución de un único hilo (176,27 seg).

¿Qué pasa si sacamos la E/S de la ecuación? Verifiquemos si un número es primo

Para la siguiente ronda de pruebas, eliminamos a la E/S y examinamos cuánto tiempo nos llevaría determinar si cierto número, bastante grande, es primo o no. ¿Qué tan grande? De 19 dígitos. 1.530.692.068.127.007.263, o en otras palabras: un trillón, setenta y nueve mil trescientos sesenta y cuatro billones, treinta y ocho mil cuarenta y ocho millones, trescientos cinco mil treinta y tres. ¡Uff! ¡Permíteme que tome un poco de aire! En cualquier caso, no hemos usado ninguna optimización excepto por calcular su raíz cuadrada, así que verificamos todos los números pares aun cuando nuestro enorme número no es divisible por 2, así lo hacemos procesar por más tiempo. Aviso aguafiestas: es primo, así que todas las implementaciones ejecutaron la misma cantidad de cálculos.

 

He aquí el resultado:

Prime Number Test Results

** Ejecución con un solo hilo: 118.127 mseg, o sea casi 2 minutos.

** Ten en cuenta que el gráfico comienza a los 20.000 milisegundos.

 

1. Menores diferencias entre los 8 y los 16 hilos

A diferencia de la prueba de E/S, aquí no tenemos llamados de E/S, así que el desempeño con 8 y con 16 hilos fue más bien similar, excepto con la solución Fork/Join. Terminamos realizando varias pruebas más para asegurarnos de que estuviéramos obteniendo resultados correctos aquí, tras ver esta “anomalía”, pero tuvimos resultados similares vez tras vez. Nos encantaría escuchar tus comentarios sobre esto en la sección de comentarios que aparece más abajo.

2. Los mejores resultados son similares para todos los métodos

Vemos que todas las implementaciones obtienen mejores resultados de alrededor de 28 segundos. No importa de qué forma les abordemos, los resultados vuelven a ser los mismos. Esto no significa que seamos indiferentes sobre qué método usar. Fíjate en el siguiente punto.

3. Los flujos paralelos manejan la sobrecarga de hilos mejor que otras implementaciones

Esta es la parte más interesante. Con esta prueba, una vez más vemos que los mejores resultados con 16 hilos vienen con el uso de los flujos paralelos. Lo que es más aún, en esta versión, usar flujos paralelos fue una buena decisión para todas las variaciones de número de hilos.

4. El desempeño con un único hilo fue 4.2x peor que el mejor de los resultados

Además, el beneficio de usar paralelismo al ejecutar tareas computacionales intensivas es casi 2 veces peor que en la prueba de E/S con la E/S de los archivos. Esto tiene sentido pues es una prueba que demanda mucho al procesador, a diferencia de la previa, donde podíamos obtener un beneficio extra de recortar el tiempo que nuestros núcleos pasaban esperando por hilos trancados con E/S.

Conclusión

Yo recomendaría ir a la fuente para averiguar más sobre cuándo usar los flujos paralelos y aplicar un juicio sano y cuidadoso cada vez que se trabaje con paralelismo en Java. El mejor camino a tomar sería realizar pruebas similares a estas en un entorno de pruebas, donde puedas probar y tener una noción de a qué te estás enfrentando. Los factores que debes tener presentes son, por supuesto, el hardware sobre el cuál ejecutarás (y harás las pruebas), y el cantidad total de hilos en tu aplicación. Esto incluye el pool Fork/Join common y el código en el que otros miembros de tu equipo estén trabajando.  Así que intenta mantener esos aspectos bajo control y obtén un vistazo de tu aplicación antes de añadirle paralelismo por tu cuenta.

Condiciones de prueba

Para realizar estas pruebas, usamos una instancia EC2 c3.2xlarge con 8 vCPUs y 15GB de RAM. El hecho de que sean vCPUs significa que implementan Hyper-threading, así que en realidad hay cuatro núcleos físicos, cada uno de los cuales actúa como si se tratara de dos. En lo que concierne al planificador del sistema operativo, aquí tenemos 8 núcleos. Para hacerlo lo más justo posible, cada implementación fue ejecutada 10 veces y tomamos el tiempo de ejecución promedio desde la segunda hasta la novena. Eso son 260 pruebas… ¡Uff!! Otra cosa importante era el tiempo de procesamiento. Elegimos tareas que tomaran algo más de 20 segundos en ser procesadas, de forma que las diferencias fueran más fáciles de apreciar y menos afectadas por factores externos.

¿Y ahora qué?

Los resultados brutos están disponibles por aquí, y el código, en GitHub. Siéntete libre de experimentar con él y déjanos saber qué resultados obtienes. Si tienes alguna otra explicación o conocimiento sobre los resultados que no hayamos incluido más arriba, nos encantaría leerlos y añadirlos al artículo.

OverOps te muestra cuándo y por qué tu código falla en producción. Detecta excepciones capturadas y no capturadas, errores de log y HTTP, y te muestra el código y el estado de variable, tal como estaba en el momento en que ellos ocurrieron. Obtén información procesable, resuelve errores complejos en minutos. Instalación en 5 minutos. Pensado para producción.

solve-button (1)

Further reading:

threadfred

Thread Magic Tricks: 5 Things You Never Knew You Can Do with Java Threads – read more 

02 (1)

5 Error Tracking Tools Java Developers Should Know – read more

Alex is the Director of Product Marketing at OverOps, helping software engineers deliver reliable applications by identifying, preventing, and resolving critical issues in new releases. In the past, he worked with early stage startups and was an organizer and speaker in developer meetups.