La lección anterior habló todo el rato de "flujos de ejecución" sin comprometerse con qué eran. Ahora toca concretar. En el módulo 2 conociste el proceso: su imagen de memoria, su task_struct, su espacio de direcciones privado protegido por la MMU. Un proceso es una unidad de dos cosas a la vez: posesión de recursos (memoria, ficheros abiertos, credenciales) y ejecución (un contador de programa avanzando por el código).
La idea del hilo consiste en separar esas dos cosas. Un proceso sigue siendo el dueño de los recursos, pero puede tener varios flujos de ejecución dentro, compartiendo todo lo que el proceso posee. Eso abarata brutalmente la creación y la comunicación —y a cambio elimina la red de seguridad de la MMU entre ellos, que es exactamente por lo que existe el resto de este módulo.
Al terminar sabrás con precisión qué comparten y qué no comparten los hilos hermanos, cuánto cuesta cada operación en microsegundos reales, cómo implementa Linux los hilos (una respuesta sorprendente: no los implementa; implementa clone()), cómo se programa con hilos POSIX en C y con threading en Python, qué es realmente el GIL sin mitos, y cómo elegir entre proceso, hilo y bucle de eventos para cada pieza de Meteora.
Contenido
- Qué es un hilo y cuál es su unidad mínima privada
- Qué comparte y qué no comparte un hilo con sus hermanos
- El bloque de control del hilo
- Por qué existen los hilos: los números
- Modelos de implementación: N:1, 1:1 y M:N
- Cómo lo hace Linux de verdad:
clone() - Inspeccionar hilos desde fuera:
ps -eLfy/proc/<pid>/task/ - Hilos POSIX en C: el
ingestorcon cuatro hilos - Hilos en Python, el GIL y
multiprocessing - Grupos de hilos: por qué casi nunca se crean a mano
- Proceso por conexión, hilo por conexión y asíncrono en
meteo-api - Criterios de elección entre hilos y procesos
- Terminación y cancelación de hilos
Qué es un hilo y cuál es su unidad mínima privada
Un hilo (thread, o hilo de ejecución) es la unidad básica de uso de la CPU: un flujo secuencial de instrucciones dentro de un proceso. Un proceso tradicional tiene exactamente un hilo; un proceso multihilo tiene varios ejecutándose sobre el mismo espacio de direcciones.
La pregunta clave para entenderlo es: ¿cuál es la cantidad mínima de estado que necesita un flujo de ejecución para ser independiente de otro? La respuesta es corta:
- Un contador de programa (
%rip): dónde está ejecutando ahora mismo. - Un juego de registros: sus variables en curso.
- Una pila propia: sus variables locales, sus parámetros y su cadena de llamadas.
Nada más. Todo lo demás —el código, los datos globales, el montón, los ficheros abiertos, la tabla de páginas— puede compartirse sin que los flujos dejen de ser independientes.
Esa pila propia merece un momento de atención, porque es la parte que más se olvida. Si dos hilos compartieran la pila, la llamada a función de uno pisaría el marco del otro y el programa se destruiría en microsegundos. Por eso cada hilo recibe su propia región de pila dentro del espacio de direcciones compartido: en Linux, 8 MB de espacio virtual reservado por hilo por defecto (ulimit -s), aunque solo se materializan las páginas físicas que se usan de verdad, gracias a la asignación bajo demanda que viste en el módulo 2.
graph TB
subgraph P["Proceso meteo-api (un espacio de direcciones)"]
COD["Código + datos globales + montón<br/>cache, contadores (COMPARTIDO)"]
FD["Tabla de descriptores: sockets,<br/>meteo-api.log (COMPARTIDA)"]
H1["Hilo 1<br/>%rip + registros<br/>Pila propia (8 MB)"]
H2["Hilo 2<br/>%rip + registros<br/>Pila propia"]
H3["Hilo 3<br/>%rip + registros<br/>Pila propia"]
end
El diagrama contiene toda la lección en una imagen: tres flujos con lo mínimo privado, flotando sobre un océano de memoria común. Ese océano es lo que hace a los hilos rápidos y peligrosos a partes iguales.
Qué comparte y qué no comparte un hilo con sus hermanos
Esta tabla es la referencia que consultarás una y otra vez. Vale la pena leerla entera con atención, porque cada fila tiene una consecuencia práctica.
| Elemento | ¿Compartido entre hilos? | Consecuencia práctica |
|---|---|---|
| Espacio de direcciones (código, datos, montón) | Sí | Un puntero es válido para todos. Cualquier global es una carrera potencial |
Tabla de páginas / mm_struct |
Sí | El cambio entre hilos no vacía el TLB: por eso es 5-10 veces más barato |
| Tabla de descriptores de fichero | Sí | Un hilo abre /var/log/meteora/meteo-api.log, todos pueden escribir. Y un close() afecta a todos |
Directorio de trabajo (cwd) |
Sí | Un chdir() de un hilo cambia las rutas relativas de todos |
Credenciales (UID/GID meteora:meteora) |
Sí | No puedes bajar privilegios de un solo hilo |
Manejadores de señal (sigaction) |
Sí | Hay una tabla de manejadores por proceso, no por hilo |
| PID | Sí | Todos comparten el mismo PID visible; cada uno tiene su TID |
Segmentos mmap, incluido /dev/shm/meteora-cache |
Sí | Un mmap() de un hilo lo ven todos inmediatamente |
Límites de recursos (ulimit) |
Sí | El límite de descriptores es del proceso, no del hilo |
Contador de programa (%rip) |
No | Cada hilo va por su sitio del código |
| Registros de propósito general | No | Se salvan y restauran en el cambio de contexto |
| Pila | No | Variables locales privadas. Es el mecanismo natural de "no compartir" |
| TID (identificador de hilo) | No | gettid(); es lo que ves en /proc/<pid>/task/ |
errno |
No (almacenamiento local) | Desde 1995 es una macro que expande a una variable por hilo |
| Máscara de señales bloqueadas | No | pthread_sigmask(): sí es por hilo, aunque el manejador sea común |
| Prioridad y política de planificación | No | Cada hilo se planifica por separado: chrt -p <TID> |
errno, strtok(), TLS declarado con __thread |
No | Almacenamiento local del hilo (Thread-Local Storage) |
| Estado de cancelación | No | Cada hilo decide si acepta cancelación y cuándo |
Cuatro filas merecen desarrollo porque son fuente constante de errores.
errno es local al hilo, y tiene que serlo. Si fuera global, dos hilos haciendo llamadas al sistema simultáneas se pisarían el código de error y ningún programa multihilo podría comprobar errores de forma fiable. La solución fue convertirlo en una macro: en glibc, #define errno (*__errno_location()), donde __errno_location() devuelve un puntero distinto por hilo, obtenido del bloque TLS al que apunta el registro de segmento %fs. Por eso errno "funciona" en programas multihilo sin que hagas nada, y por eso no puedes hacer int *p = &errno; en un hilo y usarlo desde otro.
Las señales son del proceso, pero la máscara es del hilo. Esta asimetría causa mucha confusión. Hay una única tabla de manejadores: si un hilo instala un manejador para SIGHUP, se lo instala a todos. Pero cada hilo tiene su propia máscara de señales bloqueadas, y cuando llega una señal dirigida al proceso, el núcleo elige un hilo cualquiera que no la tenga bloqueada. Eso significa que no sabes qué hilo la atenderá. El patrón profesional consiste en bloquear la señal en todos los hilos y dedicar uno a recibirlas con sigwait(). Lo veremos aplicado a la recarga de /etc/meteora/meteora.conf en Comunicación entre Procesos (IPC).
La tabla de descriptores compartida es un arma de doble filo. Es cómodo: el hilo 1 acepta un socket y el hilo 2 lo atiende. Pero si el hilo 1 hace close(fd) mientras el hilo 2 está en un read(fd), y el núcleo reasigna ese número a un fichero nuevo, el hilo 2 lee del fichero equivocado. Es una de las carreras más difíciles de encontrar en servidores reales.
El almacenamiento local del hilo (TLS) es la vía de escape cuando quieres una "global" que no se comparta. En C basta con __thread (o _Thread_local en C11): __thread unsigned long peticiones_de_este_hilo = 0; da una copia por hilo, sin sincronización. Es la técnica que sostiene el consejo de la lección anterior: no compartir es mejor que sincronizar bien. Si cada trabajador de meteo-api lleva su contador en TLS y alguien los suma una vez por minuto, la sección crítica desaparece.
El bloque de control del hilo
Igual que cada proceso tiene su bloque de control (el task_struct del módulo 2), cada hilo necesita el suyo: el TCB (Thread Control Block). Contiene exactamente lo que la tabla anterior marcó como "no compartido": el TID; el estado (ejecutando, listo, bloqueado, los mismos del módulo 2); el contexto de registros (%rip, %rsp, generales y SIMD); la base y el tamaño de su región de pila; los datos del planificador (vruntime, política, nice); su máscara de señales; el puntero a su bloque TLS; y un puntero al PCB del proceso, que es por donde llega a todo lo compartido.
La relación es jerárquica: un PCB, varios TCB apuntando a él. Y aquí es donde el diseño se vuelve interesante en Linux, porque Linux no hace exactamente esto, como veremos en el apartado 6.
Por qué existen los hilos: los números
La justificación de los hilos es puramente cuantitativa. Estas son mediciones sobre meteo-01 (Linux 6.1, Xeon a 3,0 GHz), obtenidas con microbenchmarks de 100.000 repeticiones:
| Operación | Coste | Relación |
|---|---|---|
Crear y destruir un proceso (fork + exit + wait) |
~180 µs | referencia |
Crear y destruir un hilo (pthread_create + join) |
~22 µs | 8× más barato |
| Cambio de contexto entre procesos | ~3,5 µs | referencia |
| Cambio de contexto entre hilos del mismo proceso | ~1,2 µs | 3× más barato |
| Comunicar 1 MB entre procesos por tubería | ~180 µs | referencia |
| Comunicar 1 MB entre hilos (mismo puntero) | ~0 µs | inmediato |
| Memoria por proceso inactivo (RSS mínimo) | ~1.400 KB | referencia |
| Memoria por hilo adicional (pila materializada) | ~12 KB | 100× menos |
Las razones de cada diferencia son concretas y se apoyan en lo que ya sabes del módulo 2:
Creación. fork() tiene que duplicar el mm_struct, recorrer todas las áreas de memoria (VMA) del proceso, copiar la tabla de páginas marcando todo como copy-on-write, y duplicar la tabla de descriptores. Un clone() que comparte mm se salta todo eso: incrementa un contador de referencias y ya está. Cuanto más grande sea el proceso, mayor la diferencia: un proceso con 2 GB mapeados tarda mucho más en hacer fork() que uno con 10 MB, mientras que crear un hilo cuesta lo mismo en ambos casos.
Cambio de contexto. Aquí la clave es el TLB. Cambiar entre procesos exige cargar %cr3 con otra tabla de páginas, lo que invalida las entradas del TLB (mitigado, pero no eliminado, por los identificadores PCID de las CPU modernas). Después vienen decenas o cientos de fallos de TLB mientras el proceso nuevo recalienta. Entre hilos del mismo proceso, %cr3 no cambia: el TLB y las cachés siguen siendo válidos. Esa es la mayor parte del factor 3.
Comunicación. Un dato compartido entre hilos no se "envía": está ahí. Pasar un puntero a un buffer de 1 MB cuesta 8 bytes. Entre procesos hay que copiarlo a través del núcleo (dos copias: usuario→núcleo→usuario) o montar memoria compartida explícitamente.
Esos números explican por qué el ingestor de Meteora usa hilos y no procesos para procesar lotes: crea 4 flujos en 88 µs en lugar de 720 µs, y sobre todo se pasa el array de Lectura sin copiar 17 MB.
Modelos de implementación: N:1, 1:1 y M:N
Un hilo puede gestionarlo la biblioteca en espacio de usuario, el núcleo, o ambos. Históricamente ha habido tres modelos.
N:1, hilos de usuario. Toda la gestión ocurre en una biblioteca de espacio de usuario. El núcleo ve un único proceso con un único hilo y no sabe nada. La biblioteca guarda registros, cambia la pila y salta: un cambio de "contexto" cuesta ~100 nanosegundos, sin llamada al sistema.
1:1, hilos de núcleo. Cada hilo de usuario corresponde a una tarea planificable por el núcleo. El núcleo los conoce, los planifica individualmente y los reparte entre núcleos.
M:N, híbrido. M hilos de usuario se multiplexan sobre N hilos de núcleo, con dos niveles de planificación.
| N:1 (usuario) | 1:1 (núcleo) | M:N (híbrido) | |
|---|---|---|---|
| Coste de creación | ~1 µs | ~22 µs | ~1 µs (los ligeros) |
| Coste de cambio | ~0,1 µs | ~1,2 µs | ~0,1 µs dentro del mismo portador |
| Paralelismo real en varios núcleos | No | Sí | Sí |
| Una llamada bloqueante bloquea... | A todos los hilos | Solo a ese hilo | Solo al portador |
| Planificación del núcleo | Ignora los hilos | Justa entre hilos | Dos niveles, difícil de afinar |
| Complejidad de implementación | Baja | Media | Muy alta |
| Ejemplos | Green threads de Java 1.1, GNU Pth | Linux NPTL, Windows, macOS | Solaris antiguo, Go (goroutines), Java 21 (hilos virtuales) |
El defecto letal de N:1 es la tercera fila desde abajo: si un hilo hace read() sobre un socket y se bloquea, el núcleo bloquea todo el proceso, porque solo ve un hilo. Los otros 99 hilos, aunque tuvieran trabajo, se quedan parados. Combinado con la imposibilidad de usar varios núcleos, condenó al modelo para uso general.
M:N resuelve ambos problemas sobre el papel, pero es endiabladamente complejo: hay que interceptar todas las llamadas bloqueantes para migrar el hilo ligero a otro portador, y los dos planificadores toman decisiones que se contradicen. Solaris lo implementó en los 90 y acabó abandonándolo por 1:1. Linux lo intentó (el proyecto NGPT de IBM) y también lo descartó.
Lo interesante es que M:N ha vuelto por la puerta de los lenguajes, no del sistema operativo: las goroutines de Go y los hilos virtuales de Java 21 son M:N implementados en el runtime del lenguaje, que sí controla todos los puntos de bloqueo porque controla toda la biblioteca estándar. Es la misma idea con la pieza que faltaba.
Linux eligió 1:1 con NPTL (Native POSIX Thread Library, 2003) apostando por hacer los hilos de núcleo tan baratos que no compensara complicarse. Los 22 µs de la tabla anterior son el resultado de esa apuesta.
Cómo lo hace Linux de verdad: clone()
Aquí llega la parte que sorprende a casi todo el mundo: el núcleo de Linux no tiene un concepto de "hilo". Tiene tareas (task_struct), y cada tarea decide qué comparte con su creadora. Un "hilo" es simplemente una tarea que comparte el espacio de direcciones; un "proceso" es una tarea que no lo comparte. La misma llamada al sistema crea ambos: clone().
/* Simplificado: la llamada real es clone3() en núcleos modernos */
long clone(unsigned long flags, void *pila, int *ptid, int *ctid, unsigned long tls);Los flags son lo que decide qué se comparte:
| Flag | Qué comparte con el padre |
|---|---|
CLONE_VM |
El espacio de direcciones (mm_struct) — el flag que hace un "hilo" |
CLONE_FS |
Directorio de trabajo, raíz, umask |
CLONE_FILES |
La tabla de descriptores de fichero |
CLONE_SIGHAND |
La tabla de manejadores de señal |
CLONE_THREAD |
El grupo de hilos: mismo PID visible, misma entrega de señales |
CLONE_SYSVSEM |
Los semáforos System V |
CLONE_SETTLS |
Instala el bloque TLS indicado |
CLONE_NEWNS, CLONE_NEWPID, CLONE_NEWNET... |
No comparte: crea espacios de nombres nuevos (base de los contenedores, módulo 6) |
Con esos flags, las dos operaciones clásicas son solo dos combinaciones: fork() es clone(SIGCHLD, ...) —no comparte nada—, y pthread_create() es clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|..., pila, ...). Se comprueba con strace:
$ strace -f -e trace=clone,clone3 ./ingestor_4hilos 2>&1 | head -6
clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD
|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,
child_tid=0x7f2a4c1f8990, parent_tid=0x7f2a4c1f8990,
stack=0x7f2a4b9f8000, stack_size=0x7ffa80}, 88) = 4312Ahí está todo: la lista de flags, la pila de 8 MB (0x7ffa80 bytes) reservada por la biblioteca, y el TID 4312 devuelto.
Esta unificación tiene tres consecuencias muy prácticas:
- El planificador CFS planifica hilos, no procesos. Cuando en el módulo 2 hablamos de
vruntimey de repartir la CPU, la unidad era la tarea. Un proceso con 8 hilos recibe, por defecto, 8 veces más CPU que un proceso monohilo compitiendo con él. - La frontera es un continuo, no un muro. Puedes crear una tarea que comparta la memoria pero no los descriptores, o que comparta los descriptores pero no la memoria. Los contenedores explotan precisamente esa flexibilidad con los flags
CLONE_NEW*(módulo 6). - En
/proclos hilos existen y se ven. Cada uno tiene su directorio, como veremos ahora.
Inspeccionar hilos desde fuera: ps -eLf y /proc/<pid>/task/
Un ps -ef normal esconde los hilos: muestra un proceso, aunque tenga veinte flujos dentro. La opción -L los revela:
$ ps -eLf | head -1; ps -eLf | grep meteo-api | grep -v grep UID PID PPID LWP NLWP C STIME TTY TIME CMD meteora 2841 1 2841 5 0 08:12 ? 00:00:03 /usr/bin/meteo-api meteora 2841 1 2843 5 3 08:12 ? 00:04:11 /usr/bin/meteo-api meteora 2841 1 2844 5 3 08:12 ? 00:04:08 /usr/bin/meteo-api meteora 2841 1 2845 5 3 08:12 ? 00:04:15 /usr/bin/meteo-api meteora 2841 1 2846 5 3 08:12 ? 00:04:09 /usr/bin/meteo-api
Cómo se lee:
PID2841 para las cinco filas: es un único proceso.LWP(Light Weight Process) es el TID de cada hilo. Son distintos: 2841, 2843, 2844, 2845, 2846.NLWP= 5: cinco hilos en total.- El hilo cuyo TID coincide con el PID (2841) es el hilo principal, el que arrancó en
main(). Su tiempo de CPU es 3 segundos frente a los 4 minutos de los demás: es el hilo que acepta conexiones y las reparte, mientras los cuatro trabajadores hacen el trabajo real.
En /proc la estructura es igual de explícita:
$ ls /proc/2841/task/
2841 2843 2844 2845 2846
$ cat /proc/2841/task/2844/comm
api-worker-2
$ cat /proc/2841/task/2844/stat | awk '{print "utime:", $14, "stime:", $15, "nucleo:", $39}'
utime: 24831 stime: 3102 nucleo: 5Un directorio por hilo, con su propio stat, su propio status, su propia stack. Fíjate en que cada hilo tiene sus contadores de tiempo y su núcleo actual. Lo que no encontrarás es un maps distinto por hilo: /proc/2841/task/2844/maps es idéntico a /proc/2841/maps, porque el mapa de memoria es del proceso. Es la demostración práctica de la primera fila de la tabla de compartición.
Un truco muy útil para diagnosticar: top -H muestra hilos en lugar de procesos, y es la forma rápida de descubrir que "el proceso está al 400 %" en realidad significa "un hilo está al 100 % y tres al 100 %" o bien "un hilo está al 400 %... imposible, luego son cuatro". Volveremos a ello en Monitorización y Diagnóstico de Rendimiento.
Hilos POSIX en C: el ingestor con cuatro hilos
Vamos con código real. El ingestor recibe lotes de lecturas y debe validarlas y convertirlas antes de escribirlas en /var/lib/meteora/lecturas/. Es un trabajo perfectamente divisible: cada lectura es independiente de las demás.
/* ingestor_hilos.c — procesar un lote de Lectura con 4 hilos */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define N_HILOS 4
struct Lectura { /* 24 bytes, el glosario del curso */
unsigned int estacion_id;
unsigned long timestamp;
float temperatura, humedad, presion;
};
/* Cada hilo recibe SU trozo. No hay dato compartido escrito por dos hilos. */
struct Trozo {
struct Lectura *lecturas; /* puntero al array compartido */
size_t inicio, fin; /* rango [inicio, fin) exclusivo de este hilo */
size_t validas; /* RESULTADO: solo lo escribe este hilo */
int id;
};
static int lectura_valida(const struct Lectura *l) {
return l->temperatura > -90.0f && l->temperatura < 60.0f
&& l->humedad >= 0.0f && l->humedad <= 100.0f
&& l->presion > 800.0f && l->presion < 1100.0f;
}
void *procesar_trozo(void *arg) {
struct Trozo *t = (struct Trozo *)arg;
t->validas = 0;
for (size_t i = t->inicio; i < t->fin; i++) {
if (lectura_valida(&t->lecturas[i])) t->validas++;
else t->lecturas[i].estacion_id = 0; /* marcar como descartada */
}
printf("[hilo %d] rango [%zu,%zu) validas=%zu\n",
t->id, t->inicio, t->fin, t->validas);
return NULL;
}
int main(void) {
size_t n = 700000;
struct Lectura *lote = malloc(n * sizeof(struct Lectura));
/* ... aquí se rellenaría el lote desde el socket ... */
pthread_t hilos[N_HILOS];
struct Trozo trozos[N_HILOS];
size_t por_hilo = n / N_HILOS;
for (int i = 0; i < N_HILOS; i++) {
trozos[i].lecturas = lote; /* MISMO puntero */
trozos[i].inicio = i * por_hilo;
trozos[i].fin = (i == N_HILOS - 1) ? n : (i + 1) * por_hilo;
trozos[i].id = i;
if (pthread_create(&hilos[i], NULL, procesar_trozo, &trozos[i]) != 0) {
perror("pthread_create");
exit(1);
}
}
size_t total = 0;
for (int i = 0; i < N_HILOS; i++) {
pthread_join(hilos[i], NULL); /* espera y libera recursos */
total += trozos[i].validas; /* seguro: los hilos ya terminaron */
}
printf("Total válidas: %zu de %zu (%.2f%%)\n", total, n, 100.0*total/n);
free(lote);
return 0;
}Se compila con gcc -O2 -pthread ingestor_hilos.c -o ingestor_hilos. El flag -pthread es obligatorio: define _REENTRANT y enlaza la biblioteca, y olvidarlo produce fallos incomprensibles.
Puntos de diseño que conviene entender bien, porque son el patrón correcto:
Todos los hilos reciben el mismo puntero lote. No se copia nada. 17 MB de lecturas compartidos por el coste de cuatro punteros de 8 bytes. Esto es exactamente lo que hace baratos a los hilos.
Cada hilo tiene un rango disjunto. El hilo 0 toca [0, 175000), el 1 toca [175000, 350000), etc. No hay dos hilos que escriban la misma posición, así que no hay condición de carrera pese a compartir el array. Es la técnica más importante de esta lección: partir los datos en lugar de proteger los datos.
El resultado de cada hilo va a su propio struct Trozo. Si los cuatro hicieran total_global++, tendríamos exactamente la carrera de la lección anterior. Al escribir cada uno en su estructura y sumar en el hilo principal después de los pthread_join, la suma es segura sin ninguna primitiva de sincronización.
pthread_join cumple dos funciones: espera a que el hilo termine y libera sus recursos (pila y TCB). Un hilo que termina y nadie hace join se queda como zombi de hilo consumiendo memoria, igual que los procesos zombi del módulo 2. Si no vas a esperar a un hilo, créalo desprendido (pthread_detach o el atributo PTHREAD_CREATE_DETACHED) para que se autolibere.
El pthread_create se comprueba por valor de retorno, no por errno. Es una peculiaridad de la API de pthreads: las funciones devuelven directamente el código de error y no tocan errno. Escribir if (pthread_create(...) < 0) perror(...) es un error clásico que no detecta nada.
Rendimiento medido sobre las 700.000 lecturas del fichero 2026-08-31.dat:
3,49× de 4 posible: eficiencia del 87 %. Es un caso casi ideal precisamente porque no hay estado compartido escrito. En cuanto haya que sincronizar, el número bajará.
Hilos en Python, el GIL y multiprocessing
Python tiene hilos reales del sistema operativo: threading.Thread acaba llamando a pthread_create. Pero el intérprete CPython tiene el GIL (Global Interpreter Lock), un candado global que garantiza que solo un hilo ejecuta bytecode de Python a la vez.
Antes de los mitos, la razón de que exista. La gestión de memoria de CPython usa recuento de referencias: cada objeto lleva un contador que se incrementa y decrementa continuamente. Ese contador es un read-modify-write, exactamente la carrera de la lección anterior. Sin protección, dos hilos manipulando el mismo objeto corromperían el contador y provocarían liberaciones prematuras o fugas. Las opciones eran un candado por objeto (lento por el coste de las operaciones atómicas y arriesgado por interbloqueos) o un solo candado global (simple y rapidísimo para código monohilo). CPython eligió lo segundo en 1992 y arrastra la decisión desde entonces.
Lo esencial y lo que casi nadie dice bien: el GIL se libera durante las operaciones de E/S y durante las llamadas a código C que lo sueltan explícitamente. Eso parte el mundo en dos:
# gil_demo.py — la misma estructura, dos cargas de trabajo distintas
import threading, time, multiprocessing
def cpu_bound(n): # calcular: mantiene el GIL todo el rato
return sum(i * i for i in range(n))
def io_bound(_): # esperar: libera el GIL durante la espera
time.sleep(0.5) # simula leer del socket de una estación
def medir(func, arg, n_hilos, etiqueta):
t0 = time.perf_counter()
hilos = [threading.Thread(target=func, args=(arg,)) for _ in range(n_hilos)]
for h in hilos: h.start()
for h in hilos: h.join()
print(f"{etiqueta:28} {n_hilos} hilos: {time.perf_counter()-t0:.2f} s")
if __name__ == "__main__":
for n in (1, 4): medir(cpu_bound, 20_000_000, n, "CPU-bound (threading)")
for n in (1, 4): medir(io_bound, None, n, "E/S-bound (threading)")
t0 = time.perf_counter()
with multiprocessing.Pool(4) as p:
p.map(cpu_bound, [20_000_000] * 4)
print(f"{'CPU-bound (multiprocessing)':28} 4 procs: {time.perf_counter()-t0:.2f} s")Resultados en meteo-01 (8 núcleos, CPython 3.11):
CPU-bound (threading) 1 hilos: 1.42 s CPU-bound (threading) 4 hilos: 5.88 s ← ¡PEOR que 4× uno solo! E/S-bound (threading) 1 hilos: 0.50 s E/S-bound (threading) 4 hilos: 0.50 s ← escalado perfecto CPU-bound (multiprocessing) 4 procs: 1.55 s ← 3,8× de aceleración
Léelo con calma, porque cada línea dice algo:
| Caso | Resultado | Por qué |
|---|---|---|
| CPU con 1 hilo | 1,42 s | Referencia |
| CPU con 4 hilos | 5,88 s (≈ 4,1× el de uno) | No hay paralelismo: se turnan el GIL. Y además hay un 4 % de sobrecoste por el ir y venir del candado cada 5 ms |
| E/S con 1 hilo | 0,50 s | Referencia |
| E/S con 4 hilos | 0,50 s | Paralelismo perfecto: cada hilo suelta el GIL al entrar en sleep/read, los cuatro esperan a la vez |
| CPU con 4 procesos | 1,55 s | Cada proceso tiene su propio GIL: paralelismo real, 3,8× |
Las conclusiones prácticas, sin mitos:
- "Los hilos de Python son inútiles" es falso. Para trabajo dominado por E/S —que es la mayor parte del código de servidor— escalan perfectamente. El
ingestoresperando 800 sockets es un caso ideal parathreading. - "Los hilos de Python dan paralelismo de CPU" también es falso. Para calcular, hay que usar
multiprocessing, o bibliotecas que suelten el GIL en su código C (NumPy, pandas,hashlib, la compresión dezlib), o extensiones propias. - Añadir hilos a código CPU-bound lo hace más lento, no solo igual. La contención del GIL tiene coste.
El coste de multiprocessing es que los argumentos y resultados se serializan con pickle y viajan por una tubería. Para 17 MB de lecturas eso son unos 180 ms de ida y otros tantos de vuelta: si el cálculo dura menos que eso, sale a perder. La alternativa es multiprocessing.shared_memory, que es memoria compartida POSIX y la veremos en Comunicación entre Procesos (IPC).
Un apunte de futuro que conviene conocer: Python 3.13 introdujo una compilación experimental sin GIL (PEP 703, free-threading), que sustituye el candado global por recuento de referencias sesgado y candados por objeto. Cuando se estabilice, la tabla anterior cambiará. Hasta entonces, el criterio sigue siendo el de arriba.
Grupos de hilos: por qué casi nunca se crean a mano
Los ejemplos anteriores crean hilos, trabajan y los destruyen. En un servidor real eso es un error, por tres razones:
- Coste de creación repetido. 22 µs por hilo parece poco, pero a 1.200 peticiones por segundo son 26 ms por segundo, un 2,6 % de un núcleo tirado en administrativo.
- Sin límite de concurrencia. Un hilo por petición significa que un pico de 5.000 peticiones simultáneas crea 5.000 hilos, 40 GB de espacio virtual de pila y un planificador que se pasa más tiempo cambiando de contexto que trabajando. Es el thread explosion, y tumba servidores.
- Sin control de recursos. Cada hilo puede abrir descriptores, reservar memoria y contactar con la base de datos. Sin techo, no hay dimensionamiento posible.
La solución es el grupo de hilos (thread pool): un número fijo de hilos creados al arrancar que consumen tareas de una cola compartida.
# pool.py — el patrón correcto para meteo-api
from concurrent.futures import ThreadPoolExecutor
import urllib.request
ESTACIONES = [f"http://estacion-{i:03d}.meteora.local/estado" for i in range(1, 81)]
def consultar(url):
with urllib.request.urlopen(url, timeout=2) as r:
return url, r.status
# 8 hilos atienden 80 tareas: nunca hay más de 8 conexiones simultáneas
with ThreadPoolExecutor(max_workers=8, thread_name_prefix="meteo") as pool:
for url, estado in pool.map(consultar, ESTACIONES):
print(f"{url} -> {estado}")Qué gana este código respecto a crear 80 hilos:
- Se crean 8 hilos una vez, no 80. El coste de creación se amortiza entre todas las tareas.
- El paralelismo está acotado a 8. Las estaciones no reciben 80 conexiones de golpe, y el proceso no dispara su consumo.
- La cola actúa de amortiguador. Si llegan tareas más rápido de lo que se procesan, se encolan en lugar de crear hilos. Es un mecanismo de contrapresión natural.
- El
withgarantiza eljoinde todos los hilos al salir, incluso si hay una excepción.
Dimensionar el grupo tiene una regla útil. Para trabajo de CPU, el tamaño óptimo es el número de núcleos (más hilos solo añaden cambios de contexto). Para trabajo de E/S, la fórmula clásica es:
Para meteo-api: 8 núcleos, cada petición tarda 0,4 ms de CPU y espera 12 ms a la base de datos. Sale 8 × (1 + 12/0,4) = 8 × 31 = 248 hilos. Ese número, y el hecho de que sea tan grande, es justamente lo que motiva el apartado siguiente.
Proceso por conexión, hilo por conexión y asíncrono en meteo-api
meteo-api tiene que atender consultas HTTP. Hay tres arquitecturas clásicas y la elección lo condiciona todo.
Un proceso por conexión. El servidor hace accept() y fork() —while(1){ int cli=accept(...); if(fork()==0){close(escucha); atender(cli); _exit(0);} close(cli); }—. Es el modelo de inetd y de Apache prefork. Máximo aislamiento: si atender una petición provoca un fallo de segmento, muere solo ese proceso. Coste: 180 µs y ~1,4 MB por conexión; con 1.000 conexiones simultáneas, 1,4 GB solo de procesos.
Un hilo por conexión. Igual, pero con pthread_create. 22 µs y unos 12 KB reales por conexión (aunque 8 MB virtuales). Es el modelo de Apache en modo worker y de la mayoría de servidores Java tradicionales. Aguanta bien hasta unos pocos miles de conexiones; a partir de ahí, la memoria y el cambio de contexto se comen la máquina.
Asíncrono con un bucle de eventos. Un solo hilo (o uno por núcleo) que vigila miles de descriptores con epoll y atiende el que esté listo. Es el modelo de nginx, Node.js y asyncio.
# api_async.py — un hilo, miles de conexiones
import asyncio
async def atender(lector, escritor):
datos = await lector.read(1024) # cede el control mientras espera
fila = await consultar_cache(datos) # cede otra vez
escritor.write(respuesta_http(fila))
await escritor.drain()
escritor.close()
async def main():
servidor = await asyncio.start_server(atender, "0.0.0.0", 8080)
async with servidor:
await servidor.serve_forever()
asyncio.run(main())Cada await es un punto donde la corrutina cede el control al bucle, que atiende a otra conexión mientras esta espera. El coste por conexión baja a unos 2-5 KB y no hay cambios de contexto del núcleo.
| Proceso/conexión | Hilo/conexión | Asíncrono | |
|---|---|---|---|
| Coste por conexión | ~1,4 MB, 180 µs | ~12 KB, 22 µs | ~3 KB, ~1 µs |
| Conexiones prácticas | cientos | miles | cientos de miles |
| Aprovecha varios núcleos | Sí | Sí | Solo con un proceso por núcleo |
| Aislamiento ante fallos | Total | Ninguno | Ninguno |
| Riesgo de condiciones de carrera | Bajo | Alto | Muy bajo |
| Una operación lenta afecta a... | Solo esa conexión | Solo ese hilo | A todas |
| Dificultad de programación | Baja | Media | Alta (contagio de async) |
| Ejemplos | Apache prefork, PostgreSQL | Apache worker, Tomcat | nginx, Node.js, Redis |
La fila que más decisiones determina es la penúltima: en el modelo asíncrono, una llamada bloqueante o un cálculo largo congela el servidor entero. Un time.sleep(1) en lugar de await asyncio.sleep(1) dentro de una corrutina detiene las 10.000 conexiones durante un segundo. Es el fallo número uno en código asíncrono, y no da error: solo latencia inexplicable.
La decisión de Meteora, ahora justificada: meteo-api usa un grupo de 8 hilos porque cada petición hace consultas a la caché que tardan milisegundos y el número de clientes simultáneos es de cientos, no de decenas de miles; el aislamiento no compensa el coste de procesos. El ingestor, en cambio, mantiene 800 conexiones permanentes que están casi siempre inactivas —cada estación envía una vez por minuto—: es el caso perfecto para epoll asíncrono, porque 800 hilos dormidos serían 800 pilas y 800 tareas en el planificador para nada.
Criterios de elección entre hilos y procesos
| Criterio | Elige procesos si... | Elige hilos si... |
|---|---|---|
| Aislamiento ante fallos | Un fallo no debe tumbar el servicio (navegadores, plugins, código no confiable) | Un fallo puede tumbar todo, es aceptable |
| Volumen de datos compartidos | Poco y bien delimitado | Mucho (17 MB de lecturas) o estructuras complejas |
| Frecuencia de creación | Baja (arranque, o unos pocos por minuto) | Alta (miles por segundo) |
| Seguridad | Necesitas separar privilegios o aplicar seccomp distinto por pieza |
Todo el código es igual de confiable |
| Lenguaje | Python con carga de CPU (GIL) | C, Rust, Java, Go; o Python con E/S |
| Depuración | Prefieres poder aislar y depurar cada pieza | Estás dispuesto a lidiar con carreras |
| Escalado horizontal futuro | Quieres poder mover una pieza a otra máquina | El servicio será siempre local |
En Meteora, ingestor, agregador y meteo-api son procesos separados por tres razones acumuladas: si el agregador falla procesando un fichero corrupto, la API sigue sirviendo; el ingestor necesita permisos de escritura en /var/lib/meteora/lecturas/ que la API no debe tener; y mañana el agregador podría moverse a otra máquina sin cambiar nada del diseño. Dentro de cada proceso, hilos, porque ahí el trabajo comparte datos y el aislamiento no aporta.
Terminación y cancelación de hilos
Un hilo puede terminar de cuatro formas: haciendo return de su función (el valor lo recoge pthread_join); llamando a pthread_exit(valor), igual pero desde cualquier profundidad de la pila; recibiendo un pthread_cancel(tid) de otro hilo; o porque cualquier hilo del proceso llame a exit().
Esa última posibilidad es crítica y sorprende: exit() no termina el hilo, termina el proceso entero. Si un hilo trabajador llama a exit(1) al detectar un error, se lleva por delante a los otros tres y las peticiones que estuvieran atendiendo. En un hilo trabajador se hace return NULL o pthread_exit(), nunca exit().
La cancelación es el mecanismo más delicado de pthreads. pthread_cancel(tid) no mata al hilo: envía una solicitud que el hilo atiende según su configuración. Con el tipo PTHREAD_CANCEL_DEFERRED (el de por defecto), solo surte efecto al llegar a un punto de cancelación: read, write, sleep, pthread_cond_wait, accept y unas decenas más de funciones que pueden bloquear. Con PTHREAD_CANCEL_ASYNCHRONOUS, en cualquier instrucción, lo que es casi siempre una mala idea: el hilo puede morir con un mutex tomado o con memoria a medio reservar. Y un hilo puede rechazarla temporalmente con pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &viejo) mientras hace algo indivisible.
El problema de fondo es la limpieza: si el hilo muere en mitad de una función, ¿quién libera lo que había reservado? Existen pthread_cleanup_push/pop para registrar manejadores, pero es un mecanismo frágil y fácil de olvidar. Por eso la práctica profesional evita pthread_cancel y usa terminación cooperativa: una bandera que el hilo consulta y un mecanismo para despertarlo si está bloqueado.
volatile sig_atomic_t parar = 0; /* la escribe el hilo principal */
void *trabajador(void *arg) {
while (!parar) {
struct Peticion *p = sacar_de_la_cola(); /* con tiempo límite */
if (p) atender(p);
}
liberar_recursos_propios(); /* limpieza garantizada */
return NULL;
}El hilo decide cuándo parar, en un punto donde sabe que su estado es consistente. Es más código, pero es el único enfoque que funciona de forma fiable. Y la bandera, para ser correcta del todo, debería ser un atomic_int, no un volatile: lo justificaremos en Sincronización y Exclusión Mutua.
Errores Comunes y Consejos
Olvidar -pthread al compilar. Sin él, glibc puede enlazar versiones no reentrantes de algunas funciones y errno puede no ser local al hilo. Los síntomas son aleatorios e inexplicables. Es -pthread (no -lpthread), y va tanto en la compilación como en el enlazado.
Comprobar errores de pthreads con errno. Las funciones pthread_* devuelven el código de error como valor de retorno y no tocan errno. Lo correcto es int rc = pthread_create(...); if (rc != 0) fprintf(stderr, "%s\n", strerror(rc));.
Pasar la dirección de una variable de bucle al hilo. El error clásico: for (int i=0;i<4;i++) pthread_create(&h[i], NULL, f, &i);. Los cuatro hilos reciben el mismo puntero y leen el valor de i cuando les toca ejecutar, que puede ser 4 en todos. Pasa un puntero a un elemento de un array que sobreviva (como el trozos[i] del ejemplo) o el valor convertido a void *.
No hacer join ni detach. El hilo terminado conserva su TCB y su pila hasta que alguien recoja su estado. En un servidor que crea hilos continuamente, eso es una fuga de memoria que crece hasta agotar la máquina.
Llamar a exit() desde un hilo trabajador. Mata el proceso entero. Usa return o pthread_exit().
Usar funciones no reentrantes. strtok, asctime, getpwnam, gmtime o rand guardan estado en variables estáticas compartidas y devuelven basura si dos hilos las llaman a la vez. Usa siempre las variantes _r: strtok_r, gmtime_r, getpwnam_r, rand_r.
Consejo: parte los datos antes que proteger los datos. El ejemplo del ingestor alcanza 3,49× de 4 posible sin una sola primitiva de sincronización, porque cada hilo trabaja sobre un rango disjunto y escribe su resultado en su propia estructura. Cuando puedas particionar, particiona: es más rápido y no puede fallar.
Consejo: da nombre a tus hilos. pthread_setname_np(pthread_self(), "api-worker-2") (máximo 15 caracteres) hace que top -H, ps -eLf y gdb muestren nombres legibles en lugar de repetir el del ejecutable. Cuando estés depurando un cuelgue a las tres de la mañana, lo agradecerás.
Consejo: no crees hilos por trabajo, crea un grupo. El coste de creación, el riesgo de explosión de hilos y la falta de contrapresión hacen que crear hilos a demanda sea casi siempre un error en un servicio.
Ejercicios
Ejercicio 1: demostrar qué se comparte y qué no
Escribe un programa en C con dos hilos que compruebe experimentalmente tres afirmaciones de la tabla de compartición: (a) una variable global es compartida, (b) una variable local de la función del hilo es privada, (c) una variable __thread es privada aunque sea global. Además, imprime desde cada hilo su TID (gettid()) y su PID (getpid()) para comprobar que el PID coincide. Verifica el resultado con ls /proc/<pid>/task/.
Ejercicio 2: medir el GIL
Escribe un programa en Python que mida el tiempo de una tarea de CPU (por ejemplo, contar cuántas de un millón de Lectura simuladas son válidas) con 1, 2, 4 y 8 hilos usando threading, y con 1, 2, 4 y 8 procesos usando multiprocessing. Construye la tabla comparativa y explica cada columna. Después repite el experimento de hilos sustituyendo el cálculo por time.sleep(0.25) y compara.
Ejercicio 3: elegir arquitectura
Meteora quiere añadir un componente nuevo, meteo-alertas, que mantiene una conexión WebSocket abierta con cada cliente suscrito para enviarle avisos de temperatura extrema. Se esperan 15.000 clientes conectados simultáneamente, con muy poco tráfico (un mensaje cada varios minutos), y cada mensaje requiere 0,2 ms de CPU. Elige entre proceso por conexión, hilo por conexión y asíncrono. Justifica con números de memoria y de cambios de contexto, y calcula cuánta CPU total consumiría el servicio.
Soluciones
Solución 1
/* que_comparten.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/syscall.h>
int global = 0; /* (a) compartida */
__thread int tls = 0; /* (c) una copia por hilo */
void *hilo(void *arg) {
int local = 0; /* (b) en la pila, privada */
int id = *(int *)arg;
for (int i = 0; i < 5; i++) { global++; local++; tls++; }
printf("hilo %d: PID=%d TID=%ld | global=%d local=%d tls=%d | &local=%p\n",
id, getpid(), syscall(SYS_gettid), global, local, tls, (void *)&local);
sleep(2); /* para poder mirar /proc mientras vive */
return NULL;
}
int main(void) {
pthread_t h1, h2;
int id1 = 1, id2 = 2;
printf("main: PID=%d TID=%ld\n", getpid(), syscall(SYS_gettid));
pthread_create(&h1, NULL, hilo, &id1);
pthread_create(&h2, NULL, hilo, &id2);
pthread_join(h1, NULL); pthread_join(h2, NULL);
printf("main: global=%d tls=%d\n", global, tls);
return 0;
}Salida típica:
main: PID=5120 TID=5120 hilo 1: PID=5120 TID=5121 | global=5 local=5 tls=5 | &local=0x7f3a4bff8e5c hilo 2: PID=5120 TID=5122 | global=10 local=5 tls=5 | &local=0x7f3a4b7f7e5c main: global=10 tls=0
Análisis línea a línea:
global: el hilo 1 ve 5, el hilo 2 ve 10 (los suyos más los del otro), ymainve 10. Es compartida. Los valores intermedios varían entre ejecuciones, porqueglobal++es la carrera de la lección anterior; con más iteraciones el total final sería menor que 10.local: ambos ven 5, y sus direcciones difieren en unos 8 MB (0x7f3a4bff8e5cfrente a0x7f3a4b7f7e5c, exactamente el tamaño de pila por defecto). Cada hilo tiene su propia pila, y la separación entre ellas confirma la reserva de 8 MB por hilo.tls: ambos hilos ven 5, ymainve 0, aunquetlsestá declarada como global.__threadcrea una copia por hilo, y el hilo principal tiene la suya.- PID idéntico (5120), TID distintos (5120, 5121, 5122): un proceso, tres tareas; el TID del hilo principal coincide con el PID. Y mientras duermen,
ls /proc/5120/task/lista exactamente esos tres TID.
Solución 2
# medir_gil.py
import threading, multiprocessing, time, random
def contar_validas(n):
val = 0
for i in range(n):
t = -90 + (i * 37 % 150) # temperatura simulada
if -90 < t < 60: val += 1
return val
def esperar(_):
time.sleep(0.25)
TRABAJO = 4_000_000
def con_hilos(func, arg, n):
t0 = time.perf_counter()
hs = [threading.Thread(target=func, args=(arg,)) for _ in range(n)]
[h.start() for h in hs]; [h.join() for h in hs]
return time.perf_counter() - t0
def con_procesos(func, arg, n):
t0 = time.perf_counter()
with multiprocessing.Pool(n) as p:
p.map(func, [arg] * n)
return time.perf_counter() - t0
if __name__ == "__main__":
print(f"{'N':>3} {'hilos CPU':>10} {'procs CPU':>10} {'hilos E/S':>10}")
for n in (1, 2, 4, 8):
print(f"{n:>3} {con_hilos(contar_validas,TRABAJO,n):>10.2f}"
f" {con_procesos(contar_validas,TRABAJO,n):>10.2f}"
f" {con_hilos(esperar,None,n):>10.2f}")Resultados en meteo-01 (8 núcleos):
| N | hilos CPU | procs CPU | hilos E/S |
|---|---|---|---|
| 1 | 0,31 s | 0,36 s | 0,25 s |
| 2 | 0,64 s | 0,38 s | 0,25 s |
| 4 | 1,33 s | 0,41 s | 0,25 s |
| 8 | 2,81 s | 0,52 s | 0,25 s |
Columna "hilos CPU": el tiempo crece linealmente con el número de hilos, y un poco más que linealmente (2,81 s frente a los 2,48 s que serían 8×0,31). No hay ningún paralelismo: los hilos se turnan el GIL, que se cede cada 5 ms por defecto (sys.setswitchinterval()), y ese trasiego añade un 13 % de sobrecoste. Ocho hilos hacen el trabajo de ocho, uno detrás de otro, pagando además el peaje.
Columna "procs CPU": el tiempo apenas sube (0,36 → 0,52 s) porque los 8 procesos corren de verdad en los 8 núcleos, cada uno con su propio GIL. La subida que hay se debe al arranque de los procesos y a la serialización, no al cálculo. Con 8 procesos se hace 8 veces más trabajo en 1,4 veces más tiempo: 5,5× de aceleración efectiva.
Columna "hilos E/S": constante en 0,25 s con 1 y con 8 hilos. time.sleep() libera el GIL, así que las ocho esperas transcurren simultáneamente. Es el escenario en el que threading es exactamente la herramienta correcta.
Regla que se deduce: en CPython, threading para esperar y multiprocessing para calcular.
Solución 3
Datos: 15.000 conexiones simultáneas, un mensaje cada 5 minutos por cliente, 0,2 ms de CPU por mensaje.
Carga real de CPU. 15.000 clientes / 300 s = 50 mensajes/s. A 0,2 ms cada uno: 10 ms de CPU por segundo, el 1 % de un núcleo. El servicio no tiene ningún problema de cálculo: su problema es mantener 15.000 conexiones inactivas.
Proceso por conexión. 15.000 × 1,4 MB = 21 GB de RSS, imposible en meteo-01, más 15.000 procesos en la cola del planificador. Descartada.
Hilo por conexión. 15.000 × 12 KB de pila materializada = 180 MB de RSS, más unos 10 KB de task_struct y pila de núcleo por hilo: otros 150 MB. Total ~330 MB para usar el 1 % de un núcleo. Funciona, pero mantiene 15.000 tareas en el planificador, casi todas bloqueadas en read(), y cada despertar arrastra migraciones entre núcleos y fallos de caché.
Asíncrona. Un descriptor y unos 3 KB de estado por conexión: 45 MB, un único hilo, cero cambios de contexto entre conexiones. epoll_wait() devuelve solo los descriptores listos, así que el coste es O(eventos), no O(conexiones). Con 50 eventos por segundo, el bucle está prácticamente dormido.
| Procesos | Hilos | Asíncrono | |
|---|---|---|---|
| RSS estimado | 21 GB | 330 MB | 45 MB |
| Tareas en el planificador | 15.000 | 15.000 | 1 |
| Uso de CPU | 1 % + sobrecoste | 1 % + sobrecoste | 1 % |
| Viable | No | Sí, a coste alto | Sí, holgado |
Elección: asíncrono, y no está reñido con nada. Es el caso de libro para un bucle de eventos: muchísimas conexiones, casi todas inactivas, muy poco cálculo por mensaje. Es exactamente el mismo perfil que el ingestor con sus 800 estaciones, y la misma razón por la que nginx atiende cientos de miles de conexiones en una máquina modesta.
La única precaución, y hay que tomarla en serio: ninguna operación del manejador puede bloquear. Si al detectar una alerta hay que escribir en la base de datos, esa escritura debe ser asíncrona o delegarse a un pequeño grupo de hilos con run_in_executor(). Un INSERT síncrono de 40 ms congelaría las 15.000 conexiones durante ese tiempo.
Conclusión
Un hilo es la unidad de ejecución dentro de un proceso, y su estado privado mínimo es sorprendentemente pequeño: contador de programa, registros y pila propia. Todo lo demás lo comparte con sus hermanos: el espacio de direcciones, la tabla de descriptores, el directorio de trabajo, las credenciales meteora:meteora, los manejadores de señal y los mapeos como /dev/shm/meteora-cache. Quedan fuera, además de la pila y los registros, el TID, la máscara de señales, la prioridad y errno, que desde 1995 es una macro sobre almacenamiento local del hilo precisamente porque un errno global haría imposible programar con hilos.
Los hilos existen por razones cuantitativas que hemos medido: crear uno cuesta 22 µs frente a los 180 µs de un proceso, cambiar entre hilos cuesta 1,2 µs frente a 3,5 µs porque no hay que recargar %cr3 ni invalidar el TLB, y compartir 1 MB entre hilos cuesta cero frente a los 180 µs de copiarlo entre procesos. De los tres modelos de implementación, N:1 murió por bloquear todo el proceso en cada llamada bloqueante, M:N por su complejidad —aunque ha vuelto en los runtimes de Go y Java—, y Linux eligió 1:1 con NPTL.
Y la revelación de la lección: Linux no implementa hilos. Implementa clone(), y un hilo es una tarea creada con CLONE_VM|CLONE_THREAD|CLONE_FILES|... mientras que un proceso es una tarea creada sin esos flags. De ahí que el planificador reparta CPU entre hilos y no entre procesos, que la frontera sea un continuo que los contenedores explotarán en el módulo 6, y que cada hilo tenga su directorio en /proc/<pid>/task/ visible con ps -eLf.
En C, pthread_create/pthread_join y el patrón que importa: los cuatro hilos del ingestor alcanzan 3,49× de aceleración sin una sola primitiva de sincronización, porque cada uno trabaja sobre un rango disjunto del array de Lectura y escribe su resultado en su propia estructura. Partir los datos gana a proteger los datos. En Python, el GIL hace que 4 hilos con carga de CPU tarden 5,88 s frente a 1,42 s de uno solo —peor, no igual— mientras que con carga de E/S escalan perfectamente; para calcular, multiprocessing da 3,8×. Los grupos de hilos evitan el coste de creación repetido y la explosión de hilos, y el dimensionado sale de núcleos × (1 + espera/cpu). Entre las tres arquitecturas de servidor, meteo-api usa hilos y el ingestor usa epoll, cada uno por su perfil de carga. Y la terminación se hace de forma cooperativa, nunca con pthread_cancel, y jamás con exit() desde un trabajador.
Pero en toda la lección hemos hecho trampa. Los cuatro hilos del ingestor no compartían nada escrito, y por eso funcionaban. En cuanto haya que compartir de verdad, y sobre todo en cuanto los flujos sean procesos separados —como ingestor, agregador y meteo-api, que no comparten espacio de direcciones—, hace falta un mecanismo explícito para que se pasen datos. ¿Cómo le envía el ingestor un lote de lecturas al agregador, si son procesos distintos con memorias separadas? ¿Qué es realmente una tubería por dentro? ¿Y cómo se le dice a un proceso "recarga tu configuración" sin pararlo?
Es el turno de Comunicación entre Procesos (IPC).
Fundamentos de Sistemas Operativos
Módulo 1: Introducción a los Sistemas Operativos
- Conceptos Básicos de Sistemas Operativos
- Historia y Evolución de los Sistemas Operativos
- Tipos de Sistemas Operativos
- Funciones Principales de un Sistema Operativo
- Arquitectura del Núcleo: Monolítico, Microkernel e Híbrido
- Modo Usuario, Modo Núcleo y Llamadas al Sistema
Módulo 2: Gestión de Recursos
- Gestión de Procesos
- Planificación de la CPU
- Gestión de Memoria
- Memoria Virtual y Paginación
- Gestión de Almacenamiento
- Gestión de Dispositivos
- Controladores, Interrupciones y Operaciones de E/S
Módulo 3: Concurrencia
- Conceptos de Concurrencia
- Hilos y Procesos
- Comunicación entre Procesos (IPC)
- Sincronización y Exclusión Mutua
- Problemas Clásicos de Concurrencia
- Interbloqueos: Prevención, Detección y Recuperación
Módulo 4: Estructuras de Archivos
- Sistemas de Archivos
- Estructuras de Directorios
- Particiones, Montaje y Sistema de Archivos Virtual
- Gestión de Archivos
- Asignación de Espacio, Journaling e Integridad
- Seguridad y Permisos de Archivos
Módulo 5: Protección y Seguridad del Sistema
- Principios de Protección y Control de Acceso
- Usuarios, Autenticación y Escalada de Privilegios
- Amenazas Comunes y Endurecimiento del Sistema
- Auditoría, Registros y Respuesta a Incidentes
Módulo 6: Virtualización y Contenedores
- Virtualización: Hipervisores y Máquinas Virtuales
- Contenedores: Namespaces y cgroups
- El Sistema Operativo en la Nube
- Sistemas Operativos Móviles y de Tiempo Real
