Programación Asíncrona: Java vs Node.js

Maria Paula Vizcaíno
Pragma
Published in
7 min readFeb 15, 2024

Actualmente contamos con un modelo de trabajo bastante efectivo que nos permite trabajar bajo las metodologías que funcionen mejor para cada pragmático.

Esto nos permite alcanzar nuestros objetivos como equipo gracias a lo valioso de la individualidad de cada eslabón.

La asincronía no solo nos ha permitido crecer como Pragma en todas sus ramas sino a que su vez ha permitido a los pragmáticos desarrolladores de software construir aplicaciones escalables y robustas.

La programación asíncrona, de manera similar a nuestra metodología de trabajo asíncrono, es un paradigma de programación en el cual las tareas y funcionalidades de un sistema se ejecutan de manera independiente sin necesidad de contar con un orden específico, permitiendo al sistema que estemos desarrollando completar la ejecución de algunas operaciones sin que otras detenidas interfieran en el proceso.

Este paradigma de programación es empleado mayormente en sistemas enfocados en operaciones de E/S (entrada/salida), donde, en lugar de esperar a que las solicitudes en cola se realicen, las van realizando de manera paralela.

La capacidad de ejecutar diversas funcionalidades de manera simultánea, sin comprometer el funcionamiento y la capacidad de respuesta del sistema, es fundamental para garantizar requisitos como eficiencia y buenos tiempos de ejecución.

Como desarrolladores tenemos la tarea de emplear las herramientas que mejor se acomoden con nuestros objetivos y recursos, por ello, es necesario que conozcamos las ventajas y desventajas que cada entorno de programación nos ofrezca.

Entre dichos entornos, dentro de Pragma, se destacan dos en particular, Java y Node.js. Así, como pragmáticos tenemos la responsabilidad de conocer las herramientas que ofrecen estos entornos para aportar nuestra milla extra sin dificultades.

Java, el lenguaje ampliamente utilizado

Como lenguaje de programación orientado a objetos, originalmente cuenta con modelo de concurrencia complejo y tendente a errores debido a su enfoque centrado en hilos. Su enfoque hacia la asincronía implica el uso de esos hilos de manera intensiva, creando y gestionándolos por medio de la clase ‘Thread’:

public class HiloPragma extends Thread {

public void run() {
System.out.println("Este es un hilo ejecutándose de manera asíncrona");
}

public static void main(String[] args) {
// Instanciamos el hilo e iniiciamos su la ejecución
HiloPragma hilo = new HiloPragma();
hilo.start();
}

}

Y por medio de la interfaz ‘Runnable’:

public class RunnablePragma implements Runnable {

public void run() {
System.out.println("Este es un hilo ejecutándose de manera asíncrona");
}

public static void main(String[] args) {
RunnablePragma runnable = new RunnablePragma();
Thread hilo = new Thread(runnable);
// Iniciamos la ejecución del hilo
hilo.start();
}

}

De esta manera podemos ejecutar funcionalidades simultáneamente, sin embargo, el manejo de una gran cantidad de hilos es compleja dando cabida a errores y problemas de concurrencia, particularmente en aplicaciones de gran tamaño, complicando el proceso de detección y corrección de errores.

Aun así, algunos de los problemas de concurrencia más comunes suelen ser la “condición de carrera”, este error es ocasionado por el choque de tareas que requieren un orden particular en su ejecución como, por ejemplo, dos o más hilos que modifican una misma variable.

Otro escenario común es el “bloqueo mutuo” que, como su nombre lo indica, sucede cuando dos o más hilos se bloquean mutuamente debido que requiere el mismo recurso, muchas veces alguno de los hilos estancado hasta que el recurso es liberado terminando en un “deadlock”.

Para abordar estos errores, Java cuenta con la capacidad de decidir si de bloqueo o no una operación deteniendo la ejecución de un hilo hasta que se complete la operación E/S, sin embargo, esto puede resultar en ineficiencia en la utilización de los recursos del sistema.

A su vez, cuenta con la capacidad de manejar excepciones, anticipando y respondiendo de manera adecuada a estas situaciones y muchas otras que pueden resultar en fallos del sistema.

En Java, el manejo de excepciones se realiza por medio del bloque ‘try-catch’ junto con el bloque opcional ‘finally’:

public class HandlerException {

public static void main(String[] args) {
try {
// Código que puede generar una excepción siendo r su resultado
...
System.out.println("El resultado de la operación es: " + r);
} catch (SomeException e) { // Captura la excepción específicando el tipo de la excepción
System.out.println("Error");
} finally {
System.out.println("El bloque finally siempre se ejecuta, incluso si se lanza una excepción");
}
}

}

Continuando con las herramientas que nos ofrece Java para la programación asíncrona, por medio del framework ‘java.util.concurrent’ cuenta con clases e interfaces para facilitar la ejecución simultánea de hilos como la clase ‘ExecutorService’ y la interfaz ‘Executor’:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {

public static void main(String[] args) \{
// Utilicemos 5 hilos en nuestro ejemplo
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i=0; i<10; i++) {
Runnable tarea = () -> {
System.out.println("Tarea asíncrona ejecutándose en el hilo: "
+ Thread.currentThread().getName());
};
// Ejecutamos la tarea de manera asíncrona
executor.execute(tarea);
}
// Cerramos el ExecutorService
executor.shutdown();
}

}

También contamos con la interfaz ‘CompletableFuture’ la cual permite verificar el estado de una tarea:

import java.util.concurrent.CompletableFuture;

public class Main {
public static void main(String[] args) {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "¡Hola pragmático!";
});

