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) o new (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:

Diagrama de estados de un proceso: nuevo, listo, ejecutando, bloqueado, terminado, con transiciones entre ellos. Nuevo Listo Ejecutando Bloqueado Terminado admit scheduler expira cuanto espera I/O I/O lista exit
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 read y 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:

  1. fork() para crear un hijo.
  2. En el hijo, execlp("ls", "ls", "-la", NULL) para reemplazarlo con ls.
  3. En el padre, waitpid() para esperar a que ls termine.
  4. 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:

  1. Hace dos forks (uno para ls, otro para grep).
  2. Crea una pipe.
  3. Conecta stdout del primero a stdin del segundo.
  4. 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 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á multiprocessing o lenguajes sin GIL. Para I/O-bound, threading está 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: 1,000,000+1,000,000=2,000,0001{,}000{,}000 + 1{,}000{,}000 = 2{,}000{,}000.

Obtenés: un número aleatorio, generalmente entre 1,000,0001{,}000{,}000 y 2,000,0002{,}000{,}000.

¿Por qué? Porque contador++ no es atómico. En realidad son tres pasos:

  1. Leer contador a un registro.
  2. Sumar 1.
  3. 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:

  1. Lee una línea.
  2. La parte por espacios.
  3. Hace fork + exec del primer token con el resto como argumentos.
  4. Espera al hijo.
  5. 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;
}

✏️ 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;
}

✏️ Ejercicio 2.3 — Zombie

Modificá un programa para que cree intencionalmente un proceso zombie. Verificalo con ps.

✏️ 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?

✏️ 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.

2.13 Para profundizar


Definiciones nuevas: programa, proceso, PCB, estado de proceso, zombie, fork, exec, wait, IPC, pipe, hilo, race condition, copy-on-write.