🌐Desarrollo

Concurrencia en programación: qué es y cómo evitar errores en tu aplicación

Felipe••23 min de lectura
Entiende concurrencia, paralelismo y async/await con ejemplos en TypeScript, un simulador visual y estrategias para coordinar Node.js y PostgreSQL.

Tu API recibe una petición para cargar un perfil. Consulta la base de datos, pide los últimos pedidos a otro servicio y obtiene las preferencias del usuario. Mientras espera esas respuestas, llegan más peticiones. Si cada espera inmovilizara toda la aplicación, una base de datos lenta dejaría haciendo fila incluso a quienes solo quieren consultar información que ya está disponible.

La concurrencia permite organizar varias tareas para que progresen durante intervalos de tiempo superpuestos. Una tarea puede estar esperando mientras otra utiliza la CPU. Eso no exige que dos instrucciones se ejecuten exactamente al mismo tiempo.

Esta capacidad también introduce una pregunta menos cómoda: ¿qué ocurre si dos peticiones intentan modificar el mismo dato? Vamos a seguir ese recorrido: aprovechar las esperas, reconocer cuándo necesitamos paralelismo y proteger los recursos que varias operaciones comparten.

Primero, qué es lo que se está ejecutando

Un programa es el conjunto de instrucciones. Un proceso es una instancia en ejecución, con memoria y recursos como archivos o conexiones abiertas. Puedes iniciar dos procesos a partir del mismo programa y cada uno tendrá su propio estado local.

Dentro de un proceso hay uno o más hilos, o threads: secuencias de ejecución que el sistema operativo puede planificar. Comparten recursos del proceso, aunque cada hilo mantiene su propio contexto de ejecución. Una tarea, en cambio, es una unidad de trabajo de nuestra aplicación: atender una petición, consultar un servicio o generar un informe. Una tarea no equivale necesariamente a un proceso ni a un hilo. Microsoft: procesos e hilos.

En la API del ejemplo, una petición puede ser una tarea compuesta por varias operaciones. El modo de organizarlas cambia lo que sucede:

OrganizaciónQué ocurreEjemplo
SecuencialUna tarea termina antes de iniciar la siguienteConsultar el perfil y, al recibirlo, consultar los pedidos
ConcurrenteVarias tareas permanecen en curso durante intervalos superpuestosIniciar ambas consultas y atender sus respuestas cuando lleguen
ParalelaVarias operaciones se ejecutan efectivamente al mismo tiempoDos hilos calculan partes de un informe en distintos núcleos

Una persona cocinando puede alternar entre preparar una salsa y cortar verduras mientras el agua hierve. Está coordinando tareas concurrentes. Si otra persona corta las verduras mientras la primera prepara la salsa, también hay trabajo en paralelo. La analogía ayuda con la organización; no describe literalmente los hilos ni la CPU.

Quién reparte el tiempo de CPU

La multiprogramación permite mantener varios programas disponibles para ejecución y aprovechar la CPU cuando uno debe esperar. El scheduler, o planificador del sistema operativo, decide qué hilo listo para trabajar recibe tiempo de procesador. En sistemas modernos puede interrumpir uno para dar paso a otro; no depende únicamente de que el programa ceda voluntariamente. Microsoft: multitarea.

En un cambio de contexto, el sistema conserva el estado necesario para retomar un hilo y restaura el de otro. Este trabajo tiene un costo: multiplicar los hilos no multiplica gratis la capacidad de cómputo. Microsoft: cambios de contexto.

Con un único recurso de ejecución puedes intercalar trabajo y tener concurrencia. Varios núcleos permiten además ejecutar trabajo simultáneamente, si la aplicación puede repartirlo. Los procesadores lógicos que expone el hardware tampoco equivalen necesariamente a núcleos físicos independientes. Para decidir una arquitectura, importa qué recursos consume el trabajo, no solo cuántos hilos puedes crear.

Esperar una respuesta no debería inmovilizar toda la API

Síncrono y bloqueante están relacionados, pero describen cosas distintas. Una llamada síncrona entrega su resultado o error antes de devolver el control. Una interfaz asíncrona permite recibir el resultado después. Una operación bloquea un hilo si lo mantiene ocupado o esperando de modo que no puede atender otro trabajo.

