Inicio / Artículos

Espera versus cálculo: concurrencia y paralelismo en Node.js y Go

Ejemplos ejecutables en Node.js y Go que separan la concurrencia del paralelismo, mostrando cuándo son suficientes las promesas, cuándo se necesitan hilos de trabajo y en qué difieren las gorutinas.

Editor: REACTAPP.TOP8 min de lectura1761 palabras

Fuentes: Concurrency vs parallelism, explained with Node.js and Go

Tratar la concurrencia y el paralelismo como sinónimos conduce a errores reales, como una API que se detiene bajo carga. Comparar Node.js y Go hace que la diferencia sea tangible: Node.js ofrece concurrencia sin esfuerzo alguno y obliga al desarrollador a crear el paralelismo por sí mismo, mientras que Go proporciona ambos mediante una sola palabra clave. Con pequeños programas ejecutables en cada lenguaje, aprenderá cuál es el más adecuado para su carga de trabajo.

Dos definiciones y una cocina

La concurrencia significa que un único proceso maneja varias tareas al pasar a otra cada vez que la actual está en espera. El paralelismo significa que varios procesos ejecutan varias tareas literalmente al mismo tiempo.

Imagínese una cocina. Un solo cocinero con tres sartenes representa concurrencia: el cocinero remueve la primera sartén y, mientras la segunda hierve, corta verduras para la tercera. En ningún momento ocurren dos cosas al mismo tiempo, pero el cocinero nunca está ocioso. Si se ponen tres cocineros en la cocina, uno por cada sartén, eso es paralelismo.

Node.js: la concurrencia viene integrada

Un hilo, muchas solicitudes en ejecución

Node.js ejecuta tu JavaScript en un único hilo. Aún así, maneja bien el tráfico intenso porque la mayor parte del trabajo del servidor consiste en esperar a una base de datos, un archivo u otro servicio. Node transfiere esa espera al sistema operativo (y, para algunas operaciones, al pool de hilos interno de libuv) y continúa con el siguiente fragmento de JavaScript. Ese mecanismo de programación es el bucle de eventos. Para conocer más en profundidad sobre este funcionamiento, consulta cómo se integran libuv, el bucle de eventos y el pool de hilos.

El siguiente programa simula tres solicitudes, cada una de las cuales dura un segundo, utilizando temporizadores envueltos en promesas:

function fakeRequest(name, ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`${name} done`), ms);
  });
}

async function main() {
  console.time("total");

  const results = await Promise.all([
    fakeRequest("users", 1000),
    fakeRequest("orders", 1000),
    fakeRequest("products", 1000),
  ]);

  console.log(results);
  console.timeEnd("total");
}

main();

Dado que Promise.all inicia los tres procesos al mismo tiempo y los temporizadores se agotan de forma simultánea, console.timeEnd muestra aproximadamente un segundo en lugar de tres. Un único hilo tenía tres tareas en ejecución y nunca permaneció inactivo esperando a ninguna de ellas. Eso es concurrencia.

Qué ocurre con trabajos reales en la CPU

El bucle de eventos solo puede superponer tiempos de espera. Cuando una tarea es puramente computacional, no hay nada que entregar al sistema operativo. Este programa suma un rango amplio tres veces:

function countTo(n) {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += i;
  return sum;
}

console.time("total");
countTo(2_000_000_000);
countTo(2_000_000_000);
countTo(2_000_000_000);
console.timeEnd("total");

El tiempo total es aproximadamente tres veces mayor que el de una sola llamada. Envolver las llamadas en Promise.all no ayudaría, ya que cada countTo se ejecuta de forma síncrona desde el inicio hasta el final antes de que pueda ejecutarse cualquier otra cosa. En términos culinarios, el único cocinero debe terminar cada sartén por turnos. Peor aún, en un servidor el proceso no puede responder a ninguna solicitud HTTP mientras se ejecuta el bucle.

Obtener paralelismo con hilos de trabajo

Usar más de un núcleo de CPU requiere más de un hilo, y la herramienta de Node para ello es el módulo worker_threads. Primero, el trabajador: un archivo separado que lee sus datos de entrada desde workerData, realiza el cálculo y envía el resultado de vuelta al proceso padre.

