Sistema operativo y concurrencia¶
De un vistazo
Materia: Capa 3 · Sobrevivir en producción · Tiempo de lectura: ~35 min · Requisitos previos: Capa 0 (Modelo de ejecución, memoria) sobre todo —necesitas tener claro qué es un proceso, qué es su espacio de direcciones y la diferencia entre stack y heap.
En una frase: vas a entender por qué tu programa no está solo en la máquina, cómo el sistema operativo reparte una CPU entre miles de tareas, y por qué en cuanto dos hilos tocan el mismo dato a la vez el mundo se rompe de formas que ningún test determinista pilla.
Por qué esto importa¶
Durante toda la Capa 0 dimos por hecho una mentira cómoda: que tu programa era el único inquilino de la máquina, con la CPU y la memoria para él solo. Es falso. En tu portátil hay ahora mismo cientos de procesos vivos y un puñado de CPUs físicas. Alguien tiene que decidir quién corre, cuándo, y durante cuánto. Ese alguien es el sistema operativo, y sus decisiones se filtran hasta tu código de formas que parecen magia negra hasta que entiendes el reparto.
Las preguntas que no puedes responder desde el lenguaje, solo desde el SO y la concurrencia:
- ¿Por qué mi contador de visitas marca 998 cuando sé que hubo exactamente 1000 peticiones, y el número cambia cada vez que ejecuto la prueba?
- ¿Por qué el mismo código pasa el test 200 veces y falla la 201, sin que yo haya tocado nada?
- ¿Por qué mi servidor se queda congelado del todo sin consumir CPU, sin errores, sin logs, simplemente colgado para siempre?
- ¿Por qué "meter más hilos" hizo mi programa más lento en vez de más rápido?
- ¿Por qué Node.js presume de un solo hilo y aguanta 10.000 conexiones, mientras un servidor con mil hilos se ahoga?
Todas tienen la misma raíz: cómo se reparte la CPU y qué pasa cuando varias líneas de ejecución comparten memoria. Sin este modelo mental, un bug de concurrencia es lo más frustrante que existe —no se reproduce, no se atrapa en el debugger (porque el debugger cambia los tiempos), y "funciona en mi máquina" es literalmente cierto. Con él, dejan de ser fantasmas y pasan a ser consecuencias predecibles de reglas que vas a aprender aquí.
Por qué PHP no te ha enseñado esto (y por qué aquí cambiamos de lenguaje)
El modelo tradicional de PHP es un proceso por petición: el servidor web arranca (o reutiliza) un proceso, ese proceso atiende una petición de principio a fin, con su memoria privada, y muere o se recicla. Dos peticiones simultáneas son dos procesos distintos que no comparten nada —el aislamiento te lo regala el SO gratis. Por eso un programador de PHP puede pasarse años sin toparse jamás con una condición de carrera: el modelo se las esconde. Es una bendición para la productividad y una venda para entender la máquina. En esta lección los ejemplos van en Go (que hace la concurrencia de primera clase y legible) y en pseudocódigo / C cuando quiero enseñar el mecanismo desnudo. Lo aviso en cada bloque.
Intuición primero: una cocina, un cocinero, muchos pedidos¶
Imagina un restaurante con un solo cocinero (una CPU) y una avalancha de pedidos (procesos que quieren ejecutarse). El cocinero solo puede hacer una cosa a la vez. Y sin embargo, al final de la noche, todas las mesas han comido. ¿Cómo?
El cocinero no termina un pedido entero antes de empezar el siguiente. Hace un poco de cada uno y va rotando: pica la cebolla del pedido A, la deja al fuego, se pasa al pedido B a emplatar, vuelve al A a remover, atiende el C… Va tan rápido cambiando de tarea que, desde fuera, parece que cocina varios platos a la vez. Pero no: en cada instante solo toca uno. Eso es concurrencia: la ilusión de simultaneidad conseguida repartiendo el tiempo de un recurso único.
Ahora mete cuatro cocineros (cuatro CPUs) en la misma cocina. Ahora sí que hay cuatro platos avanzando de verdad al mismo tiempo. Eso es paralelismo: simultaneidad física real. Y fíjate en el problema nuevo que aparece con cuatro cocineros y que no existía con uno: si dos van a coger la misma sartén a la vez, chocan. Con un cocinero eso jamás pasaba. La sartén compartida es tu memoria compartida, y el choque es la condición de carrera.
graph TB
subgraph CONC["Concurrencia · 1 cocinero"]
C1["Cocinero único"] -->|"un poco de A"| PA["Pedido A"]
C1 -->|"un poco de B"| PB["Pedido B"]
C1 -->|"un poco de C"| PC["Pedido C"]
end
subgraph PAR["Paralelismo · 4 cocineros"]
K1["Cocinero 1"] --> Q1["Pedido A"]
K2["Cocinero 2"] --> Q2["Pedido B"]
K3["Cocinero 3"] --> Q3["Pedido C"]
K4["Cocinero 4"] --> Q4["Pedido D"]
end
Guarda esta cocina. El SO es el jefe de cocina que decide quién toca qué y cuándo; la sincronización son las reglas para compartir sartenes sin partirse la cara; y el interbloqueo es el desastre de dos cocineros esperándose el uno al otro para siempre. Vamos por partes.
El detalle¶
1. Qué hace un sistema operativo: el jefe que reparte¶
Un sistema operativo (SO) es, en el fondo, un programa con un trabajo: gestionar los recursos de la máquina y repartirlos entre los programas que quieren usarlos, manteniéndolos aislados para que no se pisen. Los recursos son tres grandes:
- La CPU (tiempo de cálculo): la reparte el planificador (scheduler).
- La memoria (espacio): la reparte con memoria virtual, dando a cada proceso su espacio de direcciones privado (Capa 0). Por eso un proceso no puede leer la memoria de otro: el SO no lo permite.
- Los dispositivos (disco, red, teclado): el acceso lo controla el SO; tú no hablas con el disco, le pides al SO que lo haga por ti mediante una llamada al sistema (syscall).
Esa última idea es clave y casi nadie la interioriza: tu programa no tiene permiso para tocar el hardware directamente. La CPU funciona en dos modos —modo usuario (tu código, capado) y modo núcleo (kernel, el SO, con todos los permisos)—. Cuando necesitas leer un fichero o mandar un byte por la red, ejecutas una syscall, que es una puerta controlada por la que saltas a modo núcleo, el SO hace la operación privilegiada, y vuelve. Esa transición cuesta (cientos de nanosegundos), y ese coste explicará más adelante por qué ciertos modelos de concurrencia son más eficientes que otros.
El planificador y por qué tu programa se puede parar en cualquier sitio
El planificador reparte la CPU dando a cada tarea una rodaja de tiempo (time slice o quantum), unos pocos milisegundos. Cuando se agota —o cuando tu tarea se bloquea esperando disco/red— el SO le hace un cambio de contexto (context switch): guarda su estado de ejecución (PC, registros; ¿te suena de Capa 0?), lo aparca, y carga el de otra tarea. Esto es preemptivo: el SO te interrumpe cuando le da la gana, a mitad de lo que estés haciendo, sin pedirte permiso ni avisarte. Interioriza esto, porque es la semilla de todos los bugs de concurrencia: entre dos líneas cualesquiera de tu código —incluso a mitad de una sola línea— el SO puede pararte y dejar correr a otro. Tú crees que tus operaciones son atómicas e ininterrumpibles. No lo son.
2. Procesos vs hilos: la frontera está en la memoria¶
En Capa 0 definimos un proceso como espacio de direcciones + estado de ejecución. Un hilo (thread) parte esa definición en dos y se queda solo con la segunda mitad.
- Un proceso tiene su propio espacio de direcciones (su memoria privada, aislada del resto).
- Un hilo es una línea de ejecución (su propio PC, sus registros, su propio stack) que vive dentro de un proceso. Un proceso puede tener varios hilos, y todos esos hilos comparten el mismo espacio de direcciones: el mismo heap, las mismas variables globales, el mismo código.
Esa es toda la diferencia, y lo es todo:
Los hilos del mismo proceso comparten memoria (el heap). Los procesos, no.
graph TB
subgraph PROC["Un proceso con 3 hilos"]
direction TB
HEAP["<b>Heap + Datos globales</b><br/>COMPARTIDO por los 3 hilos<br/>(objetos, variables globales)"]
CODE["<b>Código</b> · compartido"]
subgraph H1["Hilo 1"]
S1["stack propio<br/>PC + registros propios"]
end
subgraph H2["Hilo 2"]
S2["stack propio<br/>PC + registros propios"]
end
subgraph H3["Hilo 3"]
S3["stack propio<br/>PC + registros propios"]
end
H1 --> HEAP
H2 --> HEAP
H3 --> HEAP
end
Cada hilo tiene su stack propio (por eso cada uno puede estar en una función distinta con sus variables locales), pero el heap es de todos. De ahí salen las dos caras de la moneda:
- La ventaja: comunicar dos hilos es trivial y baratísimo —uno escribe en una variable del heap, el otro la lee. No hay que copiar ni mandar nada. Comparado con dos procesos (que para hablar necesitan mecanismos del SO —tuberías, sockets, memoria compartida explícita, todo con syscalls y copias), los hilos son ligeros.
- La maldición: como todos ven la misma memoria y el SO los interrumpe cuando quiere (sección 1), en cuanto dos hilos tocan el mismo dato sin coordinarse, entras en el territorio de las condiciones de carrera. Esto es el resto de la lección.
La regla mental para elegir
Procesos cuando quieres aislamiento (que un fallo de uno no tumbe al otro, que no se pisen la memoria por accidente): más seguro, más pesado. Hilos cuando quieres compartir estado de forma rápida dentro de una tarea: más ligero, infinitamente más peligroso. El modelo de PHP (proceso por petición) elige aislamiento y por eso duerme tranquilo; un servidor Java o Go con un pool de hilos elige compartir y por eso su gente tiene que saber lo que viene ahora.
3. Concurrencia ≠ paralelismo (y no es pedantería)¶
Es la distinción que más se confunde y la que más aclara:
- Concurrencia es una propiedad de la estructura de tu programa: tener varias tareas en marcha cuyo progreso se solapa en el tiempo, gestionadas de forma que avancen "a la vez" aunque se turnen. Es lidiar con muchas cosas a la vez. Existe aunque tengas una sola CPU (el cocinero rotando).
- Paralelismo es una propiedad de la ejecución: varias cosas ocurriendo físicamente en el mismo instante, lo que exige varias CPUs / núcleos. Es hacer muchas cosas a la vez.
Concurrencia es diseño; paralelismo es ejecución. Puedes tener concurrencia sin paralelismo (un núcleo turnándose entre tareas) y el paralelismo necesita una estructura concurrente para aprovecharse.
La cita de Rob Pike (uno de los creadores de Go) lo clava: "Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once." Por qué te importa en la práctica: si tu problema es esperar (mil conexiones de red que pasan el rato ociosas esperando datos), lo que necesitas es concurrencia —un solo hilo puede gestionar las mil turnándose mientras esperan, sin paralelismo ninguno. Si tu problema es calcular (multiplicar dos matrices gigantes), lo que necesitas es paralelismo real repartiendo el cálculo entre núcleos. Confundirlos lleva a soluciones que no atacan tu cuello de botella.
4. El problema de fondo: estado compartido mutable¶
Aquí está la raíz de todo el dolor, y conviene nombrarla con precisión. El problema no es la concurrencia en sí. El problema es la combinación de tres ingredientes:
- Estado compartido — varios hilos ven el mismo dato (el heap común).
- Mutable — ese dato se modifica (no es de solo lectura).
- Sin coordinación — nada garantiza el orden en que lo tocan.
Quita cualquiera de los tres y el problema desaparece. Si el estado no se comparte (procesos aislados, o cada hilo con su copia), no hay carrera. Si es compartido pero inmutable (solo se lee, nunca cambia), tampoco —mil lectores no chocan. Si está coordinado (sección 6), lo domas. Los modelos alternativos a los hilos (sección 8) son, todos, formas distintas de eliminar uno de los tres ingredientes. Tenlo presente: cada solución que veas es "cargarse el estado compartido", "cargarse la mutabilidad" o "meter coordinación".
5. Condiciones de carrera: el contador que miente¶
Una condición de carrera (race condition) es un bug en el que el resultado del programa depende del orden exacto en que el planificador entrelaza las operaciones de varios hilos —un orden que no controlas y que cambia entre ejecuciones. El ejemplo canónico, incrementar un contador compartido, parece imposible de romper y sin embargo:
// Go. Lanzamos 1000 goroutines que hacen contador++ una vez cada una.
var contador int // estado compartido, en memoria común
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { // "go" lanza una tarea concurrente
defer wg.Done()
contador++ // <-- la línea "inocente"
}()
}
wg.Wait()
fmt.Println(contador) // esperas 1000... imprime 973, o 981, o 1000. Cambia cada vez.
"Pero si es una sola línea, contador++". Ahí está el engaño. contador++ no es una operación, son tres, y el SO puede interrumpirte entre cualquiera de ellas (sección 1). A nivel de máquina:
- LEER el valor de
contadorde memoria a un registro. - SUMAR 1 en el registro.
- ESCRIBIR el registro de vuelta a memoria.
Ahora imagina dos hilos, A y B, con el contador en 41, y el planificador entrelazándolos así:
sequenceDiagram
participant A as Hilo A
participant M as contador (memoria)
participant B as Hilo B
Note over M: contador = 41
A->>M: LEE 41
B->>M: LEE 41
Note over A: suma → 42 (en su registro)
Note over B: suma → 42 (en su registro)
A->>M: ESCRIBE 42
B->>M: ESCRIBE 42
Note over M: contador = 42 ❌ (debería ser 43)
Los dos leyeron 41, los dos calcularon 42, los dos escribieron 42. Dos incrementos, pero el contador solo subió uno. Se ha perdido una actualización (lost update). Con 1000 hilos peleándose, pierdes decenas. Y como todo depende del capricho del planificador —de milisegundos—, el número cambia cada ejecución y el bug desaparece bajo el debugger (que ralentiza y descoloca los tiempos). Esto se llama un heisenbug: se esfuma cuando lo observas.
El trozo de código donde se toca el estado compartido —las tres instrucciones de contador++— se llama sección crítica. La regla de oro es: una sección crítica solo puede estar siendo ejecutada por un hilo a la vez. Esa propiedad se llama exclusión mutua, y conseguirla es lo que hacen los mecanismos de la siguiente sección.
Por qué esto no lo ves en PHP tradicional
Vuelve al ejemplo con la cabeza del modelo PHP: mil peticiones = mil procesos, cada uno con su propia variable $contador en su propia memoria. No hay estado compartido, no hay carrera. El "contador global compartido" en PHP vive fuera del proceso —en Redis, en la base de datos— y ahí el problema reaparece exactamente igual (por eso existen INCR atómico en Redis y las transacciones con bloqueo en SQL). El fenómeno no desaparece; se muda al recurso compartido de turno. Aprenderlo con hilos en memoria es aprenderlo en su forma más pura.
6. Sincronización: mutex, locks y semáforos (y su precio)¶
Si el problema es "dos hilos en la sección crítica a la vez", la solución es un guardián que solo deje pasar a uno. Ese es el mutex (de mutual exclusion), también llamado lock (cerrojo). Es un objeto con dos operaciones: lock() (cierra: si ya está cerrado por otro, te quedas esperando hasta que se abra) y unlock() (abre y deja pasar al siguiente). Es la llave del único baño de un bar: quien la tiene, entra; el resto hace cola.
// Go. El mismo contador, ahora protegido.
var contador int
var mu sync.Mutex // el guardián
go func() {
mu.Lock() // pido la llave (espero si otro la tiene)
contador++ // sección crítica: aquí estoy YO SOLO, garantizado
mu.Unlock() // suelto la llave; el siguiente en la cola entra
}()
// Ahora sí: siempre 1000.
El mutex convierte las tres instrucciones de contador++ en una operación efectivamente atómica frente a los demás hilos: mientras A tiene la llave, B se queda parado en su Lock() sin poder leer ni escribir. La carrera muere.
Un semáforo es la generalización: en vez de "uno o nadie" (mutex = semáforo de 1), permite hasta N a la vez. Es un contador de permisos: acquire() coge un permiso (espera si no quedan), release() devuelve uno. Sirve para limitar aforo —"como mucho 10 conexiones simultáneas a la base de datos"— o para señalizar entre hilos ("hay 3 elementos listos para consumir").
graph LR
subgraph COLA["Hilos esperando"]
B["Hilo B"]
C["Hilo C"]
end
subgraph CRIT["Sección crítica"]
A["Hilo A<br/>tiene el lock 🔒"]
end
MU["MUTEX"]
B -->|"Lock() → espera"| MU
C -->|"Lock() → espera"| MU
A -->|"Unlock() al salir"| MU
MU -.->|"deja pasar al<br/>siguiente"| CRIT
Todo esto tiene un precio, y por eso no es la respuesta a todo:
- Contención y serialización. Un lock convierte trabajo paralelo en trabajo en fila. Si todos tus hilos pelean por el mismo lock, se pasan la vida esperando en cola y has vuelto, en la práctica, a un solo hilo... pero pagando el coste de coordinarlos. Un lock demasiado "gordo" (que protege demasiado) mata el rendimiento que fuiste a buscar con los hilos.
- Coste directo. Pedir y soltar un lock cuesta (a veces implica una syscall si hay que dormir al hilo). Bloquear/despertar hilos son cambios de contexto, que no son gratis.
- Bugs propios. Olvidar un
unlock()(por unreturno una excepción a mitad de la sección) cuelga a todos los demás para siempre. Y coordinar varios locks abre la puerta al problema de la siguiente sección.
La regla de oro de los locks
Mantén las secciones críticas lo más pequeñas posible (protege el dato, no el cálculo entero) y los locks lo menos tiempo posible. Nunca hagas dentro de un lock algo lento (E/S de red, disco): tendrías a toda la cola esperándote mientras hablas con el mundo. El mejor lock es el que no necesitas —de ahí los modelos de la sección 8.
7. Interbloqueo: dos que se esperan para siempre¶
Metes locks para arreglar las carreras y aparece un monstruo nuevo. Un interbloqueo (deadlock) es cuando dos o más hilos se quedan esperándose mutuamente para siempre, cada uno reteniendo lo que el otro necesita. Nadie avanza, nadie da error, el programa se cuelga consumiendo cero CPU. La analogía perfecta: dos coches en un cruce estrecho, cada uno metido hasta la mitad, cada uno esperando a que el otro retroceda. Ninguno cede. Atasco eterno.
El caso clásico con dos locks, A y B:
// Go. Hilo 1 y Hilo 2 cogen dos locks en ORDEN OPUESTO. Receta del desastre.
// Hilo 1:
mu_A.Lock() // coge A
mu_B.Lock() // quiere B... pero lo tiene el Hilo 2 → espera
// ...
// Hilo 2 (a la vez):
mu_B.Lock() // coge B
mu_A.Lock() // quiere A... pero lo tiene el Hilo 1 → espera
// Hilo 1 espera a B (que tiene 2). Hilo 2 espera a A (que tiene 1). PARA SIEMPRE.
graph LR
H1["Hilo 1<br/>tiene 🔒A"] -->|"quiere B"| LB["Lock B"]
H2["Hilo 2<br/>tiene 🔒B"] -->|"quiere A"| LA["Lock A"]
LB -.->|"lo retiene"| H2
LA -.->|"lo retiene"| H1
Un deadlock necesita las cuatro condiciones de Coffman a la vez —y esto es oro puro, porque romper una sola basta para hacerlo imposible:
- Exclusión mutua: los recursos no se pueden compartir (por eso los usamos: un lock es exclusivo por definición).
- Retención y espera (hold and wait): un hilo retiene un recurso mientras espera otro.
- Sin expropiación (no preemption): no le puedes quitar un recurso a la fuerza; lo suelta él o nada.
- Espera circular: hay un ciclo A→B→...→A de "quién espera a quién".
La cura más práctica ataca la espera circular (4): imponer un orden global de adquisición de locks. Si la regla es "siempre coge A antes que B, nunca al revés", el ciclo es imposible por construcción y el deadlock del ejemplo no puede darse. Otras tácticas: coger todos los locks de golpe o ninguno (mata hold and wait), o poner un timeout al lock() y reintentar (introduce una expropiación blanda). Pero la disciplina de ordenar los locks es la que se usa de verdad.
El primo cercano: el livelock y la inanición
No todo cuelgue es un deadlock quieto. En un livelock los hilos sí se mueven —reaccionan uno al otro— pero sin progresar nunca (los dos peatones que se apartan al mismo lado una y otra vez para dejarse pasar). Y en la inanición (starvation) un hilo nunca llega a ejecutarse porque otros más "ávidos" acaparan siempre el recurso. Los tres son fallos de liveness: el programa no está roto, está atascado.
8. Los hilos no son gratis: modelos alternativos¶
Llegados aquí ya intuyes que los hilos con memoria compartida y locks son un campo de minas. Además, tienen un coste material que la gente ignora: cada hilo del SO se lleva su propio stack reservado (típicamente ~1 MB), y cada cambio de contexto entre hilos cuesta al SO (guardar/restaurar estado, invalidar cachés). Lanzar 10.000 hilos del SO significa ~10 GB solo de stacks y un planificador ahogado cambiando de contexto sin parar: el famoso problema C10k. "Añadir hilos" no escala linealmente; a partir de cierto punto, cada hilo nuevo resta. De ahí que existan modelos que buscan la concurrencia por otras vías:
a) Bucle de eventos / asíncrono (Node.js, el async/await de casi todos). Un solo hilo que nunca se queda bloqueado esperando. En vez de "espera a que llegue el dato de la red" (que dormiría al hilo), dice "avísame cuando llegue" y se va a atender otra cosa. Un bucle central va despachando lo que va estando listo. Con un hilo atiende decenas de miles de conexiones porque casi todas están esperando (concurrencia sin paralelismo, sección 3). Brilla en cargas de espera (E/S). Su talón de Aquiles: como es un solo hilo, un cálculo pesado lo bloquea todo (si te pones a triturar números, nadie más avanza), y no aprovecha varios núcleos por sí solo.
b) Actores (Erlang/Elixir, Akka). Nada de memoria compartida. Cada actor es una unidad aislada con su propio estado privado que nadie más toca; los actores solo se comunican enviándose mensajes. Al no compartir estado (te cargaste el ingrediente 1 de la sección 4), no hay condiciones de carrera ni locks. Escala a millones de actores y tolera fallos de maravilla (si uno peta, no corrompe a nadie). Es la filosofía detrás de sistemas de telecomunicaciones que llevan décadas sin caerse.
c) CSP: goroutines y canales (Go). El lema de Go: "No comuniques compartiendo memoria; comparte memoria comunicando." En vez de que dos tareas toquen la misma variable con un lock, se pasan el dato por un canal (channel), una tubería con seguridad de concurrencia. La goroutine es la otra mitad del truco: una tarea concurrente ligerísima gestionada por el runtime de Go, no por el SO. Empieza con un stack de ~2 KB (contra el ~1 MB de un hilo) que crece si hace falta, y el runtime multiplexa millones de goroutines sobre unos pocos hilos del SO (tantos como núcleos). Lanzar un millón de goroutines es normal; lanzar un millón de hilos, un suicidio.
// Go. Comunicar por canal en vez de compartir con lock.
ch := make(chan int) // el canal (la tubería)
go func() { ch <- 42 }() // una goroutine ENVÍA 42 y se va
resultado := <-ch // otra RECIBE. El canal coordina el paso del dato.
// No hay variable compartida, no hay mutex, no hay carrera posible.
graph TB
subgraph HILOS["Modelo de hilos + memoria compartida"]
T1["Hilo"] --> SH["dato compartido<br/>🔒 protegido por lock"]
T2["Hilo"] --> SH
end
subgraph CSP["Modelo CSP (Go)"]
G1["goroutine"] -->|"envía"| CH["canal"]
CH -->|"recibe"| G2["goroutine"]
end
No hay bala de plata: el async brilla en E/S y sufre con CPU; los actores dan aislamiento y tolerancia a fallos a cambio del coste de los mensajes; CSP da concurrencia legible y barata pero sigues pudiendo compartir memoria y equivocarte si te empeñas. Lo que todos comparten es que atacan el problema de la sección 4 (eliminan estado compartido, o lo mutan, o coordinan sin locks a pelo) y evitan el peaje de los hilos del SO uno a uno.
Por qué Go para esta capa y no PHP
PHP con "un proceso por petición" no puede enseñarte esto: te esconde los hilos, la memoria compartida y por tanto las carreras, los deadlocks y la elección de modelo. Go los pone en primera fila con una sintaxis mínima (go, chan, sync.Mutex) —puedes provocar una carrera real y verla fallar en diez líneas, y arreglarla en dos. Para entender la concurrencia necesitas un lenguaje donde la concurrencia exista y se vea. Ese es el motivo del cambio de idioma en esta lección.
Cómo se ve en la práctica¶
El caso real que tumba productos: un endpoint de "canjear cupón de un solo uso" bajo carga. La lógica parece intachable:
// ❌ Comprobar-y-actuar (check-then-act): una carrera de manual.
func canjear(cuponID string) error {
cupon := db.Buscar(cuponID)
if cupon.Usado { // (1) COMPRUEBA: ¿ya usado?
return errors.New("cupón ya usado")
}
aplicarDescuento(cupon)
cupon.Usado = true // (2) ACTÚA: márcalo usado
db.Guardar(cupon)
return nil
}
Con un usuario, perfecto. Pero un atacante manda 20 peticiones idénticas en el mismo milisegundo. Veinte hilos (o veinte procesos, da igual: el recurso compartido es la fila de la base de datos) ejecutan el if antes de que ninguno llegue a marcar Usado: los veinte leen Usado = false, los veinte pasan la comprobación, y el descuento se aplica veinte veces. Es la carrera de la sección 5 exactamente, con dinero real de por medio. El hueco entre "comprobar" y "actuar" es la sección crítica desprotegida.
La cura es la misma idea de la sección 6, llevada al recurso compartido de verdad (la BD): hacer atómica la comprobación-y-actuación con un bloqueo o, mejor, empujándola a una sola operación que la base de datos garantiza indivisible:
// ✅ La condición y la acción, en UNA operación atómica que hace la BD.
// "marca como usado SOLO SI aún no lo estaba"; devuelve cuántas filas cambió.
filas := db.Exec(
"UPDATE cupones SET usado = true WHERE id = ? AND usado = false",
cuponID,
)
if filas == 0 {
return errors.New("cupón ya usado") // otro se nos adelantó, y lo sabemos
}
aplicarDescuento(cupon) // solo UNO llega aquí, garantizado
Las dos versiones "hacen lo mismo" a ojos de la lógica de negocio. La diferencia es puramente concurrencia: la primera deja un hueco entre comprobar y actuar por el que se cuelan veinte hilos; la segunda no tiene hueco porque la comprobación y la acción son una sola operación indivisible. Sin este modelo mental, el arreglo ("usa un WHERE ... AND usado = false" o un lock optimista) es un truco copiado de Stack Overflow. Con él, es la consecuencia obvia de que una sección crítica no puede partirse.
Lo que sacrificas / errores comunes¶
- Creer que una línea de código es atómica.
contador++,lista.append(x),if x: x=y—casi nada lo es a nivel de máquina. El planificador te parte por dentro. Este es el malentendido madre del que salen casi todas las carreras. - "Meto más hilos y va más rápido." Falso pasado cierto punto: si el cuello es E/S, los hilos duermen esperando; si compiten por un lock, se serializan; y cada hilo cuesta ~1 MB y cambios de contexto. Más hilos puede ir más lento. Mide antes de asumir.
- Locks demasiado gordos o demasiado tiempo. Proteger un bloque enorme, o hacer E/S dentro de un lock, convierte tu programa concurrente en uno secuencial con sobrecoste. Protege el dato, no el mundo.
- Ignorar el orden de adquisición de locks. En cuanto manejas dos o más locks sin un orden global fijo, el deadlock es cuestión de tiempo. Ordénalos siempre igual.
- "El GC / el framework me protege de las carreras." No. El recolector de basura gestiona memoria, no concurrencia. Un ORM o un framework web no serializan mágicamente tus accesos al estado compartido.
- Confundir concurrencia y paralelismo al elegir solución. Meter paralelismo (más núcleos) a un problema de espera (E/S), o async a un problema de cálculo puro. Diagnostica si tu cuello es esperar o calcular antes de elegir modelo.
- Depurar carreras con el debugger. El debugger cambia los tiempos y el bug se esconde (heisenbug). Se cazan con razonamiento, stress tests concurrentes y detectores de carreras (p. ej.
go run -race).
Resumen¶
- El sistema operativo reparte los recursos: reparte la CPU con el planificador, que te da rodajas de tiempo y te interrumpe cuando quiere —entre dos líneas cualesquiera de tu código puede pararte y dejar correr a otro.
- Procesos vs hilos: la frontera es la memoria. Los hilos de un proceso comparten heap (rápido de comunicar, peligroso); los procesos están aislados (seguro, pesado).
- Concurrencia (estructura: tareas solapadas, existe con una CPU) ≠ paralelismo (ejecución física simultánea, necesita varias CPUs). Espera → concurrencia; cálculo → paralelismo.
- La raíz de todo mal es estado compartido + mutable + sin coordinar: quita un ingrediente y no hay bug. Una condición de carrera hace el resultado dependiente del orden del planificador (
contador++son tres pasos, no uno). - Se doma con exclusión mutua (mutex, semáforos), que cuesta contención y serialización; y su peligro nuevo es el deadlock (cuatro condiciones de Coffman; rompe una —ordena los locks— y muere). Los modelos alternativos (async, actores, CSP/goroutines) esquivan el peaje de los hilos y el estado compartido.
Ejercicios socráticos¶
No busques la respuesta fuera. Razónala con el modelo de esta lección. Si te atascas, la pista está en el texto.
- Explica por qué
contador++ejecutado por dos hilos puede acabar sumando solo 1 en vez de 2. Descompón la línea en sus operaciones de máquina y dibuja un entrelazado del planificador que produzca el fallo. ¿En qué punto exacto habría que impedir la interrupción? - El mismo código de carrera "funciona" 500 veces seguidas en tu portátil y falla en producción. Da dos razones por las que el entorno de producción hace la carrera mucho más probable de manifestarse. (Pista: núcleos y carga.)
- Te dan cuatro locks y un sistema que a veces se cuelga sin consumir CPU. Sabes que es un deadlock. Sin cambiar la lógica de negocio, ¿cuál de las cuatro condiciones de Coffman atacarías y cómo, con la menor cantidad de cambios? Justifica por qué esa y no otra.
- Un compañero quiere acelerar un servidor que atiende 5.000 conexiones, casi todas ociosas esperando datos de red. Propone lanzar 5.000 hilos del SO. ¿Qué le dices? Razona con concurrencia vs paralelismo y con el coste material de un hilo, y propón el modelo adecuado.
- El lema de Go es "comparte memoria comunicando, no comuniques compartiendo memoria". Traducido: ¿cuál de los tres ingredientes de la sección 4 elimina un canal, y por qué eso hace imposible la condición de carrera en el ejemplo del canal?
Repaso espaciado¶
Pasa estas a Anki (
repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.
- [ ] ¿Cuál es la única diferencia esencial entre un proceso y un hilo, y qué consecuencia (buena y mala) tiene para comunicar dos líneas de ejecución?
- [ ] Distingue concurrencia de paralelismo en una frase cada una. ¿Cuál puede existir con una sola CPU y cuál necesita varias?
- [ ] Nombra los tres ingredientes que juntos causan una condición de carrera. Si quitas uno cualquiera, ¿qué pasa? Da un modelo que se cargue cada ingrediente.
- [ ] ¿Qué es una sección crítica y qué garantiza un mutex sobre ella? Menciona un coste concreto de usar el mutex.
- [ ] Enumera las cuatro condiciones de Coffman del deadlock y explica cuál rompe "ordenar siempre los locks igual" y por qué eso basta.
Para seguir tirando del hilo¶
- Operating Systems: Three Easy Pieces (Arpaci-Dusseau) — gratis online; la parte de Concurrency (hilos, locks, semáforos, deadlock) es la mejor introducción rigurosa que existe.
- The Go Programming Language (Donovan & Kernighan), cap. 8 y 9 — goroutines, canales y sincronización con la claridad de Kernighan.
- Charla "Concurrency is not Parallelism" de Rob Pike — 30 minutos que fijan la distinción de la sección 3 para siempre.
- Experimento: escribe el contador de 1000 goroutines en Go, ejecútalo diez veces y observa cómo cambia el número. Luego córrelo con
go run -racepara ver al detector de carreras señalarte la línea exacta. Después arréglalo consync.Mutex. Ver el bug con tus ojos y verlo morir asienta esto más que releerlo.