Curso exprés · No. 16
Un programa que hace una cosa a la vez es simple y a menudo demasiado lento. Por eso hacemos que el software haga malabares con muchas tareas, o las ejecute en muchos cores a la vez. De ahí viene la velocidad — y de ahí viene también toda una familia de errores extraños, todos con la misma raíz: dos piezas de trabajo tocando los mismos datos en el mismo instante.
Solo lo esencial · Una imagen por idea · Aprende las palabras
Dos palabras se usan como si significaran lo mismo, y no es así. Una trata de hacer malabares; la otra, de tener muchas manos. Distinguirlas es la base de todo lo demás.
Concurrency es hacer malabares; parallelism es tener muchas manos
Un cocinero atendiendo cuatro platos — corta, luego remueve, luego revisa el horno, cambiando rápido — frente a cuatro cocineros haciendo cada uno un plato en el mismo instante.
Concurrency es ocuparse de muchas tareas alternando entre ellas — un solo trabajador avanzando en varias cosas, nunca de verdad en el mismo instante. Parallelism es muchas tareas ejecutándose literalmente en el mismo instante, en varios trabajadores. Un cocinero haciendo malabares con cuatro platos es concurrente; cuatro cocineros es paralelo. A menudo se combinan, pero son ideas distintas — concurrency es una forma de estructurar el trabajo; parallelism es una forma de ejecutarlo.
Un core puede ser concurrente; muchos cores son paralelos
Un solo empleado de taquilla puede atender una cola entera ocupándose por turnos de los pasos rápidos de cada persona; una fila de empleados atiende a varias personas en el mismísimo segundo.
Un core de procesador hace una cosa a la vez, pero puede cambiar entre tareas tan rápido que parece hacer muchas — eso es concurrency en un solo core. El parallelism de verdad necesita varios cores ejecutándose realmente a la vez. Las máquinas modernas tienen muchos cores, así que el software real suele hacer ambas cosas: varias tareas en curso (concurrente), algunas ejecutándose de verdad juntas (paralelo). Saber cuál tienes explica qué aceleraciones son siquiera posibles.
Por qué molestarse siquiera
Un camarero que tomara el pedido completo de una mesa, lo sirviera y solo entonces saludara a la siguiente mesa dejaría una sala llena de gente esperando sin nada.
Hacer una cosa a la vez desperdicia enormes cantidades de espera. Mientras una tarea espera — una respuesta de red, un disco, un usuario — el trabajador podría estar avanzando en otra. Concurrency recupera ese tiempo muerto; parallelism añade caballos de fuerza para el trabajo pesado. Juntas son la razón de que una app siga respondiendo mientras carga, y de que un cálculo grande pueda usar todos los cores. El coste es la complejidad de la que trata el resto de este curso.
Concurrency es hacer malabares con muchas tareas alternando; parallelism es ejecutarlas en el mismo instante en muchos cores. Ideas distintas — una estructura el trabajo, la otra lo ejecuta.
Para ejecutar cosas de forma concurrente, divides el trabajo en líneas de ejecución separadas. Hay dos tipos principales, y la diferencia entre ellos se reduce sobre todo a una cosa: si comparten memoria.
Un process es un programa aislado
Dos cocinas separadas en dos edificios separados — cada una con sus propios ingredientes, sus propias encimeras, incapaces de tocar los suministros de la otra sin una llamada de por medio.
Un process es un programa en ejecución con su propia memoria privada, aislado de otros processes. Dos processes no pueden corromper por accidente los datos del otro porque no comparten ninguno — para comunicarse, tienen que pasar mensajes a propósito. Ese aislamiento hace a los processes seguros y robustos, pero más pesados: arrancar uno cuesta más, y hablar entre ellos es más lento. Piensa en los processes como programas separados y amurallados.
Un thread comparte memoria con sus hermanos
Varios cocineros en una sola cocina, compartiendo las mismas encimeras e ingredientes — rápidos de coordinar, pero pueden ir a por el mismo cuchillo en el mismo momento.
Un thread es una línea de ejecución dentro de un process, y los threads del mismo process comparten la misma memoria. Eso los hace ligeros y rápidos de coordinar — pero también es el origen de casi todos los errores de concurrency, porque dos threads pueden tocar los mismos datos a la vez. La memoria compartida es un poder y un peligro: comunicación barata, colisiones peligrosas. Casi todo este curso trata de ese peligro.
Cambiar entre tareas tiene un coste
Un trabajador haciendo malabares con varios encargos tiene que soltar uno, recordar exactamente dónde iba y tomar otro — y todo ese soltar y retomar lleva tiempo real.
Cuando un core cambia de una tarea a otra, realiza un context switch: guarda dónde estaba y carga el estado de la siguiente tarea. Es rápido pero no gratis, y hacerlo demasiado a menudo — miles de tareas diminutas agitándose — desperdicia tiempo en puro sobrecoste. Por eso «basta con crear más threads» no es automáticamente más rápido: pasado cierto punto, el malabarismo en sí cuesta más de lo que ahorra.
Los processes están aislados, con memoria privada y mensajería segura pero pesada. Los threads comparten memoria — ligeros y rápidos, y la raíz de casi todos los errores de concurrency.
Gran parte del tiempo, un programa no está calculando — está esperando. Cómo gestionas esa espera, de forma blocking o asíncrona, decide si un trabajador puede seguir ocupado o se queda parado.
Una llamada blocking se detiene y espera
Estar de pie en un mostrador mientras el empleado va al fondo a buscar tu pedido, sin hacer nada hasta que vuelve — no puedes ayudar a nadie más mientras estás ahí parado.
Una llamada blocking es aquella en la que el trabajador se detiene y espera el resultado antes de hacer cualquier otra cosa — le preguntas a la base de datos y te quedas ahí parado hasta que responde. Mientras está bloqueado, ese trabajador no logra nada. Para un solo paso rápido está bien; pero si te bloqueas en cosas lentas — red, disco, otros servicios — desperdicias justo el tiempo que podrías haber dedicado a otro trabajo.
Async deja que un trabajador siga ocupado
Coger un número en una charcutería con mucha gente: en vez de quedarte parado, te pones con otros recados y vuelves cuando llaman a tu número.
El trabajo asíncrono (no bloqueante) le da la vuelta: inicias una operación lenta y, en vez de esperar, te vas a hacer otra cosa, recogiendo el resultado cuando está listo. Las palabras clave async y await en muchos lenguajes expresan justo esto — «inicia esto y déjame continuar; vuelve cuando termine». Un trabajador puede tener cientos de operaciones lentas en curso a la vez, ocupado en vez de parado. Así es como un solo thread atiende a miles de conexiones en espera.
El event loop hace malabares con la espera
Un único recepcionista, muy organizado, que atiende cada llamada, lanza las consultas lentas y atiende a quien esté listo a continuación — nunca bloqueado en ninguna de ellas.
Async suele ejecutarse sobre un event loop: un trabajador que inicia tareas y, cada vez que una cosa lenta termina, ejecuta el pequeño fragmento de código que esperaba por ella, y luego sigue. Parece que muchas cosas pasan a la vez, pero es un trabajador cambiando rápido entre tareas listas. Este es el modelo detrás de entornos como JavaScript y Node — asombrosamente eficiente haciendo malabares con la espera, en un solo thread.
El I/O-bound adora async; el CPU-bound necesita parallelism
Un trabajo que es sobre todo esperar entregas se beneficia de un mejor malabarista; un trabajo que es sobre todo cargar peso necesita más músculo, no mejor planificación.
La herramienta correcta depende de qué está esperando tu trabajo. El trabajo I/O-bound — sobre todo esperar a la red, al disco o a otros servicios — es perfecto para async: un trabajador hace malabares con toda la espera. El trabajo CPU-bound — cálculo pesado que mantiene un core totalmente ocupado — no recibe ninguna ayuda de async (no hay tiempo muerto que recuperar) y en su lugar necesita parallelism entre cores. Confundir cuál tienes es por lo que algunas «aceleraciones» no hacen nada.
Blocking se detiene y espera; async inicia la cosa lenta y sigue ocupado. Async gana para esperar (I/O-bound); el parallelism real gana para el cálculo pesado (CPU-bound).
Aquí está el corazón de por qué la concurrency es difícil. En el momento en que dos cosas pueden tocar los mismos datos a la vez, puedes tener un error intermitente, invisible en las pruebas y enloquecedor de reproducir.
Una race condition: dos manos, un objeto
Dos personas yendo a por la última galleta en el mismo instante — ambas ven que está ahí, ambas la agarran, y lo que pasa después depende del momento exacto, por fracciones de segundo.
Una race condition ocurre cuando el resultado depende del momento preciso de operaciones concurrentes que tocan datos compartidos. Dos threads leen ambos un contador como 5, ambos suman uno, ambos escriben 6 — y una cuenta se pierde en silencio, porque la respuesta correcta era 7. Nadie cometió un error de lógica; los pasos simplemente se entrelazaron mal. Este es el error que define la concurrency, y viene directo del estado compartido más el acceso simultáneo.
El horror es que es intermitente
Un traqueteo en un coche que desaparece en el momento en que lo llevas al mecánico — real, intermitente e imposible de mostrar a voluntad.
Las race conditions son desagradables porque son no deterministas: dependen del momento, así que aparecen una vez de cada mil ejecuciones, nunca en tus pruebas, y solo bajo carga real en producción. La misma entrada puede funcionar o fallar según una planificación microscópica. Por eso los errores de concurrency tienen una reputación temible — no puedes reproducirlos de forma fiable, y «en mi máquina funciona» es exactamente lo que esperarías incluso cuando está roto.
El peligro es el estado compartido y mutable
Las colisiones solo ocurren sobre las cosas que ambas personas pueden agarrar y cambiar — cierra el armario compartido con llave, o dale uno a cada una, y se acaban las peleas.
Fíjate en el origen preciso: datos que son a la vez compartidos (más de una tarea puede alcanzarlos) y mutables (pueden cambiarse). Quita cualquiera de las dos propiedades y la race desaparece — los datos que solo toca una tarea son seguros, y los datos que nadie cambia son seguros de compartir libremente. Esta es la idea clave sobre la que construye el resto del curso: cada solución es en realidad una forma de controlar, restringir o evitar el estado compartido y mutable.
Una race condition es dos tareas tocando datos compartidos y mutables a la vez, con el resultado pendiendo del momento. Es intermitente, invisible en las pruebas — y el error que define la concurrency.
La solución clásica para una race es asegurarse de que solo una tarea toque los datos compartidos a la vez. Funciona — pero la herramienta que usas para imponerlo trae un peligro famoso propio.
Un lock da a una tarea acceso exclusivo
Una única llave para un baño de una sola persona: quien la tiene entra; los demás esperan su turno fuera. Nunca dos personas dentro a la vez.
Un lock (o mutex, de «mutual exclusion») garantiza que solo una tarea entre en un tramo de código a la vez. Antes de tocar los datos compartidos, una tarea debe adquirir el lock; las demás que lo quieren esperan hasta que se libere. El tramo protegido es la critical section. Bien hecho, esto convierte un caos sin reglas sobre los datos compartidos en una cola ordenada de uno en uno, y la race condition desaparece.
Deadlock: todos esperando para siempre
Dos personas en una puerta estrecha, cada una negándose a echarse atrás hasta que lo haga la otra — ambas atascadas, educadamente, para siempre.
Los locks traen su propio fallo clásico: deadlock. La tarea A tiene el lock 1 y quiere el lock 2; la tarea B tiene el lock 2 y quiere el lock 1. Cada una espera a que la otra suelte, y ninguna lo hace nunca — ambas congeladas para siempre. Viene de adquirir varios locks en distinto orden. La cura habitual es la disciplina: adquiere siempre los locks en el mismo orden acordado, para que no pueda formarse la espera circular.
Los locks son correctos pero costosos
Una llave de baño mantiene el orden, pero si todo el mundo la necesita constantemente, se forma una cola y toda la oficina se ralentiza hasta la velocidad de esa única puerta.
Los locks arreglan las races, pero tienen un precio: mientras una tarea tiene el lock, las demás esperan, así que un bloqueo intenso puede borrar la velocidad que querías de la concurrency en primer lugar. Bloquear de más crea contention — todos haciendo cola en el mismo lock. Por eso retienes los locks el menor tiempo posible, proteges lo menos posible y prefieres diseños que necesiten menos locks. Un programa correcto que está serializado en un solo lock no es mucho mejor que uno de un solo thread.
Un lock da a una tarea a la vez acceso exclusivo, matando la race. Pero los locks pueden caer en deadlock, y bloquear de más serializa todo — correcto, pero lento.
Los locks combaten los síntomas del estado compartido y mutable. La jugada más profunda es diseñar para que haya menos contra lo que luchar — y unos pocos patrones hacen que clases enteras de errores de concurrency sean sencillamente imposibles.
No compartas memoria — pasa mensajes
En vez de muchos cocineros peleando por una encimera compartida, cada uno trabaja en su propio puesto y entrega los platos terminados por un pase — nunca dos van a por la misma cosa.
Una alternativa potente a la memoria compartida es el message passing: las tareas no tocan los mismos datos; se envían mensajes a través de una queue. Solo una tarea posee una pieza de datos dada, y las demás piden cambios enviando un mensaje. Hay un lema famoso para ello: «no te comuniques compartiendo memoria; comparte memoria comunicándote». Quita el acceso compartido y quitas la race de raíz, sin necesidad de locks.
Los datos inmutables no pueden sufrir una race
Darle a cada uno su propia fotocopia en vez de un único original compartido — todos pueden leer a la vez, y nadie puede garabatear sobre lo que otro está leyendo.
Si los datos nunca cambian después de crearse — si son immutable — entonces cualquier cantidad de tareas puede leerlos a la vez con riesgo cero, porque el peligro nunca fue compartir, fue compartir y cambiar. En vez de mutar un valor en su sitio, creas uno nuevo. La inmutabilidad elimina una mitad de la condición «compartido y mutable», y con ella toda una categoría de errores. Por eso los estilos funcionales se apoyan tanto en ella.
Las operaciones atomic son indivisibles
Un torno que cuenta a cada persona al pasar en un único clic indivisible — no hay un momento a medio contar para que dos personas se cuelen.
Algunas operaciones son atomic: ocurren como un único paso indivisible que no puede interrumpirse a medias, así que dos tareas no pueden pillarse una a otra a mitad de una actualización. Los lenguajes y las bases de datos proporcionan contadores atomic, intercambios atomic y transacciones justo para esas pequeñas actualizaciones compartidas que las races adoran. Cuando de verdad debes compartir un valor que cambia, una operación atomic suele ser una solución más barata y segura que un lock completo — la actualización sencillamente no puede partirse.
El mejor error de concurrency es el que se vuelve imposible: pasa mensajes en vez de compartir, haz los datos immutable o usa operaciones atomic — y la race no tiene dónde vivir.
La concurrency es un poder que deberías añadir a propósito, no por reflejo. La habilidad está en recurrir a ella solo cuando el problema lo necesita, y en elegir el modelo más simple que haga el trabajo.
Ajusta la herramienta al cuello de botella
No contratas más cargadores para un trabajo que es todo esperar, ni un mejor planificador para un trabajo que es todo cargar peso — ajustas la ayuda a donde de verdad se va el tiempo.
Antes de añadir concurrency, conoce tu cuello de botella. Si el trabajo es I/O-bound — sobre todo esperar — async en un solo thread a menudo lo resuelve sin más. Si es CPU-bound — sobre todo calcular — necesitas parallelism real entre cores. Si no es ninguno — rápido y simple — añadir concurrency solo te compra errores sin ganancia. El error más común es tirar de threads cuando el código liso y secuencial ya era lo bastante rápido.
Mantén minúscula la superficie compartida
Un taller donde cada persona trabaja sobre todo sola, con un único estante de herramientas compartido, pequeño y claramente marcado — cuanto menos se comparte, menos puede salir mal.
Como cada error de concurrency viene del estado compartido y mutable, la jugada maestra es minimizar lo compartido. Mantén la mayoría de los datos en manos de una sola tarea, haz immutable lo que puedas y confina las partes genuinamente compartidas y cambiantes a una superficie pequeña y cuidadosamente vigilada. Un diseño con una superficie compartida minúscula es uno sobre el que de verdad puedes razonar; uno donde todo es compartido y mutable es una casa encantada. La simplicidad aquí no es opcional — es toda la defensa.
- Cuál es el cuello de botella — I/O-bound (async), CPU-bound (parallel) o ninguno (no lo añadas)? - Qué datos son compartidos y mutables, y podrían dos tareas tocarlos a la vez? - Puedo evitar compartir — message passing, inmutabilidad o una sola operación atomic? - Si debo bloquear, ¿es la critical section minúscula y el orden de los locks consistente? - Podría caer en deadlock, y ¿he descartado la espera circular? - Vale la pena la complejidad añadida, o el código secuencial ya era lo bastante rápido?
- concurrency / parallelism — hacer malabares alternando, frente a ejecutar en el mismo instante. - process / thread — aislado con memoria privada, frente a compartir memoria (y el riesgo). - blocking / async / await / event loop — detenerse y esperar, frente a iniciar y continuar. - I/O-bound / CPU-bound — trabajo de mucha espera frente a trabajo de mucho cálculo. - race condition — error dependiente del momento sobre estado compartido y mutable. - lock / mutex / critical section / deadlock / contention — acceso exclusivo y sus peligros. - message passing / immutable / atomic — los patrones que quitan la race de raíz.
- Recurres a async para esperar y a parallelism para calcular — no por reflejo. - Puedes señalar exactamente qué datos son compartidos y mutables, y son pocos. - Prefieres el message passing y la inmutabilidad a esparcir locks por todas partes. - Tus locks son cortos, ordenados, y has razonado sobre el deadlock. - No añades concurrency donde el código secuencial liso ya era lo bastante rápido.
La concurrency es poder deliberado: async para esperar, parallelism para calcular y una superficie compartida minúscula vigilada a propósito. Cada error se rastrea hasta el estado compartido y mutable — así que comparte lo menos que puedas.