// worker.js
const { parentPort, workerData } = require("worker_threads");

let sum = 0;
for (let i = 0; i < workerData; i++) sum += i;

parentPort.postMessage(sum);

El archivo principal envuelve a cada trabajador en una promesa que se resuelve con el evento message del trabajador y se rechaza con error, luego inicia tres trabajadores al mismo tiempo:

// main.js
const { Worker } = require("worker_threads");
const path = require("path");

function countInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, "worker.js"), {
      workerData: n,
    });
    worker.on("message", resolve);
    worker.on("error", reject);
  });
}

async function main() {
  console.time("total");

  await Promise.all([
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
  ]);

  console.timeEnd("total");
}

main();

En una máquina con tres o más núcleos, el tiempo total disminuye a aproximadamente la duración de una sola llamada. Tres cocineros, tres sartenes, trabajando simultáneamente. El hilo principal también permanece disponible para atender solicitudes.

Pero fíjense en lo que se necesita: un archivo dedicado, un canal de mensajes y un envoltorio de promesa. En Node.js, el paralelismo es algo que se construye manualmente. En entornos de producción también se evitaría crear un nuevo trabajador por tarea, ya que iniciar uno implica un costo real; un grupo reutilizable de trabajadores es la solución habitual, explicada en nuestra guía sobre hilos y grupos de trabajadores.

Go: una palabra clave para ambos

Goroutines en lugar de un bucle de eventos

En Go, añadir el prefijo go al inicio de una llamada a función inicia una goroutine. Cada goroutine comienza con un stack de solo unos pocos kilobytes que crece según sea necesario, por lo que ejecutar miles de ellas es algo habitual. El planificador del entorno de ejecución de Go asigna las goroutines a hilos del sistema operativo, y por defecto permite que se ejecuten simultáneamente tantas como núcleos de CPU, un límite controlado por GOMAXPROCS.

Aquí está nuevamente la simulación con tres solicitudes, utilizando sync.WaitGroup para esperar a que todas las goroutines hayan finalizado:

package main

import (
 "fmt"
 "sync"
 "time"
)

