Programación en paralelo y Asíncrona en Java 8

El código en paralelo, que es código que se ejecuta en más de un hilo, fue una vez la pesadilla de muchos desarrolladores experimentados, pero Java 8 trajo muchos cambios que deberían hacer que este truco para aumentar el rendimiento sea mucho más manejable.

Flujos paralelos

Antes de Java 8 había una gran diferencia entre el código paralelo (o concurrente) y el código secuencial. También fue muy difícil depurar código no secuencial. Simplemente establecer un punto de interrupción y pasar por el flujo como lo haría normalmente, eliminaría el aspecto paralelo, que es un problema si eso es lo que está causando el error.

Afortunadamente, Java 8 nos dio transmisiones, lo mejor para los desarrolladores de Java desde bean. Si no sabe cuáles son, la API de flujo permite manejar secuencias de elementos en un asunto funcional. (Consulte nuestra comparación entre streams y LINQ de. NET aquí.) Una de las ventajas de los flujos es que la estructura del código permanece igual: ya sea secuencial o concurrente, se mantiene igual de legible.

Para hacer que su código se ejecute en paralelo, simplemente use .parallelStream() en lugar de .stream(), (o stream.parallel(), si no es el creador de la transmisión).

Pero solo porque sea fácil, no significa que el código paralelo sea siempre la mejor opción. Siempre debe considerar si tiene algún sentido usar la concurrencia para su pieza de código. El factor más importante en esa decisión será la velocidad: solo use concurrencia si hace que su código sea más rápido que su contraparte secuencial.

La pregunta de velocidad

El código paralelo obtiene su beneficio de velocidad al usar varios subprocesos en lugar del único que utiliza el código secuencial. Decidir cuántos hilos crear puede ser una pregunta complicada porque más hilos no siempre dan como resultado un código más rápido: si usas demasiados hilos, el rendimiento de tu código podría disminuir.

Hay un par de reglas que le dirán qué número de hilos elegir. Esto depende principalmente del tipo de operación que desee realizar y del número de núcleos disponibles.

Las operaciones intensivas de computación deben usar un número de subprocesos menor o igual al número de núcleos, mientras que las operaciones intensivas de IO, como copiar archivos, no tienen uso para la CPU y, por lo tanto, pueden usar un número mayor de subprocesos. El código no sabe qué caso es aplicable a menos que le digas qué hacer. De lo contrario, tendrá por defecto un número de hilos igual al número de núcleos.

Hay dos casos principales en los que puede ser útil ejecutar su código en paralelo en lugar de secuencial: tareas que consumen mucho tiempo y tareas que se ejecutan en colecciones grandes. Java 8 trajo una nueva forma de manejar esas grandes colecciones, es decir, con flujos. Los flujos tienen eficiencia incorporada por pereza: utilizan una evaluación perezosa que ahorra recursos al no hacer más de lo necesario. Esto no es lo mismo que el paralelismo, que no se preocupa por los recursos siempre y cuando vaya más rápido. Así que para colecciones grandes, probablemente no necesites paralelismo clásico.

Ir Asincrónico

Lecciones de JavaScript

Es raro que un desarrollador Java pueda decir que aprendió algo mirando JavaScript, pero cuando se trata de programación asincrónica, JavaScript realmente lo hizo bien primero. Como lenguaje fundamentalmente asincrónico, JavaScript tiene mucha experiencia con lo doloroso que puede ser cuando se implementa mal. Comenzó con devoluciones de llamada y más tarde fue reemplazado por promesas. Un beneficio importante de las promesas es que tiene dos «canales»: uno para los datos y otro para los errores. Una promesa de JavaScript podría verse algo como esto:

func.then(f1).catch(e1).then(f2).catch(e2);

Entonces, cuando la función original tiene un resultado exitoso, se llama a f1, pero si se lanzó un error, se llamará a e1. Esto podría devolverlo a la pista correcta (f2) o provocar otro error (e2). Puede pasar de la pista de datos a la pista de errores y viceversa.

