Procesos e hilos
"Un proceso es como una pupusería abierta. Un programa es como la receta que está pegada en la pared. Podés tener cinco pupuserías ejecutando la misma receta — son distintos procesos."
Qué vas a aprender en este capítulo
Vas a entender la unidad básica de ejecución del SO: el proceso. Vas a aprender qué información guarda el kernel sobre cada uno (el famoso PCB), cuáles son sus estados, y cómo se crea uno (con fork y exec en Unix). Después vas a conocer a los hilos — procesos "ligeros" que comparten memoria — y vas a tener tu primer encuentro con el demonio de la concurrencia: las race conditions.
2.1 La idea: programa vs proceso
💡 Intuición
Un programa es un archivo en el disco. Un proceso es un programa en ejecución.
Distinción crucial:
- El programa es estático: bytes en disco que componen instrucciones.
- El proceso es dinámico: instrucciones cargadas en memoria, registros con valores, posición actual de ejecución, archivos abiertos, etc.
Podés tener el mismo programa ejecutándose como muchos procesos distintos. Si abrís Firefox dos veces, hay un solo programa pero dos procesos. Si abrís 30 ventanas de Chrome, hay decenas de procesos del mismo programa.
Cada proceso tiene un estado privado: su memoria, sus variables, su "punto" de ejecución. Si Firefox-1 escribe en la barra de URL, Firefox-2 ni se entera.
¿Por qué importa la distinción? Porque entender que "ejecutar" es una acción y no una cosa te abre la mente para ver cómo se gestionan recursos: el SO no administra programas (los archivos no consumen CPU); administra procesos (los procesos sí).
Analogía pedagógica: un programa es un libro de cocina. Un proceso es una pupusería con esa receta abierta sobre el mostrador, los ingredientes ya en la mesa, las pupusas a medio hacer en el comal. Dos pupuserías idénticas son dos procesos del mismo "programa".
2.2 Anatomía de un proceso
📐 Fundamento
Cuando el SO crea un proceso, le asigna memoria virtual dividida en regiones:
+--------------------+ <- direcciones altas
| Pila (stack) | ← variables locales, llamadas a función
| ↓ crece hacia |
| abajo |
+--------------------+
| ... |
+--------------------+
| ↑ crece hacia |
| arriba |
| Heap (montón) | ← memoria dinámica (malloc, new)
+--------------------+
| BSS | ← variables globales NO inicializadas
+--------------------+
| Data | ← variables globales inicializadas
+--------------------+
| Text | ← código del programa (instrucciones)
+--------------------+ <- direcciones bajas
Cada región:
- Text: las instrucciones (código compilado). Solo lectura — proteger contra que el programa se modifique a sí mismo.
- Data: globales inicializadas (
int x = 5;). - BSS: globales no inicializadas (se inicializan a 0 automáticamente).
- Heap: memoria pedida con
malloc(C) onew(C++) o creada implícitamente en lenguajes de alto nivel. - Stack: una pila por hilo (al menos una). Crece y decrece con las llamadas a función.
Además del espacio de memoria, el kernel mantiene una estructura de datos por cada proceso, llamada Process Control Block (PCB) o task_struct (en Linux).
El PCB contiene:
| Campo | Qué guarda |
|---|---|
| PID | Process ID, identificador único |
| Estado | Listo, ejecutando, bloqueado, etc. |
| Registros guardados | Para retomar la ejecución cuando vuelva al CPU |
| PC (program counter) | Dirección de la próxima instrucción a ejecutar |
| Tabla de páginas | Mapeo de memoria virtual a física |
| Lista de archivos abiertos | Cada archivo abierto tiene su file descriptor |
| Padre y hijos | El árbol de procesos |
| Permisos | UID, GID, capabilities |
| Recursos consumidos | Tiempo de CPU, memoria, etc. |
| Señales pendientes | Como SIGTERM, SIGINT |
En Linux podés ver mucho de esto en /proc/<pid>/:
$ cat /proc/$$/status | head
Name: bash
State: S (sleeping)
Tgid: 12345
Pid: 12345
PPid: 12340
Uid: 1000 1000 1000 1000
...
$$ es una variable especial del shell que vale "mi propio PID".
2.3 Estados de un proceso
📐 Fundamento
Un proceso pasa por varios estados durante su vida:
| Estado | Significado |
|---|---|
| Nuevo | Recién creado, todavía no en cola de listos. |
| Listo (Ready) | Esperando turno de CPU. |
| Ejecutando (Running) | En la CPU ahora mismo. Solo uno por core. |
| Bloqueado (Blocked / Waiting) | Esperando algo (E/S, lock, señal). |
| Terminado (Zombie / Exit) | Ya terminó, esperando que el padre lo "coseche". |
Transiciones más comunes:
- Nuevo → Listo: cuando el SO termina de configurarlo.
- Listo → Ejecutando: lo elige el planificador.
- Ejecutando → Listo: se le acabó el cuanto, hay otro más prioritario.
- Ejecutando → Bloqueado: llamó a
ready el dato no está, espera. - Bloqueado → Listo: llegó el dato, vuelve a la cola.
- Ejecutando → Terminado: llamó a
exit().
Zombie. Estado curioso: el proceso terminó pero su PCB sigue ahí, esperando que el padre lo lea con wait(). Si el padre no lo cosecha, queda como zombie (no consume CPU pero ocupa una entrada en la tabla de procesos). Bug clásico en programas mal hechos: olvidar wait, llenar la tabla.
2.4 Crear procesos: fork
📐 Fundamento
En Unix, toda la creación de procesos ocurre con la syscall fork. Es una de las llamadas más fascinantes del kernel.
Qué hace fork: crea una copia exacta del proceso actual. Dos procesos idénticos quedan ejecutando el mismo código, con la misma memoria, los mismos archivos abiertos, a partir del punto donde se llamó a fork.
#include <stdio.h>
#include <unistd.h>
int main() {
printf("Antes del fork. PID = %d\n", getpid());
pid_t pid = fork();
if (pid == 0) {
printf("Soy el HIJO. PID = %d, padre = %d\n",
getpid(), getppid());
} else if (pid > 0) {
printf("Soy el PADRE. PID = %d, hijo = %d\n",
getpid(), pid);
} else {
printf("Error en fork\n");
}
return 0;
}
Salida típica:
Antes del fork. PID = 1234
Soy el PADRE. PID = 1234, hijo = 1235
Soy el HIJO. PID = 1235, padre = 1234
Cómo distingue padre de hijo. fork() devuelve dos veces:
- En el padre, devuelve el PID del hijo (un número positivo).
- En el hijo, devuelve 0.
- En caso de error, devuelve -1 (en el padre).
Eso es un patrón único en programación. Por eso el if (pid == 0) arriba.
Después del fork, los dos procesos son independientes: cambios en uno no se ven en el otro. Cada uno tiene su propia copia de la memoria.
Optimización: copy-on-write. Copiar toda la memoria al hacer fork sería caro. Linux usa copy-on-write (COW): ambos procesos comparten físicamente las mismas páginas hasta que uno escribe — entonces el kernel hace una copia. Si nunca se escribe, nunca se copia. Por eso fork en Linux es muy rápido aunque la memoria sea grande.
2.5 Cargar otro programa: exec
📐 Fundamento
fork te da dos procesos iguales. Pero generalmente querés que el hijo ejecute otro programa. Para eso está exec.
Qué hace exec: reemplaza la imagen del proceso actual con la de otro programa. La memoria, el PC, las pilas — todo se sustituye. El PID NO cambia.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
/* Hijo: reemplazar con `ls -la` */
execlp("ls", "ls", "-la", NULL);
/* Si llegamos acá, exec falló */
perror("exec");
return 1;
} else if (pid > 0) {
/* Padre: esperar al hijo */
int status;
waitpid(pid, &status, 0);
printf("Hijo terminó con código %d\n", WEXITSTATUS(status));
}
return 0;
}
Cómo lo lee bash. Cuando escribís ls -la en la terminal, el shell hace exactamente esto:
fork()para crear un hijo.- En el hijo,
execlp("ls", "ls", "-la", NULL)para reemplazarlo conls. - En el padre,
waitpid()para esperar a quelstermine. - Cuando termina, el padre vuelve a mostrar el prompt.
Variantes de exec:
| Función | Diferencia |
|---|---|
execl |
Argumentos como lista variable: execl(prog, arg0, arg1, ..., NULL) |
execv |
Argumentos como array: execv(prog, argv) |
execlp / execvp |
Buscan el ejecutable en $PATH |
execle / execve |
Aceptan environment custom |
execve es la "real" — las otras son envoltorios sobre ella.
2.6 Esperar y comunicación entre procesos
📐 Fundamento
Wait
Cuando un proceso termina, su PCB queda hasta que el padre llame a wait() o waitpid(). Ahí el padre obtiene el estado de salida y libera el PCB.
int status;
pid_t hijo = wait(&status); /* espera CUALQUIER hijo */
if (WIFEXITED(status)) {
int code = WEXITSTATUS(status);
printf("Hijo %d terminó normalmente con código %d\n", hijo, code);
} else if (WIFSIGNALED(status)) {
int sig = WTERMSIG(status);
printf("Hijo %d murió por señal %d\n", hijo, sig);
}
Si el padre no llama wait, el hijo queda zombie. Si el padre muere antes, los hijos son adoptados por PID 1 (init/systemd), que sí los cosecha.
IPC — comunicación entre procesos
Ya vimos que cada proceso tiene su memoria privada. ¿Cómo se comunican? Mecanismos de IPC (Inter-Process Communication):
| Mecanismo | Cuándo se usa |
|---|---|
| Pipes | Comunicación unidireccional padre-hijo (`shell |
| Named pipes (FIFO) | Pipes con nombre, persistentes |
| Sockets | Locales o de red |
| Memoria compartida (shm) | Compartir bytes entre procesos directamente |
| Mensajes (message queues) | Colas con tipos |
| Señales | Notificaciones asíncronas (SIGTERM, etc.) |
Pipes (las más usadas en shell):
$ ls | grep .txt
Bash:
- Hace dos forks (uno para
ls, otro paragrep). - Crea una pipe.
- Conecta
stdoutdel primero astdindel segundo. - Espera ambos.
El kernel garantiza el buffer intermedio. Si grep lee lento, ls se bloquea hasta que haya espacio. Si grep no lee y la pipe se llena, ls espera.
2.7 Hilos
📐 Fundamento
Un hilo (thread) es como un proceso, pero comparte memoria con otros hilos del mismo proceso.
Comparación:
| Aspecto | Proceso | Hilo |
|---|---|---|
| Memoria privada | Sí | Comparten heap, data, text |
| Stack | 1 stack | 1 stack por hilo |
| Costo de crear | Alto | Bajo |
| Comunicación | IPC (caro) | Variables compartidas (barato) |
| Aislamiento | Total | Ninguno (¡cuidado!) |
| Si uno crashea | Solo él muere | Tira el proceso entero |
Modelo mental. Un proceso es una casa: paredes, instalaciones, sus propios muebles. Un hilo es una persona dentro de esa casa: hace su tarea, comparte la cocina y los muebles con los demás hilos. Varias casas (procesos) son completamente independientes; varias personas dentro de la misma casa (hilos) tienen que negociar.
Para qué los hilos. Permiten concurrencia dentro de un programa sin pagar el precio de procesos separados. Casos típicos:
- Un servidor web atiende a 1000 clientes con 1000 hilos en lugar de 1000 procesos.
- Tu navegador renderiza una página en un hilo mientras descarga imágenes en otros.
- Un editor de video procesa cada frame en paralelo.
Crear un hilo en C (POSIX threads):
#include <pthread.h>
#include <stdio.h>
void *trabajo(void *arg) {
int n = *(int *)arg;
for (int i = 0; i < 5; i++) {
printf("Hilo %d, iteración %d\n", n, i);
}
return NULL;
}
int main() {
pthread_t h1, h2;
int a = 1, b = 2;
pthread_create(&h1, NULL, trabajo, &a);
pthread_create(&h2, NULL, trabajo, &b);
pthread_join(h1, NULL);
pthread_join(h2, NULL);
return 0;
}
pthread_create lanza un hilo. pthread_join espera (equivalente a wait para procesos).
En Python (más fácil):
import threading
def trabajo(n):
for i in range(5):
print(f"Hilo {n}, iter {i}")
h1 = threading.Thread(target=trabajo, args=(1,))
h2 = threading.Thread(target=trabajo, args=(2,))
h1.start(); h2.start()
h1.join(); h2.join()
Nota: Python tiene un detalle famoso llamado GIL (Global Interpreter Lock) que impide que dos hilos ejecuten Python real en paralelo. Para CPU-bound usá
multiprocessingo lenguajes sin GIL. Para I/O-bound,threadingestá bien.
2.8 Concurrencia: tu primer encuentro con un bug
⚠️ Trampa común — race conditions
Cuando dos hilos comparten memoria, pueden pisarse. Mirá esto:
int contador = 0;
void *incrementar(void *arg) {
for (int i = 0; i < 1000000; i++) {
contador++; /* ← problema acá */
}
return NULL;
}
int main() {
pthread_t h1, h2;
pthread_create(&h1, NULL, incrementar, NULL);
pthread_create(&h2, NULL, incrementar, NULL);
pthread_join(h1, NULL);
pthread_join(h2, NULL);
printf("Contador final: %d\n", contador);
return 0;
}
Esperás: .
Obtenés: un número aleatorio, generalmente entre y .
¿Por qué? Porque contador++ no es atómico. En realidad son tres pasos:
- Leer
contadora un registro. - Sumar 1.
- Escribir el registro a
contador.
Si los dos hilos ejecutan el paso 1 simultáneamente, los dos leen el mismo valor. Cada uno suma 1. Los dos escriben el mismo valor. Una iteración se perdió.
Eso es una race condition: el resultado depende del orden en que se intercalan los pasos. Es uno de los bugs más insidiosos de la programación concurrente — a veces el programa funciona, a veces no, y es muy difícil reproducir.
Cómo se arregla: con sincronización (locks, mutexes, semáforos). Es el tema completo del capítulo siguiente.
2.9 Comandos prácticos en Linux
🛠️ En la práctica
| Comando | Para qué |
|---|---|
ps aux |
Listar todos los procesos del sistema |
ps -ef |
Idem, formato BSD |
top / htop |
Top dinámico de procesos por CPU/mem |
pstree |
Árbol de procesos (padres e hijos) |
kill <PID> |
Enviar SIGTERM (pedir terminar gentilmente) |
kill -9 <PID> |
Enviar SIGKILL (terminar a la fuerza) |
nice / renice |
Cambiar prioridad |
time <cmd> |
Medir tiempo (real, user, sys) |
strace <cmd> |
Ver syscalls en vivo |
pmap <PID> |
Ver el mapa de memoria de un proceso |
Ejemplo de exploración:
$ pstree -p $$
bash(1234)─┬─pstree(5678)
└─sleep(5679)
$ ps -o pid,ppid,cmd $$
PID PPID CMD
1234 1230 bash
$ cat /proc/$$/maps | head
00400000-00500000 r-xp /usr/bin/bash
00700000-00710000 r--p ...
...
/proc/<pid>/maps te muestra exactamente la memoria del proceso. Es magia.
2.10 Proyecto: mini-shell, parte 1
🏗️ Avance del proyecto
Vamos a empezar a escribir un shell propio. Esta primera versión solo ejecuta comandos simples (sin pipes ni redirección — eso viene después).
Archivo mishell.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_LINE 1024
#define MAX_ARGS 64
int main() {
char linea[MAX_LINE];
char *args[MAX_ARGS];
while (1) {
printf("mishell$ ");
fflush(stdout);
if (fgets(linea, MAX_LINE, stdin) == NULL) {
printf("\n");
break; /* EOF (Ctrl-D) */
}
/* Quitar el \n final */
linea[strcspn(linea, "\n")] = 0;
/* Vacío */
if (linea[0] == 0) continue;
/* Salida */
if (strcmp(linea, "exit") == 0) break;
/* Tokenizar la línea por espacios */
int i = 0;
char *tok = strtok(linea, " ");
while (tok && i < MAX_ARGS - 1) {
args[i++] = tok;
tok = strtok(NULL, " ");
}
args[i] = NULL;
/* Fork + exec */
pid_t pid = fork();
if (pid == 0) {
/* Hijo */
execvp(args[0], args);
/* Si llegamos acá, exec falló */
perror(args[0]);
exit(1);
} else if (pid > 0) {
/* Padre: esperar */
int status;
waitpid(pid, &status, 0);
} else {
perror("fork");
}
}
return 0;
}
Compilar y probar:
$ gcc -Wall -o mishell mishell.c
$ ./mishell
mishell$ ls
archivo1.txt archivo2.txt
mishell$ ps
PID TTY TIME CMD
12345 pts/0 00:00:00 bash
12400 pts/0 00:00:00 mishell
12401 pts/0 00:00:00 ps
mishell$ exit
$
Lo que hace:
- Lee una línea.
- La parte por espacios.
- Hace fork + exec del primer token con el resto como argumentos.
- Espera al hijo.
- Repite.
Lo que NO hace todavía (próximos capítulos):
- Pipes (
|). - Redirección (
>,<). - Comandos en background (
&). - Variables de entorno y
cd(que requiere ser builtin porque no se puede cambiar el directorio del padre desde un hijo). - Globbing (
*.txt).
Ya está corriendo, sin embargo, un shell funcional. Ese código es básicamente lo que hace bash por dentro, simplificado. Cuando uno entiende esto, deja de pensar el shell como mágico.
2.11 Resumen visual
| Concepto | Línea de memoria |
|---|---|
| Programa | Archivo en disco. Estático. |
| Proceso | Programa en ejecución. Dinámico. Tiene memoria, estado, archivos abiertos. |
| PCB | Estructura del kernel con la metadata de un proceso. |
| Hilo | "Mini-proceso" dentro de un proceso. Comparte memoria. |
fork() |
Crea un hijo idéntico al padre. Devuelve dos veces. |
exec() |
Reemplaza el programa actual por otro. Mismo PID. |
wait() |
Padre espera a su hijo. |
| Race condition | Dos hilos tocando lo mismo, resultado impredecible. |
2.12 Ejercicios
✏️ Ejercicio 2.1 — Conteo de procesos
¿Cuántas veces se imprime "hola" cuando se ejecuta este código?
int main() {
fork();
fork();
fork();
printf("hola\n");
return 0;
}
Solución
Cada fork duplica los procesos. Empezás con 1; después del 1er fork hay 2; después del 2do hay 4; después del 3ro hay 8. Imprime 8 veces "hola".
Generalización: forks crean procesos al final.
✏️ Ejercicio 2.2 — fork con if
¿Qué imprime este programa?
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("A\n");
} else {
printf("B\n");
}
printf("C\n");
return 0;
}
Solución
Imprime A, B, y C dos veces — pero el orden no está garantizado, depende del scheduler.
Posibilidades válidas: A C B C, B A C C, A B C C, B C A C, etc.
Imprimir 3 letras (A, B, C, C): el padre imprime B y C, el hijo imprime A y C. Total 4 líneas.
✏️ Ejercicio 2.3 — Zombie
Modificá un programa para que cree intencionalmente un proceso zombie. Verificalo con ps.
Solución
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("Hijo termina inmediato\n");
return 0;
} else {
printf("Padre duerme 60s sin esperar al hijo\n");
sleep(60); /* sin wait! */
}
return 0;
}
En otra terminal:
$ ps -ef | grep -E "STAT|defunct"
Verás el hijo con estado Z (zombie) o <defunct>. Cuando el padre muera (o llame a wait), el zombie desaparece.
✏️ Ejercicio 2.4 — Race condition
Compilá el programa de race condition de la sección 2.8. Ejecutalo varias veces. Documentá:
a. ¿Qué valor obtenés? b. ¿Es siempre el mismo? c. ¿Qué pasa si reducís las iteraciones a 100?
Solución
a. Algo entre 1,000,000 y 2,000,000 — casi nunca exactamente 2,000,000.
b. No — varía entre ejecuciones.
c. Con 100 iteraciones casi siempre da 200 (la race es muy rara con poco trabajo). Con 1,000,000 se nota mucho.
Conclusión: las race conditions son probabilísticas. No se pueden ignorar argumentando "casi siempre funciona".
✏️ Ejercicio 2.5 — Mini-shell + builtin cd
El mini-shell del proyecto no puede cambiar de directorio porque cd modifica el cwd del proceso, y un proceso hijo no puede modificar el del padre. Modificá el mini-shell para que cuando el comando sea cd <dir>, llame directamente a chdir() en el proceso del shell, en lugar de hacer fork+exec.
Solución
Después de tokenizar:
if (args[0] == NULL) continue;
/* Builtin cd */
if (strcmp(args[0], "cd") == 0) {
if (args[1] == NULL) {
chdir(getenv("HOME"));
} else {
if (chdir(args[1]) != 0) perror("cd");
}
continue;
}
/* Builtin exit */
if (strcmp(args[0], "exit") == 0) break;
/* No es builtin, hacer fork+exec */
pid_t pid = fork();
...
cd, exit, export y otros tienen que ser builtins del shell. Cualquier shell real (bash, zsh) hace esto.
2.13 Para profundizar
- Tanenbaum & Bos cap. 2.
- OSTEP ("Operating Systems: Three Easy Pieces"), libro libre y excelente: https://pages.cs.wisc.edu/~remzi/OSTEP/
- APUE ("Advanced Programming in the UNIX Environment", Stevens) — el libro de cabecera para programar Unix a fondo.
- Próximo capítulo: Planificación de CPU — cómo el kernel decide qué proceso corre cuándo.
Definiciones nuevas: programa, proceso, PCB, estado de proceso, zombie, fork, exec, wait, IPC, pipe, hilo, race condition, copy-on-write.