func fakeRequest(name string, wg *sync.WaitGroup) {
 defer wg.Done()
 time.Sleep(time.Second)
 fmt.Println(name, "done")
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for _, name := range []string{"users", "orders", "products"} {
  wg.Add(1)
  go fakeRequest(name, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

Cada goroutine llama a wg.Done() mediante defer, y wg.Wait() se bloquea hasta que todas hayan terminado. El resultado coincide con la versión de Promise.all de Node.js: aproximadamente un segundo en total.

El trabajo limitado por la CPU no necesita mecanismos adicionales

Ahora, el ejemplo de conteo. Mientras que Node.js necesitaba un archivo de trabajo y el intercambio de mensajes, la versión en Go mantiene exactamente la misma estructura que antes:

package main

import (
 "fmt"
 "sync"
 "time"
)

func countTo(n int, wg *sync.WaitGroup) {
 defer wg.Done()
 sum := 0
 for i := 0; i < n; i++ {
  sum += i
 }
 fmt.Println("sum:", sum)
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for i := 0; i < 3; i++ {
  wg.Add(1)
  go countTo(2_000_000_000, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

En una máquina multi-núcleo, esto se completa en aproximadamente el mismo tiempo que una sola llamada. El planificador encontró tres goroutines ejecutables y las ejecutó en tres hilos al mismo tiempo. Usted expresó el trabajo de forma concurrente, y el entorno de ejecución lo convirtió en ejecución paralela.

Esto ilustra la distinción que hace el famoso discurso de Rob Pike “La concurrencia no es el paralelismo”: la concurrencia es cómo estructura un programa como piezas que se ejecutan de forma independiente, y el paralelismo es lo que el entorno de ejecución puede hacer con esa estructura cuando hay múltiples núcleos disponibles.

El precio de la memoria compartida

Cada worker de Node.js tiene su propio heap aislado y no puede leer las variables de los demás; se comunican al pasar mensajes. (Si realmente necesita memoria compartida en Node.js, existe SharedArrayBuffer con Atomics, lo cual plantea las mismas preocupaciones de sincronización descritas a continuación.)

Por el contrario, las goroutines comparten la misma memoria. Si dos goroutines incrementan un contador sin coordinación, se produce una carrera de datos y el valor final será incorrecto. La solución típica en Go es pasar datos a través de canales y utilizar un mutex solo cuando compartir estado sea inevitable.

Este grupo de workers muestra el enfoque basado en canales. Tres goroutines leen tareas de un canal y escriben valores duplicados en otro:

package main

import "fmt"

func worker(jobs <-chan int, results chan<- int) {
 for j := range jobs {
  results <- j * 2
 }
}

func main() {
 jobs := make(chan int, 100)
 results := make(chan int, 100)

 for w := 1; w <= 3; w++ {
  go worker(jobs, results)
 }

 for j := 1; j <= 9; j++ {
  jobs <- j
 }
 close(jobs)

 for r := 1; r <= 9; r++ {
  fmt.Println(<-results)
 }
}

No se comparte nada excepto los canales en sí, por lo que no son necesarias las cerraduras, y al agregar trabajadores el pool puede utilizar más núcleos. Los resultados llegan en el orden en que terminan los trabajadores, lo cual es esperado en este patrón. Ejecutar el programa con go run -race main.go activa el detector de carreras, que informa sobre cualquier acceso no sincronizado accidental a la memoria compartida.

Los dos modelos comparados

  • Unidad de concurrencia: Node.js utiliza promesas y callbacks programados en un bucle de eventos; Go utiliza goroutines.
  • Paralelismo: en Node.js es opcional, a través de worker_threads o del módulo cluster (que ejecuta múltiples procesos); en Go está activo por defecto, con el scheduler utilizando todos los núcleos.
  • Trabajo intensivo en CPU: en Node.js bloquea el bucle de eventos a menos que se mueva a un worker; en Go es simplemente otra goroutine.
  • Compartir datos: los workers de Node.js intercambian mensajes; las goroutines utilizan canales o memoria compartida protegida por mutex.
  • Modo típico de fallo: en Node.js, un bucle de eventos bloqueado; en Go, carreras de datos.
  • Elegir la herramienta adecuada: esperar o calcular?

    Una sola pregunta resuelve la mayoría de los casos: ¿la parte lenta de su carga de trabajo está esperando o calculando?

    Si está esperando (en una base de datos, el sistema de archivos o una llamada de red), lo único que necesita es concurrencia. En Node.js eso significa promesas y async/await. En Go significa goroutines, y el planificador detiene aquellas que están bloqueadas para que no consuman CPU.

    Si se trata de cálculos (redimensionar imágenes, generar hashes, analizar un archivo muy grande), se necesita paralelismo. En Node.js, transfiera el trabajo a hilos de trabajo o utilice cluster para distribuir el manejo de solicitudes entre procesos. En Go, inicie goroutines y asegúrese de que intercambien datos a través de canales o protejan el estado compartido con un mutex.

    El paralelismo está limitado por el hardware: en un contenedor de un solo núcleo, tres tareas dependientes de la CPU no se ejecutan más rápido que secuencialmente en ninguno de los lenguajes.

    Puntos clave

    • La concurrencia se refiere a la estructura: organizar el trabajo de modo que nada quede inactivo mientras algo espera. El paralelismo se refiere al hardware: cuántas tareas realmente se ejecutan al mismo tiempo.
    • En Node.js, Promise.all superpone las operaciones de espera pero no ayuda en los cálculos síncronos, lo que bloquea cada solicitud en el hilo.
  • El paralelismo en Node.js requiere hilos de trabajo explícitos (idealmente agrupados) o múltiples procesos.
  • El planificador de Go convierte automáticamente las gorutinas concurrentes en ejecución paralela, pero la memoria compartida hace que las carreras de datos sean algo contra lo que hay que protegerse; prefiera los canales y utilice el detector de carreras.
  • Lecturas relacionadas