La versión Java de JavaScript promises se llama CompletableFuture.

CompletableFuture

CompletableFuture implementa la interfaz Future y CompletionStage. Future ya existía antes de Java8, pero no era muy amigable para el desarrollador por sí solo. Solo se podía obtener el resultado de la computación asíncrona utilizando el método .get(), que bloqueaba el resto (haciendo que la parte asíncrona fuera bastante inútil la mayor parte del tiempo) y se necesitaba implementar cada posible escenario manualmente. Agregar la interfaz CompletionStage fue el avance que hizo que la programación asíncrona en Java fuera viable.

CompletionStage es una promesa, es decir, la promesa de que el cálculo se haga. Contiene un montón de métodos que le permiten adjuntar devoluciones de llamada que se ejecutarán al completarse. Ahora podemos manejar el resultado sin bloquear.

Hay dos métodos principales que le permiten iniciar la parte asincrónica de su código: supplyAsync si desea hacer algo con el resultado del método, y runAsync si no lo hace.

CompletableFuture.runAsync(() → System.out.println("Run async in completable future " + Thread.currentThread()));CompletableFuture.supplyAsync(() → 5);

Devoluciones de llamada

Ahora puede agregar esas devoluciones de llamada para manejar el resultado de su supplyAsync.

CompletableFuture.supplyAsync(() → 5).thenApply(i → i * 3).thenAccept(i → System.out.println("The result is " + i).thenRun(() → System.out.println("Finished."));

.thenApply es similar a la función .map para flujos: realiza una transformación. En el ejemplo anterior toma el resultado (5) y lo multiplica por 3. Luego pasará ese resultado (15) más abajo en la tubería.

.thenAccept realiza un método en el resultado sin transformarlo. Tampoco devolverá un resultado. Aquí imprimirá «El resultado es 15» en la consola. Se puede comparar con el método .foreach para flujos.

.thenRun no utiliza el resultado de la operación asíncrona y tampoco devuelve nada, solo espera para llamar a su Runnable hasta que se complete el paso anterior.

Asincronar su Asíncrono

Todos los métodos de devolución de llamada anteriores también vienen en una versión asincrónica: thenRunAsync, thenApplyAsync, etc. Estas versiones pueden ejecutarse en su propio hilo y te dan un control adicional porque puedes decirle qué ForkJoinPool usar.

Si no usas la versión asincrónica, las devoluciones de llamada se ejecutarán en el mismo subproceso.

Cuando las cosas salen mal

Cuando algo sale mal, se utiliza el método exceptionally para manejar la excepción. Puede darle un método que devuelva un valor para volver a la pista de datos o lanzar una (nueva) excepción.

….exceptionally(ex → new Foo()).thenAccept(this::bar);

Combinar y Componer

puede encadenar varios CompletableFutures con el thenCompose método. Sin él, el resultado sería anidado CompletableFutures. Esto hace que thenCompose y thenApply sean como flatMap y map para transmisiones.

CompletableFuture.supplyAsync(() -> "Hello").thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "World"));

Si desea combinar el resultado de dos CompletableFutures, se necesita un método convenientemente llamado thenCombine.

future.thenCombine(future2, Integer::sum).thenAccept(value → System.out.println(value));

Como puede ver en el ejemplo anterior, el resultado de la devolución de llamada en thenCombine se puede manejar como un CompletableFuture normal con todos sus métodos CompletionStage favoritos.

Conclusión

La programación en paralelo ya no tiene que ser un obstáculo insuperable en la búsqueda de código más rápido. Java 8 hace que el proceso sea lo más sencillo posible, de modo que cualquier pieza de código que pueda beneficiarse de él, se pueda tirar, patear y gritar en todos los hilos, hacia el futuro de múltiples núcleos que es, de hecho, solo el día de hoy. Con lo que quiero decir: es fácil de hacer, así que pruébalo y ve sus ventajas por ti mismo.



+