future.thenAccept(result -> {
System.out.println("Resultado recibido de manera asíncrona: " + result);
});

// Esperamos a que la tarea asincrona se complete
future.join();
}

}

Dentro de esta interfaz, desde Java 9, contamos con métodos que facilitan el manejo de las tareas flexiblemente como ‘thenApplyAsync’ o ‘thenAcceptAsync’ que permiten encadenar la ejecución de una tarea después de otra, o ‘thenComposeAsync’ que permie ejecutar una tarea en base al resultado de otra.

Además de estas clases e interfaces, Java cuenta con patrones de diseño con el fin de abstraer la complejidad de la concurrencia haciendo que el código sea más claro, entendible, extensible y mantenible, como el patrón “promise”, el “observador” o el “abstract factory”. Por ejemplo, el patrón “promise” brinda una manera de representar el posible resultado de nuestro hilo.

Para acompañar nuestras tareas asíncronas, existen las funciones callbacks que se pasan como argumentos en otras funciones y son invocadas una vez se completa un evento en específico:

import java.util.function.Consumer;

public class Main {
public static void main(String[] args) {
realizarOperacionAsincrona((r) -> {
System.out.println("Operación completada con resultado: " + r);
});
}

public static void realizarOperacionAsincrona(Consumer<String> callback) {
// Simulación de una operación asíncrona que toma tiempo
new Thread(() -> {
try {
// Simulamos una operación que toma 2 segundos
Thread.sleep(2000);
String r = "¡Operación completada!";
// Invocamos el callback con el resultado
callback.accept(r);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}

}

Un callback se define como una expresión lambda que imprime el resultado en la consola, esto permite que el código dentro del callback se ejecute una vez que se complete la operación asíncrona, sin bloquear el hilo principal de ejecución.

Estas herramientas permiten ejecutar el código de manera no secuencial reaccionando a eventos de manera flexible y eficiente, sin embargo, como desarrolladores deben tener en cuenta que el anidamiento excesivo de esta funcionalidad puede afectar el entendimiento del código.

Node.js, basado en eventos.

Node.js cuenta con una arquitectura E/S no bloqueante, es decir, las peticiones que realicemos en este entorno no bloquean el hilo principal de ejecución de nuestro programa.

Se considera que la programación asíncrona es fundamental en este entorno debido a su implementación de event loop (bucle de eventos), así, cuando una operación asíncrona entra en proceso, Node.js la encola continuando con las otras tareas previas a esta.

Una vez la operación asíncrona es completada, entra en la cola de las tareas pendientes de procesar, permitiendo un manejo de múltiples conexiones. Al igual que Java, cuenta con mecanismos como callbacks:

// Leamos un archivo por medio de callbacks
const fs = require('fs');

fs.readFile('archivo.txt', 'utf8', (error, datos) -> {
if (error) {
console.error('Error al leer el archivo:', error);
return;
}
console.log('Contenido del archivo: ', datos);
});

Cuenta con las promesas para el manejo de las operaciones, en Node.js el encadenamiento de operaciones y errores se realiza mediante métodos como ‘then()’ y ‘catch()’:

// Leamos un archivo por medio de promesas
const fs = require('fs').promises;

fs.readFile('archivo.txt', 'utf8')
.then(datos -> {
console.log('Contenido del archivo: ', datos);
})
.catch(error -> {
console.error('Error al leer el archivo: ', error);
});

Adicionalmente a estas dos estructuras, tenemos la capacidad de escribir código asíncrono de manera síncrona utilizando las palabras reservadas ‘async/await’, permitiéndonos esperar resultados en particular en nuestro programa evitando que se sobrepongan los hilos entre sí:

// Leamos un archivo por medio de async/await
const fs = require('fs').promises;

async function leerArchivo() {
try {
const datos = await fs.readfile("archivo.txt", 'utf8');
console.log('Contenido del archivo: ', datos);
} catch (error) {
console.error('Error al leer el archivo:', error);
}
}

leerArchivo();

El impacto de estos entornos.

En cuestión de rendimiento, debemos tener en cuenta que en Java el uso intensivo de hilos puede resultar en un gran consumo de recursos ya que cada hilo requiere ser almacenado para su ejecución.

Adicionalmente, la gestión manual de cada hilo puede ser propensa a errores debido a su complejidad, dificultando procesos recurrentes como el mantenimiento y la depuración del código. También puede resultar en un sistema de recurrencias complicado, limitando la escalabilidad de las aplicaciones.

La escalabilidad también se ve afectada por la creación recurrente de hilos siendo propenso a la vez a sobrecargas y congestión en el sistema. Por el lado de Node.js, el uso de un solo hilo de ejecución junto con el bucle de eventos para manejar múltiples tareas puede lograr una mayor eficiencia en el consumo de recursos.

Además, debido a su estructura no bloqueantes basada en eventos, Node.js es altamente escalable horizontalmente agregando más instancias de la aplicación distribuyendo la carga de manera equitativa siendo ideal para aplicaciones que requieran alto rendimiento.

Finalmente, emplear el entorno correcto depende de las necesidades y prioridades de la aplicación, Java al ser un lenguaje altamente utilizado cuenta con gran cantidad de bibliotecas disponibles para su implementación en pro a la programación asíncrona, pero puede no ser tan eficiente en términos de recursos afectando escenarios de alta recurrencia.

En cambio, Node.js es usualmente utilizado en servidores web o aplicaciones de chat debido a su manejo de recursos, sin embargo, al tratarse de una estructura no bloqueante, puede implicar un enfoque diferente desde nosotros, requiriendo un aprendizaje adicional en el proceso de desarrollo.

Referencias.

--

--