Sumar dos números de forma síncrona es perfectamente razonable. Leer un archivo grande con una API síncrona dentro del manejador de cada petición puede detener el hilo principal durante una espera mucho mayor. Una API no bloqueante evita mantener ese hilo esperando a que el recurso esté listo; no implica que toda la operación sea instantánea o gratuita. Node.js: operaciones bloqueantes y no bloqueantes.

En JavaScript hay varias maneras de expresar la continuación del trabajo. Un callback es una función que entregamos para que otro código la invoque; puede invocarse de forma síncrona o asíncrona, según la API. Una promesa representa un resultado eventual. async/await permite escribir la continuación de una promesa de forma parecida a una secuencia habitual:

function esperar(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function consultar() {
  console.log('Inicio de la consulta');
  await esperar(100);
  console.log('Respuesta disponible');
}

const pendiente = consultar();
console.log('El resto del programa puede continuar');

pendiente.catch(console.error);

La segunda línea que aparece es El resto del programa puede continuar. await suspende la continuación de consultar; no suspende todo el proceso ni crea un hilo. La función empieza ejecutándose normalmente hasta llegar a la suspensión. Cuando puede reanudarse, su continuación vuelve a necesitar tiempo de ejecución. MDN: funciones async.

El temporizador representa aquí una espera. En una operación real de red intervienen el sistema operativo, el dispositivo y el servidor remoto. No hace falta dedicar un hilo de JavaScript a quedarse mirando cada conexión. Si necesitas afianzar esa sintaxis, el artículo sobre promesas en JavaScript desarrolla sus estados y encadenamiento.

Cómo encaja esto en Node.js

El call stack registra las llamadas que se están ejecutando. El event loop coordina cuándo atender trabajo pendiente, como callbacks asociados a temporizadores e I/O. Node.js organiza este trabajo en fases y colas; no hay una única fila universal donde entren indistintamente promesas, timers y cualquier evento.

Las reacciones de promesas y las continuaciones de await se procesan como microtareas, en puntos de control entre ejecuciones. Un temporizador habilita un callback después de un umbral: setTimeout(fn, 0) no significa «ejecuta ahora». El código que ya está trabajando puede retrasarlo. El orden entre ciertos callbacks también depende del contexto, por lo que no conviene trasladar una regla simplificada del navegador a todas las fases de Node.js. Node.js: event loop y temporizadores.

Dentro de un mismo agente de JavaScript, un trabajo se ejecuta hasta completarse antes de que otro lo intercale. Eso ayuda a razonar sobre bloques síncronos, pero una función asíncrona puede repartirse en varias continuaciones. También es posible impedir que avance otro trabajo encadenando microtareas sin descanso. MDN: modelo de ejecución. Puedes seguir el orden con más detalle en call stack y event loop.

Un hilo de JavaScript no significa un proceso de un solo hilo

Node.js utiliza libuv para parte de su infraestructura de I/O. Muchas operaciones de red se coordinan con mecanismos del sistema operativo. Otras operaciones, como determinadas tareas del sistema de archivos y resolución de nombres, utilizan el worker pool de libuv. No es correcto decir que cada fetch crea un hilo en ese pool. libuv: diseño e I/O.

Además, algunas APIs de criptografía y compresión delegan trabajo al pool. Su capacidad es limitada: saturarlo puede retrasar operaciones que comparten esos trabajadores, aunque el event loop siga disponible. Node.js: no bloquear el event loop ni el worker pool.

Los Worker Threads son otra herramienta: permiten ejecutar JavaScript en otros hilos. Pueden ayudar con trabajo CPU-bound, cuyo costo dominante es calcular, transformar o comprimir, en vez de esperar. Para trabajo I/O-bound, dominado por esperas de red o disco, suele ser más apropiado usar las APIs asíncronas existentes. Crear un worker por cada petición puede costar más que el trabajo que se pretende repartir; para tareas frecuentes conviene evaluar un pool acotado. Node.js: Worker Threads.

Este ejemplo pequeño permite ver por qué async no resuelve por sí solo el problema de CPU:

async function calcular() {
  let total = 0;

  for (let i = 0; i < 1_000_000; i++) {
    total += i;
  }

  return total;
}

setTimeout(() => console.log('Timer atendido'), 0);

calcular().then(total => console.log('Cálculo terminado:', total));
console.log('Después de invocar calcular');

El bucle sigue ejecutándose en el hilo que invocó la función. El mensaje Después de invocar calcular aparece después de terminar ese bucle, aunque la reacción .then llegue más tarde. Si ese cálculo durara segundos, las demás peticiones atendidas por el mismo event loop sufrirían la espera. Envolverlo en Promise.all tampoco lo distribuye entre núcleos.

Tres consultas independientes: medir qué cambia

Vamos a comparar las dos formas de iniciar trabajo. Usaremos temporizadores de 600, 300 y 450 ms para representar tres servicios independientes. No hay llamadas de red ni una prueba de capacidad de servidores: queremos aislar el efecto de solapar esperas.

En una carpeta nueva, con Node.js 22 o superior y pnpm disponibles:

mkdir laboratorio-concurrencia
cd laboratorio-concurrencia
pnpm init
pnpm add -D typescript @types/node

Guarda este programa en concurrencia.mts. La extensión .mts indica que es un módulo de TypeScript que compilaremos a ESM:

import { setTimeout as esperar } from 'node:timers/promises';
import { performance } from 'node:perf_hooks';
import assert from 'node:assert/strict';

type Servicio = { nombre: string; demora: number };

const servicios: Servicio[] = [
  { nombre: 'perfil', demora: 600 },
  { nombre: 'pedidos', demora: 300 },
  { nombre: 'preferencias', demora: 450 },
];

async function consultar(servicio: Servicio): Promise<string> {
  console.log(`Inicio: ${servicio.nombre}`);
  await esperar(servicio.demora);
  console.log(`Fin: ${servicio.nombre}`);

  return servicio.nombre;
}

async function comparar() {
  console.log('\nSecuencial');
  let inicio = performance.now();
  const secuencial: string[] = [];

  for (const servicio of servicios) {
    secuencial.push(await consultar(servicio));
  }

  console.log(`Total: ${(performance.now() - inicio).toFixed(1)} ms`);

  console.log('\nConcurrente');
  inicio = performance.now();
  const concurrente = await Promise.all(servicios.map(consultar));

  console.log(`Total: ${(performance.now() - inicio).toFixed(1)} ms`);
  console.log('Resultados:', concurrente);
  assert.deepEqual(concurrente, secuencial);
}

await comparar();

Compila y ejecuta:

pnpm exec tsc concurrencia.mts --target ES2022 --module NodeNext \
  --types node --outDir dist --strict --skipLibCheck --noEmitOnError

node dist/concurrencia.mjs

En una ejecución local en macOS con Node.js, observamos lo siguiente:

ModoOrden de inicioOrden de finalizaciónTiempo total observado
Secuencialperfil → pedidos → preferencias, después de cada respuestaperfil → pedidos → preferencias1355,3 ms
Concurrenteperfil → pedidos → preferencias, sin esperar sus respuestaspedidos → preferencias → perfil602,1 ms

Son mediciones de una ejecución, no un benchmark general. El sistema operativo, los timers y la propia salida por consola introducen variaciones. El total secuencial se aproxima a la suma de las esperas; al solaparlas, se aproxima a la más larga. No hemos acelerado ninguna consulta individual.

map(consultar) invoca las funciones e inicia las operaciones. Promise.all reúne sus resultados: no crea hilos ni es quien mágicamente inicia cualquier tarea. El array final conserva el orden de entrada aunque las promesas se resuelvan en otro orden. MDN: Promise.all.

Este cambio tiene sentido si las consultas son independientes y existe capacidad para atenderlas. Si necesitas el identificador que devuelve la primera para construir la segunda, hay una dependencia que impide iniciarlas juntas. Si compiten por CPU, una conexión única o un servicio saturado, más concurrencia puede aumentar la espera en vez de reducirla.

Qué pasa si una consulta falla

La promesa de Promise.all se rechaza cuando se produce el primer rechazo. Las demás operaciones iniciadas no se cancelan automáticamente. Si una ya envió un correo o creó una reserva, rechazar el conjunto no deshace ese efecto.

Cuando necesitas inspeccionar todos los resultados, Promise.allSettled espera a que cada operación termine, con éxito o error. Por ejemplo, este fragmento independiente devuelve un resultado de cada tipo:

Promise.allSettled([
  Promise.resolve({ servicio: 'perfil', disponible: true }),
  Promise.reject(new Error('Servicio de recomendaciones no disponible')),
]).then(resultados => {
  for (const resultado of resultados) {
    if (resultado.status === 'fulfilled') {
      console.log('Disponible:', resultado.value);
    } else {
      console.log('No se pudo completar una consulta');
    }
  }
});

La decisión depende del contrato: una recomendación opcional puede fallar sin impedir mostrar el perfil; una comprobación de autorización no debería tratarse igual. allSettled tampoco impone límites de concurrencia ni timeouts. MDN: Promise.allSettled.

Mira cómo se superponen las tareas

El simulador mantiene la misma escala horizontal al cambiar de modo. Prueba primero Secuencial, después Concurrente con límite 2 y finalmente con límite 4. Una tarea nueva comienza cuando queda un hueco; no necesita esperar a que termine todo un lote.

Simulación didáctica, no medición real. No ejecuta consultas ni trabajo de CPU: calcula una planificación con las duraciones indicadas. No envía ni almacena datos.

El simulador requiere JavaScript. Los ejemplos y resultados del artículo siguen disponibles sin él.

Las barras representan operaciones en curso, incluidas sus esperas. Que se solapen no demuestra paralelismo de CPU. El modelo omite fallos, dependencias entre tareas, saturación y costo de coordinación. Sirve para comprender una decisión de planificación, no para predecir la latencia de producción.

Dos peticiones, un contador y una actualización perdida

Solapar lecturas independientes era sencillo porque ninguna modificaba lo que necesitaban las otras. El problema cambia cuando hay estado mutable compartido: datos que varias operaciones pueden leer y modificar.

Una condición de carrera aparece cuando la corrección depende del orden o del momento en que se intercalan operaciones. No basta con que dos respuestas lleguen en distinto orden: si el programa admite ambos órdenes y conserva sus reglas, no hay un fallo por ese motivo.

Añade el siguiente bloque al final del mismo archivo concurrencia.mts. Utiliza esperar y assert, que ya importamos:

let contador = 0;

async function incrementarConError() {
  const anterior = contador;
  await esperar(10);
  contador = anterior + 1;
}

async function incrementarSinIntercalacion() {
  await esperar(10);

  // Lectura y escritura juntas, sin ceder el control entre ellas.
  contador += 1;
}

async function comprobarContador() {
  await Promise.all([incrementarConError(), incrementarConError()]);
  console.log('\nContador con error:', contador);
  assert.equal(contador, 1);

  contador = 0;

  await Promise.all([
    incrementarSinIntercalacion(),
    incrementarSinIntercalacion(),
  ]);

  console.log('Contador corregido:', contador);
  assert.equal(contador, 2);
}

await comprobarContador();

Al compilar y ejecutar de nuevo, la prueba local produjo 1 en la versión defectuosa y 2 en la corregida. Ambas llamadas defectuosas leen el cero antes de suspenderse. Después escriben 0 + 1. La segunda escritura reemplaza el resultado de la primera: una actualización perdida.

sequenceDiagram
    participant A as Petición A
    participant C as Contador
    participant B as Petición B
    A->>C: Leer 0
    B->>C: Leer 0
    Note over A,B: Ambas ceden el control durante la espera
    A->>C: Escribir 1
    B->>C: Escribir 1
    Note over C: Resultado 1, aunque hubo dos incrementos

La sección crítica es el tramo que debe proteger la relación entre leer y modificar ese estado. Una operación es atómica respecto a otras cuando estas no pueden observarla o intercalarse con ella como si fueran pasos independientes. Hay que indicar el ámbito de esa garantía.

En la corrección, la lectura y escritura de una variable numérica local suceden juntas, sin await, dentro del mismo hilo de JavaScript. Ninguna otra continuación de ese hilo entra en medio. No hemos convertido += en una instrucción atómica entre hilos ni resuelto una actualización remota de base de datos. Si la operación necesita esperar entre la lectura y la escritura, mover el await puede no ser posible: necesitamos coordinación.

Esta prueba provoca deliberadamente la intercalación en vez de esperar a que un fallo aparezca por azar. Para casos más complejos, conviene sincronizar las pruebas para que ambas operaciones alcancen el punto conflictivo y comprobar la regla final: por ejemplo, «dos incrementos deben sumar dos». Repetir una prueba muchas veces ayuda a explorar órdenes, pero que pase no demuestra ausencia de carreras. El artículo sobre race conditions y TOCTOU examina otra variante: validar un recurso y utilizarlo cuando ya pudo cambiar.

Coordinar: decidir quién puede avanzar y sobre qué recurso

Un lock es un mecanismo para restringir accesos incompatibles. Un mutex aplica exclusión mutua: una sola operación entra a la sección protegida cada vez. Todas las rutas que modifican ese recurso deben respetarlo; bloquear una función no protege otra que escribe por fuera.

Un semáforo administra un número de permisos. Con cuatro permisos puedes limitar a cuatro las operaciones activas contra un servicio. Eso controla carga, pero no protege necesariamente un contador compartido: si las cuatro lo modifican sin coordinación, el error sigue siendo posible. Microsoft documenta estas distinciones para sus objetos de mutex y semáforo. Un mutex asíncrono en JavaScript suele suspender continuaciones mediante promesas, en lugar de bloquear el hilo del event loop.

No necesitamos construir una biblioteca de sincronización para cada aplicación. La elección depende del recurso y de su dueño:

NecesidadMecanismo que conviene evaluarCompromiso
Una modificación local cada vezMutex asíncrono o cola con un consumidorLas operaciones esperan; todos los accesos deben usarlo
Hasta N solicitudes activasSemáforo o pool con límiteAún necesitas limitar la cola pendiente y el tiempo de espera
Actualizar memoria compartida entre workersOperaciones de Atomics sobre tipos compatiblesUna operación atómica aislada no vuelve atómico un algoritmo completo
Proteger un registro persistenteActualización condicional o transacción en la base de datosPuede haber conflictos y reintentos
Procesar trabajo desacopladoCola de mensajes con consumidores acotadosHay que contemplar fallos, redelivery y orden

Atomics no se aplica a cualquier objeto de JavaScript. Es una API para operaciones sobre vistas tipadas compatibles, especialmente útil con memoria compartida. Para muchos diseños, enviar mensajes entre trabajadores y reducir el estado compartido es más sencillo que coordinar escrituras sobre él.

La coordinación también puede fallar

Un deadlock ocurre si A retiene un recurso y espera otro que tiene B, mientras B espera el de A. Ambos impiden que el otro continúe. Reducir la sección protegida y adquirir recursos en un orden consistente ayuda a evitar esos ciclos. PostgreSQL detecta deadlocks y aborta una transacción; no es una invitación a dejar que ocurran sistemáticamente. PostgreSQL: deadlocks.

La starvation, o inanición, es distinta: el sistema continúa trabajando, pero una tarea no consigue turno de forma persistente. Puede ocurrir por prioridades o políticas injustas de admisión. Los límites de espera y una política de cola adecuada ayudan; un lock no garantiza equidad por el hecho de llamarse lock.

Libera los recursos incluso cuando hay excepciones y evita retener un bloqueo mientras esperas un servicio externo sin límite. Un timeout de la espera tampoco prueba que la operación remota se haya detenido: antes de liberar un permiso o reintentar, necesitas entender qué se canceló realmente.

Cuando el estado vive en PostgreSQL

Un mutex del proceso podría proteger nuestro contador local. Ahora despliega dos instancias de la API: cada una tiene su propio mutex y ambas pueden entrar a la vez. Si el recurso común está en PostgreSQL, la garantía debe llegar hasta PostgreSQL.

Una transacción agrupa operaciones que se confirman o revierten juntas. ACID reúne cuatro propiedades: atomicidad, para que se aplique la unidad completa o ninguna; consistencia, para preservar las reglas que efectivamente imponemos; aislamiento, para delimitar cómo interactúan transacciones concurrentes; y durabilidad, para conservar cambios confirmados según las garantías del sistema. La base de datos no adivina todas las reglas de negocio. PostgreSQL: transacciones y restricciones.

PostgreSQL utiliza MVCC, control de concurrencia multiversión: las consultas trabajan con versiones visibles según su snapshot. Las lecturas ordinarias no tienen que esperar a cada escritura como si toda la tabla estuviera cerrada. Esto reduce interferencias, pero no elimina conflictos entre escrituras ni todos los bloqueos. PostgreSQL: MVCC.

Llevar la comprobación a la operación que modifica

Supongamos este inventario ficticio. Es un fragmento SQL independiente para una base de pruebas:

CREATE TABLE inventario_demo (
  id integer PRIMARY KEY,
  unidades integer NOT NULL CHECK (unidades >= 0),
  version integer NOT NULL DEFAULT 0
);

INSERT INTO inventario_demo (id, unidades) VALUES (1, 1);

UPDATE inventario_demo
SET unidades = unidades - 1,
    version = version + 1
WHERE id = 1 AND unidades > 0
RETURNING unidades, version;

La condición y el descuento forman parte del mismo UPDATE. En Read Committed, si otra transacción actualiza esa fila primero, PostgreSQL espera y vuelve a evaluar la condición sobre la versión actualizada. Con una sola unidad, dos descuentos de este tipo no deberían confirmar dos ventas: uno obtiene una fila y el otro ninguna. La aplicación debe comprobar el resultado y no confirmar una reserva cuando no se actualizó ninguna fila. PostgreSQL: UPDATE y comportamiento de Read Committed.

Esto protege esa regla de inventario; no constituye una implementación completa de reservas, cobros o recuperación ante fallos.

Otra estrategia es el optimistic locking: leer una versión y condicionar la escritura a que siga siendo la misma. Este fragmento parametrizado supone $1 como identificador y $2 como versión previamente leída:

UPDATE inventario_demo
SET unidades = unidades - 1,
    version = version + 1
WHERE id = $1 AND version = $2 AND unidades > 0
RETURNING unidades, version;

Si no vuelve una fila, puede haber cambiado la versión, agotado el stock o desaparecido el recurso. No declares éxito: vuelve a leer y decide. Este patrón supone que todas las escrituras relevantes incrementan la versión. «Optimista» significa detectar el conflicto en vez de reservar de antemano el acceso; el motor igualmente puede usar bloqueos internos para escribir.

El pessimistic locking reserva antes el recurso, por ejemplo con SELECT ... FOR UPDATE dentro de una transacción. Es útil cuando varias decisiones deben basarse en una fila que no puede cambiar entre pasos. Hay que usar la misma conexión para toda la transacción y mantenerla corta: esperar una respuesta HTTP con la fila bloqueada puede alargar la fila de peticiones que dependen de ella. PostgreSQL: bloqueos de filas.

Una transacción necesita el aislamiento adecuado

En PostgreSQL, Read Committed es el nivel predeterminado: cada sentencia ve un snapshot nuevo. Repeatable Read mantiene una vista estable para la transacción, aunque todavía admite anomalías de serialización. Serializable exige un efecto equivalente al de algún orden secuencial de las transacciones confirmadas; puede rechazar operaciones para conservar esa propiedad. Read Uncommitted se comporta como Read Committed en PostgreSQL. Niveles de aislamiento.

Por eso envolver un «leer, calcular y escribir un valor absoluto» en BEGIN y COMMIT no lo hace correcto automáticamente. Debes elegir una operación atómica, un bloqueo o un nivel de aislamiento que proteja la regla necesaria.

Ante un error de serialización 40001, el reintento debe abarcar toda la transacción, incluidas las lecturas y decisiones. No basta con repetir la última sentencia con valores obsoletos. Limita los intentos y aplica esperas; no reintentes indefinidamente cualquier error. Tampoco repitas sin control un cobro externo ejecutado dentro de ese recorrido: el rollback de PostgreSQL no revierte una llamada HTTP. PostgreSQL: manejo de fallos de serialización.

Varios procesos: la concurrencia cruza la red

Con varias instancias aparecen demoras de red, reinicios y respuestas que se pierden después de haber aplicado una operación. Un proceso puede creer que falló un pago cuando el proveedor ya lo procesó. Un segundo intento debe tener una identidad que permita reconocerlo; el artículo sobre idempotencia en APIs REST desarrolla esa coordinación persistente.

Un lock distribuido intenta coordinar a varios participantes mediante un sistema compartido. Habitualmente incorpora una concesión temporal o lease: si el dueño desaparece, el permiso puede expirar. Pero un proceso pausado puede perder el lock y después continuar trabajando como si aún lo tuviera.

Renovar la concesión no elimina por sí solo ese riesgo. Un fencing token es un número creciente asociado a cada adquisición: el recurso protegido debe comprobarlo y rechazar escrituras de propietarios antiguos. No basta con generar el número en el cliente. La documentación de Redis advierte expresamente sobre expiraciones y fencing. No conviene resolverlo añadiendo una clave con TTL y suponiendo exclusión perfecta. Redis: locks distribuidos y límites de consistencia.

Para renovar un token compartido, por ejemplo, un mutex local puede evitar trabajo duplicado en un proceso, pero no coordina otra réplica. Si ambas renovaciones pudieran invalidarse mutuamente, hay que aplicar una garantía común y contemplar qué pasa si el dueño falla. Cuando una actualización condicional en el almacenamiento ya resuelve la regla, introducir otro sistema de locks puede agregar más problemas de los que elimina.

Colas, límites y presión de entrada

Una cola permite separar la llegada de trabajo de su procesamiento. No hace infinito al consumidor. Hay que acotar cuántos mensajes procesa a la vez, reconocerlos cuando corresponda y admitir que algunos puedan entregarse de nuevo. Los consumidores idempotentes evitan repetir efectos ante reintentos; la guía de RabbitMQ y colas de mensajes explica ese recorrido.

Control de concurrencia limita operaciones activas a la vez. Rate limiting limita admisiones en un intervalo de tiempo. Diez peticiones por segundo pueden dejar muchas operaciones simultáneamente pendientes si cada una tarda mucho. En sentido inverso, un límite de cuatro operaciones activas puede generar muchas peticiones por segundo si terminan muy rápido.

La backpressure comunica o impone al productor que debe reducir el ritmo porque el consumidor no puede absorberlo. En Node.js, los streams ofrecen mecanismos concretos para dejar de escribir temporalmente y continuar cuando hay capacidad. Ignorar esa señal puede acumular memoria. En una API o cola de trabajos, el equivalente exige límites de pendientes, plazos y una política de rechazo o espera. Node.js: backpressure en streams.

Por eso, pasar a Promise.all el resultado de aplicar map(enviar) a millones de elementos puede ser un problema antes de recibir la primera respuesta: ya creaste una cantidad enorme de trabajo pendiente. Usa un consumidor o pool que inicie tareas conforme obtiene capacidad; para entradas grandes, limita también lo que cargas en memoria.

Elegir qué acelerar y qué proteger

Antes de añadir concurrencia, identifica qué esperas solapar y qué regla debe mantenerse cierta. Consultar tres fuentes independientes pide una solución distinta de descontar la última unidad de un producto.

Si el problema es esperar I/O, empieza por APIs asíncronas y límites razonables. Si es CPU, mide y evalúa trabajadores o procesos. Si varias operaciones deciden sobre el mismo estado, coloca la garantía donde realmente se comparte: un bloque local sin intercalaciones, una transacción, una escritura condicional o un consumidor responsable del recurso.

Distingue también rendimiento, como latencia y trabajo completado por unidad de tiempo; disponibilidad, la capacidad de seguir atendiendo solicitudes; y consistencia, la preservación de las reglas de los datos. Rechazar o retrasar una reserva cuando no puedes garantizar el stock puede ser una decisión correcta, aunque no produzca el número más alto de respuestas exitosas.

La pregunta útil no es cuántas promesas caben en un array. Es cuánto trabajo puede sostener tu sistema, qué ocurre cuando falla una parte y cómo compruebas que las operaciones siguen dejando los datos en un estado válido.

Referencias para profundizar

#concurrencia#paralelismo#Node.js#TypeScript#async await#race condition#PostgreSQL

Artículos relacionados