Concurrencia y sistemas operativos Avanzado¶
La concurrencia es la fuente de los errores más difíciles de reproducir. Entender cómo funciona por debajo (SO, CPU, memoria) permite diseñar sistemas correctos y dimensionarlos bien.
1. Conceptos base¶
| Concepto | Qué es |
|---|---|
| Concurrencia | Gestionar varias tareas cuyo progreso se solapa en el tiempo (aunque haya un solo núcleo) |
| Paralelismo | Ejecutar varias tareas a la vez en varios núcleos |
| Proceso | Programa en ejecución con su propio espacio de memoria; aislado de otros procesos |
| Hilo (thread) | Unidad de ejecución dentro de un proceso; comparte memoria con los demás hilos |
| Cambio de contexto | El SO guarda el estado de un hilo y restaura otro; cuesta microsegundos y ensucia cachés |
| Llamada al sistema | Petición al kernel (leer fichero, red, memoria); implica pasar a modo kernel |
CPU-bound vs. I/O-bound
Tareas de CPU (cálculo) escalan con núcleos: ~1 hilo por núcleo. Tareas de E/S (red, disco, BBDD) pasan la mayor parte del tiempo esperando: se benefician de muchos hilos baratos o de modelos asíncronos.
2. Memoria de un proceso¶
| Zona | Contenido |
|---|---|
| Pila (stack) | Variables locales y llamadas; una por hilo; tamaño limitado (→ StackOverflowError) |
| Montículo (heap) | Objetos dinámicos; compartido entre hilos; gestionado por el GC en Java |
| Código y datos estáticos | Instrucciones y constantes |
Memoria virtual: cada proceso ve su propio espacio de direcciones; el SO lo mapea a memoria física en páginas. Si falta memoria física → swap (lentísimo) o, en contenedores, el OOM killer mata el proceso.
3. Modelos de concurrencia¶
| Modelo | Idea | Ejemplos |
|---|---|---|
| Hilos del SO + memoria compartida | Cada petición en un hilo; sincronización con locks | Java clásico, Spring MVC |
| Hilos virtuales / green threads | Hilos ligeros gestionados por el runtime, millones posibles | Java 21+ (Loom), goroutines de Go |
| Event loop | Un hilo procesa eventos sin bloquearse; E/S asíncrona | Node.js, Netty, NGINX |
| Reactivo | Flujos asíncronos con contrapresión | Project Reactor (WebFlux), RxJava |
| Actores | Entidades con estado privado que se comunican por mensajes | Akka/Pekko, Erlang/Elixir |
| CSP / canales | Procesos que se comunican por canales | Go |
| async/await | Código secuencial en apariencia sobre operaciones asíncronas | C#, JavaScript, Python, Kotlin (corrutinas) |
Hilos virtuales en Java 21+¶
Antes: cada hilo de plataforma ≈ 1 hilo del SO (~1 MB de pila, miles como máximo) → para mucha E/S había que usar programación reactiva. Con hilos virtuales, la JVM los "aparca" cuando bloquean en E/S y reutiliza unos pocos hilos del SO: el estilo simple de "un hilo por tarea" escala a cientos de miles de tareas.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<NodeStatus>> futures = nodeIds.stream()
.map(id -> executor.submit(() -> fleetClient.fetchStatus(id))) // 10 000 llamadas HTTP concurrentes
.toList();
for (Future<NodeStatus> f : futures) report.add(f.get());
}
# Spring Boot 3.2+: atender cada petición en un hilo virtual
spring:
threads:
virtual:
enabled: true
Trampas de los hilos virtuales
No aceleran el cálculo (CPU); no uses pools de hilos virtuales (créalos por tarea); limita la concurrencia
hacia recursos escasos con un Semaphore (la BBDD sigue teniendo N conexiones); en versiones anteriores a
Java 24, bloquear dentro de synchronized "clavaba" el hilo virtual a su hilo del SO (pinning).
4. Problemas clásicos¶
Condición de carrera¶
class Counter {
private int value;
void increment() { value++; } // leer, sumar, escribir: NO es atómico
}
// Dos hilos ejecutan increment() 1 000 veces → resultado < 2 000
| Solución | Código |
|---|---|
| Exclusión mutua | synchronized void increment() { value++; } o ReentrantLock |
| Operación atómica (sin locks, CAS) | AtomicInteger value; value.incrementAndGet(); |
| Contador con alta contención | LongAdder |
| Evitar estado compartido | Inmutabilidad, confinar el estado a un hilo, paso de mensajes |
Deadlock¶
Cuatro condiciones simultáneas (Coffman): exclusión mutua, retener y esperar, sin expropiación, espera circular.
Prevención: orden global de adquisición de locks, tryLock con timeout, reducir el alcance de los locks,
no llamar a código externo con un lock tomado. Diagnóstico: jstack muestra "Found one Java-level deadlock".
Otros¶
| Problema | Descripción |
|---|---|
| Livelock | Los hilos reaccionan entre sí sin avanzar (se apartan a la vez una y otra vez) |
| Inanición | Un hilo nunca consigue el recurso |
| Inversión de prioridad | Un hilo de baja prioridad con un lock bloquea a uno de alta |
| Visibilidad | Un hilo no ve el cambio de otro por cachés de CPU o reordenaciones del compilador |
5. Java Memory Model¶
- happens-before: si A happens-before B, B ve los efectos de A. Lo establecen:
synchronized(liberar → adquirir),volatile(escribir → leer),Thread.start/join, colecciones concurrentes,Future.get. volatilegarantiza visibilidad y orden, no atomicidad (volatile int x; x++sigue siendo una carrera).- Publicación segura: campos
finalinicializados en el constructor, o publicar mediante estructuras concurrentes.
6. Caja de herramientas java.util.concurrent¶
| Herramienta | Para |
|---|---|
ExecutorService |
Ejecutar tareas en pools de hilos |
CompletableFuture |
Componer operaciones asíncronas (thenApply, thenCombine, allOf) |
ConcurrentHashMap |
Mapa concurrente; computeIfAbsent, merge atómicos |
CopyOnWriteArrayList |
Muchas lecturas, pocas escrituras (listas de listeners) |
BlockingQueue |
Productor/consumidor con contrapresión |
Semaphore |
Limitar concurrencia (p. ej. 20 llamadas simultáneas a la BBDD) |
CountDownLatch / CyclicBarrier / Phaser |
Coordinar fases |
ReentrantReadWriteLock / StampedLock |
Muchos lectores, pocos escritores |
| Structured concurrency | Tratar un grupo de subtareas como una unidad (cancelación y errores conjuntos) |
// Llamadas en paralelo con timeout y combinación de resultados
CompletableFuture<Node> node = CompletableFuture.supplyAsync(() -> registry.find(id), executor);
CompletableFuture<Metrics> metrics = CompletableFuture.supplyAsync(() -> prometheus.query(id), executor);
NodeView view = node.thenCombine(metrics, NodeView::new)
.orTimeout(2, TimeUnit.SECONDS)
.join();
7. Dimensionado de pools¶
- CPU-bound: hilos ≈ número de núcleos.
- I/O-bound (hilos de plataforma): hilos ≈ núcleos × (1 + tiempo de espera / tiempo de cálculo).
- Ley de Little: concurrencia = throughput × latencia. Con 200 peticiones/s y 50 ms de latencia de BBDD → ~10 conexiones ocupadas de media; dimensiona el pool con margen para picos, no 200.
- Un pool de conexiones a BBDD demasiado grande empeora el rendimiento de la BBDD (contención). HikariCP recomienda empezar pequeño: conexiones ≈ núcleos de la BBDD × 2 + discos.
8. Contenedores y CPU¶
- Kubernetes traduce
requestsylimitsa cgroups.limits.cpu= cuota de CFS: si el proceso la agota en el periodo (100 ms), queda estrangulado (throttling) aunque el nodo tenga CPU libre → picos de latencia. - La JVM detecta los límites del contenedor (
ActiveProcessorCount,MaxRAMPercentage); ajusta-XX:MaxRAMPercentage=75para dejar margen a memoria no-heap. - Recolectores de basura: G1 (por defecto, equilibrado), ZGC y Shenandoah (pausas de milisegundos con heaps grandes), Parallel (máximo throughput en batch).
Preguntas de repaso¶
¿volatile hace atómico un contador?
No. Garantiza visibilidad y orden, pero x++ son tres operaciones (leer, sumar, escribir). Usa AtomicInteger,
LongAdder o sincronización.
¿Cuándo NO aportan nada los hilos virtuales?
En cargas de CPU: el límite son los núcleos. También si el cuello de botella es un recurso limitado (conexiones de BBDD): hay que acotar la concurrencia igualmente.
¿Cómo se previene un deadlock de forma sistemática?
Eliminando la espera circular: todos los hilos adquieren los locks en el mismo orden global. También con
tryLock y timeouts, o evitando locks anidados.
¿Por qué un Pod con CPU libre en el nodo puede tener latencias altas?
Por throttling del límite de CPU (cuota CFS): si consume su cuota antes de terminar el periodo, se le pausa hasta
el siguiente. Muchos equipos quitan limits.cpu y mantienen requests y el límite de memoria.
Ejercicios¶
Ejercicio 1 · Básico — Encontrar la carrera
@Service
class SiteCounter {
private final Map<String, Integer> counts = new HashMap<>();
void inc(String siteId) { counts.put(siteId, counts.getOrDefault(siteId, 0) + 1); }
}
Solución
Dos problemas: (1) HashMap no es seguro para hilos: accesos concurrentes pueden corromper su estructura interna;
(2) leer-sumar-escribir no es atómico: se pierden incrementos.
private final ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
void inc(String siteId) { counts.computeIfAbsent(siteId, k -> new LongAdder()).increment(); }
computeIfAbsent es atómico en ConcurrentHashMap y LongAdder escala bien con mucha contención.
Ejercicio 2 · Básico — Ley de Little
Un servicio recibe 400 peticiones/s y cada una espera 80 ms a la base de datos. ¿Cuántas conexiones están ocupadas de media? ¿Con qué tamaño de pool empezarías si hay 4 instancias?
Solución
Concurrencia = throughput × latencia = 400 × 0,08 = 32 conexiones ocupadas de media en total. Con 4 instancias, ~8 por instancia; un pool de 10-15 por instancia deja margen para picos. Más grande no ayuda: si la base de datos se satura, más conexiones solo aumentan la contención.
Ejercicio 3 · Medio — Deadlock en transferencias
transfer(a, b) bloquea la cuenta a y luego b. Si a la vez se ejecuta transfer(b, a), los dos hilos se
bloquean para siempre. Corrige el diseño.
Solución
Imponer un orden global de bloqueo, por ejemplo por identificador:
Ambos hilos bloquean siempre primero la cuenta de menor ID, así que no puede haber espera circular. (En un sistema real, esto lo resolvería una transacción de base de datos.)Ejercicio 4 · Medio — Timeout sobre varias llamadas
Debes consultar a la vez el registro de nodos y Prometheus, y responder en máximo 2 s; si Prometheus no responde en
1,5 s, devuelve la vista sin métricas. Escríbelo con CompletableFuture.
Solución
CompletableFuture<Node> node = CompletableFuture.supplyAsync(() -> registry.find(id), executor);
CompletableFuture<Metrics> metrics = CompletableFuture.supplyAsync(() -> prometheus.query(id), executor)
.completeOnTimeout(Metrics.EMPTY, 1500, TimeUnit.MILLISECONDS) // degradación: sin métricas
.exceptionally(ex -> Metrics.EMPTY); // también si falla
NodeView view = node.thenCombine(metrics, NodeView::new)
.orTimeout(2, TimeUnit.SECONDS) // el nodo sí es obligatorio
.join();
Ejercicio 5 · Avanzado — Throttling de CPU
Un servicio Java con requests.cpu: 1 y limits.cpu: 1 tiene un p99 de 900 ms aunque su CPU media es del 40 %.
¿Cómo confirmas que es throttling y qué cambias?
Solución
Confirmar con container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total (porcentaje de periodos
estrangulados). Si es alto, el proceso agota su cuota de 100 ms en ráfagas (GC, JIT, picos de peticiones) y queda
parado el resto del periodo; la media del 40 % oculta esas ráfagas. Cambios: quitar limits.cpu (o subirlo)
manteniendo requests.cpu para la planificación; si hay que limitar, ajustar el nº de hilos de GC y de pools
(-XX:ActiveProcessorCount) para que la JVM no crea disponer de más CPU de la real.