Procesos y planificación: del bloque de control al reparto del procesador
Procesos y planificación: del bloque de control al reparto del procesador
En el capítulo 5 desarmamos el kernel: vimos que es código reactivo que despierta ante llamadas al sistema, excepciones e interrupciones, que vive en modo privilegiado y que está organizado en subsistemas. Uno de esos subsistemas quedó nombrado pero no abierto: el gestor de procesos. Es el que decide qué código ocupa el procesador en cada instante, el que crea y destruye tareas, y el que hace posible la ilusión de que veinte programas corren a la vez sobre cuatro núcleos.
Este capítulo abre esa caja respondiendo tres preguntas encadenadas: ¿qué es exactamente un proceso?, no la definición de una línea sino la estructura de datos concreta que el kernel mantiene; ¿cómo se pasa de un proceso a otro?, el mecanismo físico del cambio de contexto, qué se guarda y qué cuesta; y ¿quién decide el orden?, los algoritmos de planificación con sus números, sus trazas y sus consecuencias medibles.
Al terminar vas a poder leer la salida de ps entendiendo cada columna, escribir un intérprete de comandos en C que lance programas de verdad, explicar por qué un proceso queda zombi y calcular a mano el tiempo de espera promedio de cinco algoritmos distintos sobre la misma carga.
Programa y proceso no son lo mismo
Un programa es un archivo. Bytes en disco: código máquina, datos iniciales, tablas de símbolos, metadatos del formato ejecutable. Es estático. Puedes copiarlo, borrarlo, mandarlo por correo. Mientras está en disco no consume procesador ni tiene estado que evolucione.
Un proceso es una instancia en ejecución de ese programa. Es dinámico: tiene un punto de ejecución actual, una pila con las llamadas pendientes, memoria reservada, archivos abiertos, un identificador único y un dueño. Dos procesos pueden nacer del mismo programa y no compartir absolutamente nada de su estado. La distinción es la misma que hay entre una receta y una comida en preparación: la receta está en el libro, intacta, y sirve mil veces; cada preparación concreta tiene su propio punto de avance y su propio resultado.
Abre tres terminales y ejecuta el mismo editor en las tres: hay un programa en disco y tres procesos vivos, cada uno con su archivo abierto, su posición de cursor y su historial de deshacer. Si uno se cae, los otros dos siguen. Compruébalo con ls -l /usr/bin/bash frente a pgrep -c bash: un archivo, muchos procesos.
Formalmente, un proceso es la unidad de posesión de recursos y la unidad de ejecución que el sistema operativo administra. Estas dos responsabilidades se pueden separar —eso es exactamente lo que hacen los hilos, que veremos más adelante— pero en el modelo clásico van juntas.
Qué contiene un proceso
Un proceso vivo tiene cuatro grandes componentes, más una quinta pieza que vive fuera de su espacio de direcciones y que el proceso nunca puede tocar: su bloque de control.
| Componente | Qué es | Dónde vive |
|---|---|---|
| Imagen de código | Las instrucciones máquina que se ejecutan, normalmente de solo lectura y compartibles entre procesos del mismo programa | Espacio de direcciones del proceso, región r-x |
| Datos | Variables globales, memoria reservada dinámicamente, montículo | Espacio de direcciones, regiones rw- |
| Pila | Marcos de llamada, variables locales, dirección de retorno | Espacio de direcciones, región que crece bajo demanda |
| Contexto de ejecución | Contenido de los registros del procesador, incluido el contador de programa y el puntero de pila | Registros mientras corre; bloque de control cuando no corre |
El bloque de control de proceso
El bloque de control de proceso, o PCB por sus siglas en inglés (Process Control Block), es la estructura de datos donde el kernel guarda todo lo que necesita saber de un proceso: es su ficha. Si el PCB se pierde, el proceso deja de existir aunque su memoria siga intacta, porque el kernel ya no sabría cómo reanudarlo. Está en memoria del kernel, no en el espacio del proceso, y esa separación es deliberada: si un proceso pudiera escribir su propio PCB podría cambiarse el usuario dueño, subirse la prioridad al máximo o apuntar su contador de programa a código del kernel.
flowchart TD
subgraph KERNEL["Memoria del kernel"]
TP["Tabla de procesos"]
PCB1["PCB del proceso 1<br/>PID, estado, registros,<br/>prioridad, memoria, archivos"]
PCB2["PCB del proceso 2"]
PCB3["PCB del proceso 3"]
TP --> PCB1
TP --> PCB2
TP --> PCB3
end
subgraph USUARIO["Espacios de direcciones en modo usuario"]
M1["Proceso 1<br/>codigo, datos, pila"]
M2["Proceso 2<br/>codigo, datos, pila"]
M3["Proceso 3<br/>codigo, datos, pila"]
end
PCB1 -. "apunta a" .-> M1
PCB2 -. "apunta a" .-> M2
PCB3 -. "apunta a" .-> M3
style KERNEL fill:#5f1e1e,color:#fff
style USUARIO fill:#1e3a5f,color:#fff
Qué campos guarda
El contenido exacto varía entre sistemas, pero todos los sistemas operativos de propósito general guardan al menos estas categorías:
| Categoría | Campos típicos | Para qué sirve |
|---|---|---|
| Identificación | Identificador del proceso, identificador del padre, identificador de grupo y de sesión, usuario y grupo efectivos | Nombrar el proceso, reconstruir el árbol, aplicar permisos |
| Estado de ejecución | Estado actual, motivo de bloqueo, evento que espera | Saber si es candidato a correr |
| Contexto de procesador | Contador de programa, puntero de pila, registros de propósito general, registro de banderas, registros de coma flotante y vectoriales | Reanudar la ejecución exactamente donde quedó |
| Planificación | Prioridad estática, prioridad dinámica, política, tiempo de procesador consumido, quantum restante, núcleos permitidos | Alimentar al planificador |
| Memoria | Puntero a la tabla de páginas o al descriptor de espacio de direcciones, límites de las regiones | Cambiar de espacio de direcciones al conmutar |
| Entrada/salida | Tabla de descriptores de archivo abiertos, directorio de trabajo, directorio raíz, máscara de creación de archivos | Resolver rutas y operaciones de archivo |
| Contabilidad | Tiempo en modo usuario, tiempo en modo kernel, número de cambios de contexto, fallos de página, uso máximo de memoria | Reportes, límites y facturación |
| Señales | Señales pendientes, señales bloqueadas, manejadores instalados | Entregar notificaciones asíncronas |
| Parentesco | Punteros a hijos, a hermanos, código de salida pendiente de recoger | Implementar la espera del padre |
En Linux esta estructura se llama task_struct y está definida en el código del kernel. Es una de las estructuras más grandes del sistema: del orden de varios kilobytes por proceso. Multiplícalo por los cientos de procesos de un escritorio típico y verás por qué la tabla de procesos es un consumidor de memoria del kernel que se toma en serio.
Cómo verlo en un sistema real
Linux expone gran parte del PCB a través del sistema de archivos virtual /proc. Cada proceso vivo tiene un directorio con su número.
# Ficha legible del proceso actual del shell
grep -E '^(Name|State|Tgid|Pid|PPid|Threads|VmRSS|voluntary_ctxt_switches|nonvoluntary_ctxt_switches)' /proc/$$/status
# Descriptores de archivo abiertos y mapa del espacio de direcciones
ls -l /proc/$$/fd
head -20 /proc/$$/maps
La variable $$ del shell contiene su propio identificador de proceso. Threads te dice cuántos hilos tiene ese proceso. voluntary_ctxt_switches cuenta las veces que el proceso cedió el procesador por sí mismo —normalmente al bloquearse esperando entrada/salida— y nonvoluntary_ctxt_switches cuenta las veces que el planificador se lo quitó. La proporción entre ambos números te dice de inmediato si el proceso está dominado por entrada/salida o por cálculo.
Los estados de un proceso
Un proceso no está siempre ejecutándose. De hecho pasa la mayor parte de su vida sin ejecutarse. El kernel lo clasifica en un pequeño conjunto de estados y define exactamente qué evento provoca cada transición.
El modelo mínimo tiene tres estados vivos más dos de frontera:
- Nuevo: el kernel ya reservó el PCB pero el proceso todavía no fue admitido a la cola de listos.
- Listo: tiene todo lo que necesita para ejecutar y solo espera que le asignen un procesador.
- En ejecución: sus instrucciones están corriendo en un núcleo ahora mismo. Hay como máximo tantos procesos en este estado como núcleos disponibles.
- Bloqueado: no puede continuar hasta que ocurra un evento externo: que llegue un dato del disco, que expire un temporizador, que otro proceso le mande un mensaje. Aunque le regalaran el procesador no podría avanzar.
- Terminado: dejó de ejecutar. El PCB sobrevive un momento más para que alguien recoja su código de salida.
stateDiagram-v2
[*] --> Nuevo: creacion del proceso
Nuevo --> Listo: admitido por el kernel
Listo --> Ejecutando: el planificador lo elige
Ejecutando --> Listo: expira el quantum o llega uno mas prioritario
Ejecutando --> Bloqueado: pide E/S o espera un evento
Bloqueado --> Listo: llega el dato o se cumple el evento
Ejecutando --> Terminado: exit o senal fatal
Terminado --> [*]: el padre recoge el codigo de salida
Listo --> Suspendido_Listo: el gestor de memoria lo saca
Suspendido_Listo --> Listo: vuelve a memoria
Bloqueado --> Suspendido_Bloqueado: el gestor de memoria lo saca
Suspendido_Bloqueado --> Bloqueado: vuelve a memoria
Suspendido_Bloqueado --> Suspendido_Listo: ocurre el evento estando fuera
Presta atención a las transiciones que no aparecen, tan informativas como las que sí. No existe la transición de bloqueado a en ejecución: un proceso que se desbloquea entra a la cola de listos y compite como los demás, aunque muchos sistemas le den un empujón de prioridad para favorecer la interactividad. No existe la transición de listo a bloqueado: para bloquearse hay que pedir algo, y para pedir algo hay que estar ejecutando.
En cambio, la transición de ejecutando a listo es la clave de todo el capítulo. Es la apropiación, o desalojo: el kernel le quita el procesador a un proceso que todavía podía seguir. Si esa flecha no existe, el sistema es no apropiativo y un programa con un bucle infinito congela la máquina. Si existe, el sistema es apropiativo y necesita un temporizador de hardware que interrumpa periódicamente para poder ejercerla.
Los estados suspendidos
Las dos ramas de la derecha del diagrama aparecen cuando el sistema tiene poca memoria: el gestor de memoria saca la imagen completa de un proceso a disco y ese proceso queda suspendido, existiendo todavía, con su PCB en la tabla, pero sin memoria residente y sin poder ejecutar hasta volver. Nota la transición diagonal: un proceso suspendido y bloqueado cuyo evento se cumple pasa a suspendido y listo, porque ya no espera nada externo, solo espera volver a memoria. En sistemas actuales con memoria virtual paginada esto es menos categórico —se intercambian páginas, no procesos completos— pero el concepto reaparece intacto en la suspensión de aplicaciones móviles y en la congelación de contenedores.
Los estados reales de Linux y su reflejo en ps
Linux implementa el modelo con nombres propios. La columna STAT de ps te los muestra:
| Código | Estado | Significado |
|---|---|---|
R | Ejecutando o listo | Está en un núcleo o en la cola de ejecutables. ps no distingue ambos casos |
S | Sueño interrumpible | Bloqueado esperando un evento; una señal puede sacarlo del sueño |
D | Sueño no interrumpible | Bloqueado normalmente en entrada/salida de disco; no responde ni a SIGKILL hasta que la operación termine |
T | Detenido | Recibió SIGSTOP o SIGTSTP; se reanuda con SIGCONT |
t | Detenido por depurador | Parado en un punto de traza |
Z | Zombi | Terminó, pero su padre todavía no recogió el código de salida |
I | Ocioso | Hilo del kernel inactivo |
A esos códigos se les añaden modificadores: < prioridad alta, N prioridad baja, s líder de sesión, l proceso con varios hilos, + en el grupo de primer plano de la terminal.
# Cuantos procesos hay en cada estado ahora mismo
ps -eo stat= | cut -c1 | sort | uniq -c | sort -rn
# Provocar un estado T a mano y volver
sleep 300 &
kill -STOP %1; ps -o pid,stat,comm -p $!
kill -CONT %1; ps -o pid,stat,comm -p $!
kill %1
Casi siempre verás que la abrumadora mayoría de los procesos está en S. Esa es la observación más importante de toda la sección: los procesos pasan la mayor parte del tiempo esperando, no calculando. Sobre ese hecho se construye toda la multiprogramación.
El árbol de procesos
Todo proceso, salvo el primero, es creado por otro. El creador es el padre, el creado es el hijo, y la relación queda grabada en el PCB del hijo mediante el identificador de su padre. La consecuencia es que los procesos de un sistema forman un árbol: hay una raíz, cada nodo tiene un único padre y puede tener muchos hijos.
En sistemas tipo Unix la raíz es el primer proceso que el kernel arranca al terminar de inicializarse. Recibe el identificador 1 y hoy suele ser un gestor de servicios. Todo lo demás cuelga de él, directa o indirectamente.
flowchart TD
K["kernel<br/>arranca el primer proceso"]
INIT["PID 1: init / systemd"]
LOGIN["gestor de sesion"]
SSH["servidor ssh"]
SHELL["bash PID 4210"]
HIJO1["ls PID 4315"]
HIJO2["grep PID 4316"]
EDITOR["editor PID 4290"]
NAV["navegador PID 3877"]
TAB1["proceso de pestana 1"]
TAB2["proceso de pestana 2"]
GPU["proceso de render"]
K --> INIT
INIT --> LOGIN
INIT --> SSH
LOGIN --> SHELL
LOGIN --> NAV
SHELL --> HIJO1
SHELL --> HIJO2
SHELL --> EDITOR
NAV --> TAB1
NAV --> TAB2
NAV --> GPU
style K fill:#5f1e1e,color:#fff
style INIT fill:#1e3a5f,color:#fff
style SHELL fill:#1e5f3a,color:#fff
Ese árbol tiene cuatro consecuencias operativas concretas. La herencia: el hijo nace con copia de casi todo el entorno del padre —variables de entorno, directorio de trabajo, descriptores abiertos, límites de recursos, máscara de señales—, y por eso cd /tmp && ls funciona. La adopción: si un padre muere antes que su hijo, el hijo queda huérfano y lo adopta el proceso 1 o un subgestor designado; nunca queda sin padre, porque el modelo lo exige. La propagación de señales: las señales de la terminal se envían al grupo de procesos en primer plano completo, y por eso Ctrl-C mata la tubería entera. La contabilidad: los tiempos de procesador de los hijos se acumulan en el padre cuando este los recoge, que es la base de la salida de time sobre un script.
pstree -p | head -40 # el arbol completo
pstree -s -p $$ # ancestros de tu shell
ps -eo pid,ppid,pgid,sid,comm --forest | head -40 # padre, grupo y sesion
ps -p 1 -o pid,comm,args # quien es el PID 1
Crear procesos: fork
En sistemas tipo Unix la creación de procesos se hace con una llamada al sistema de comportamiento poco habitual: fork. No recibe argumentos y devuelve dos veces, una en cada proceso.
Lo que hace fork es duplicar el proceso llamante. Después de la llamada existen dos procesos casi idénticos: mismo código, misma memoria con el mismo contenido, mismos descriptores abiertos, mismo punto de ejecución. Se diferencian en tres cosas: su identificador de proceso, su identificador de padre, y el valor que fork les devolvió.
Valor devuelto por fork | Quién lo recibe | Qué significa |
|---|---|---|
0 | El proceso hijo | ”Tú eres el hijo. Consulta getpid() si quieres tu número” |
| Un número mayor que cero | El proceso padre | ”El hijo se creó y su identificador es este número” |
-1 | El proceso padre | ”No se pudo crear. Revisa errno: tabla de procesos llena o límite de usuario alcanzado” |
Este ejemplo es completo y compilable. Guárdalo como bifurcar.c.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
int contador = 100;
printf("antes de fork: pid=%d contador=%d\n", getpid(), contador);
fflush(stdout);
pid_t hijo = fork();
if (hijo < 0) { perror("fork"); return 1; }
if (hijo == 0) { /* solo el hijo entra aqui */
contador += 1;
printf("hijo: pid=%d ppid=%d contador=%d\n", getpid(), getppid(), contador);
return 0;
}
contador += 100; /* solo el padre llega aqui */
printf("padre: pid=%d hijo=%d contador=%d\n", getpid(), hijo, contador);
int estado = 0;
waitpid(hijo, &estado, 0);
if (WIFEXITED(estado)) {
printf("padre: el hijo termino con codigo %d\n", WEXITSTATUS(estado));
}
return 0;
}
gcc -Wall -O2 -o bifurcar bifurcar.c
./bifurcar
Salida típica:
antes de fork: pid=8120 contador=100
padre: pid=8120 hijo=8121 contador=200
hijo: pid=8121 ppid=8120 contador=101
padre: el hijo termino con codigo 0
Tres observaciones que conviene fijar. El contador vale 101 en el hijo y 200 en el padre: ambos partieron de 100 porque la memoria se duplicó, pero cada uno modificó su copia; no hay memoria compartida entre procesos creados con fork. El orden entre las dos líneas del medio no está garantizado: después de fork hay dos procesos listos y el planificador decide, así que ejecutándolo veinte veces verás los dos órdenes.
Y el fflush(stdout) antes de fork no es decorativo. La salida estándar suele estar almacenada en un búfer de la biblioteca de C, que vive en la memoria del proceso. Si el búfer tiene texto pendiente cuando ocurre fork, ese texto se duplica junto con la memoria y aparece dos veces al vaciarse. Vaciar antes elimina el problema.
Copia sobre escritura
Duplicar toda la memoria en cada fork sería costosísimo, sobre todo porque el patrón más común es bifurcarse y cargar de inmediato otro programa, tirando la copia recién hecha. Por eso los sistemas actuales usan copia sobre escritura. En el momento del fork no se copia ni un byte de datos: se copia la tabla de páginas y se marcan todas las páginas de ambos procesos como de solo lectura. Los dos procesos ven el mismo contenido físico. En cuanto uno de los dos intenta escribir, el hardware genera un fallo de protección, el kernel intercepta, hace una copia privada de esa única página, le devuelve el permiso de escritura al que escribió y reanuda la instrucción.
sequenceDiagram
participant P as Proceso padre
participant K as Kernel
participant MMU as Hardware de memoria
participant H as Proceso hijo
P->>K: fork()
K->>K: reserva PCB del hijo
K->>MMU: duplica tabla de paginas<br/>marca TODAS las paginas solo lectura
K-->>P: devuelve PID del hijo
K-->>H: devuelve 0
Note over P,H: ambos ven las mismas paginas fisicas
H->>MMU: escribe en una variable
MMU->>K: excepcion de proteccion
K->>K: copia SOLO esa pagina
K->>MMU: la nueva copia es privada y escribible
K-->>H: reanuda la instruccion que fallo
Note over P,H: solo esa pagina esta duplicada de verdad
El resultado es que fork cuesta proporcional al tamaño de la tabla de páginas, no al tamaño de la memoria. Un proceso de dos gigabytes se bifurca en microsegundos.
Cargar otro programa: exec
fork crea un clon. Para que el hijo haga algo distinto hace falta una segunda operación: reemplazar la imagen del proceso por la de otro programa. Eso es exec.
exec no crea un proceso. Toma el proceso que la llama y le sustituye el código, los datos y la pila por los del ejecutable indicado. Conserva el identificador de proceso, el padre, los descriptores de archivo abiertos, el directorio de trabajo y los límites de recursos. Si tiene éxito no retorna nunca, porque el código que habría recibido el retorno ya no existe.
La biblioteca de C ofrece una familia de variantes que se distinguen por dos letras:
| Función | La l / v significa | La p significa | La e significa |
|---|---|---|---|
execl | Argumentos como lista variable terminada en NULL | — | — |
execv | Argumentos como arreglo de punteros terminado en NULL | — | — |
execlp | Lista variable | Busca el ejecutable en las rutas de PATH | — |
execvp | Arreglo | Busca en PATH | — |
execle | Lista variable | — | Recibe además el entorno explícito |
execve | Arreglo | — | Entorno explícito; es la llamada al sistema real, el resto son envoltorios |
Ejemplo completo, reemplazar.c:
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("antes de exec: pid=%d\n", getpid());
fflush(stdout);
char *argumentos[] = { "ls", "-l", "/etc/hostname", NULL };
execvp("ls", argumentos);
/* Solo se llega aqui si exec fallo */
perror("execvp");
return 1;
}
gcc -Wall -O2 -o reemplazar reemplazar.c
./reemplazar
Fíjate en que la línea “antes de exec” se imprime y luego aparece la salida de ls con el mismo identificador de proceso. No hubo proceso nuevo: hubo un trasplante de contenido.
Un detalle importante del arreglo de argumentos: el elemento en la posición cero es, por convención, el nombre con el que el programa se ve a sí mismo. No tiene que coincidir con la ruta del ejecutable. Programas como los enlaces de las herramientas de compresión aprovechan esto para comportarse distinto según el nombre con el que fueron invocados.
El patrón fork + exec + wait
La combinación de las dos llamadas es el mecanismo con el que un intérprete de comandos lanza programas: bifurcarse, reemplazar la imagen en el hijo, esperar en el padre.
sequenceDiagram
participant U as Usuario
participant S as Shell (padre)
participant K as Kernel
participant H as Hijo
U->>S: escribe "ls -l"
S->>K: fork()
K-->>S: devuelve PID del hijo
K-->>H: devuelve 0
S->>K: waitpid(hijo)
Note over S: el shell pasa a estado BLOQUEADO
H->>K: execvp("ls", argumentos)
K->>K: carga el ejecutable de ls<br/>reemplaza codigo, datos y pila
K-->>H: salta al punto de entrada de ls
H->>H: ejecuta ls y escribe en la salida heredada
H->>K: exit(0)
K->>K: el hijo pasa a ZOMBI<br/>guarda su codigo de salida
K->>S: senal SIGCHLD; waitpid retorna
K->>K: libera el PCB del hijo
S->>U: muestra el prompt de nuevo
Este es el intérprete de comandos mínimo pero funcional que implementa exactamente ese diagrama. Guárdalo como minishell.c.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_ARGS 32
static int trocear(char *linea, char *argv[]) {
int n = 0;
char *token = strtok(linea, " \t\n");
while (token != NULL && n < MAX_ARGS - 1) {
argv[n++] = token;
token = strtok(NULL, " \t\n");
}
argv[n] = NULL;
return n;
}
int main(void) {
char linea[512];
char *argv[MAX_ARGS];
for (;;) {
printf("mini$ ");
fflush(stdout);
if (fgets(linea, sizeof(linea), stdin) == NULL) { printf("\n"); break; }
if (trocear(linea, argv) == 0) continue;
if (strcmp(argv[0], "salir") == 0) break;
/* "cd" se ejecuta en el propio shell: si lo hiciera el hijo,
cambiaria el directorio del hijo y el padre no se enteraria. */
if (strcmp(argv[0], "cd") == 0) {
const char *destino = argv[1] ? argv[1] : getenv("HOME");
if (chdir(destino) != 0) perror("cd");
continue;
}
pid_t hijo = fork();
if (hijo < 0) { perror("fork"); continue; }
if (hijo == 0) {
execvp(argv[0], argv);
fprintf(stderr, "mini: no se pudo ejecutar '%s'\n", argv[0]);
_exit(127);
}
int estado = 0;
if (waitpid(hijo, &estado, 0) < 0) { perror("waitpid"); continue; }
if (WIFEXITED(estado)) {
printf("[codigo de salida: %d]\n", WEXITSTATUS(estado));
} else if (WIFSIGNALED(estado)) {
printf("[terminado por senal: %d]\n", WTERMSIG(estado));
}
}
return 0;
}
gcc -Wall -O2 -o minishell minishell.c
./minishell
Pruébalo con ls -l, con date, con cd /tmp seguido de pwd, con un comando inexistente y con sleep 30 interrumpido por Ctrl-C. El manejo de cd como comando interno ilustra un punto conceptual: hay operaciones que no se pueden delegar a un hijo porque su efecto vive en el PCB del que las ejecuta. Y el uso de _exit(127) en lugar de exit(127) tras un exec fallido también es deliberado: exit vaciaría los búferes heredados del padre, imprimiendo por segunda vez texto que el padre ya tenía pendiente.
Recoger al hijo: la familia wait
Cuando un proceso termina, el kernel no puede liberar su PCB de inmediato: dentro está el código de salida y el padre podría querer leerlo. El proceso queda en estado zombi: sin memoria, sin código, sin pila, reducido a una entrada de la tabla de procesos que solo guarda cómo murió. El padre lo recoge con wait o waitpid, y en ese momento el kernel entrega el código de salida y libera la entrada.
| Macro sobre el estado devuelto | Qué responde |
|---|---|
WIFEXITED(estado) | ¿Terminó normalmente, por return o exit? |
WEXITSTATUS(estado) | Si terminó normalmente, ¿con qué código? Solo los 8 bits bajos |
WIFSIGNALED(estado) | ¿Lo mató una señal? |
WTERMSIG(estado) | ¿Qué número de señal lo mató? |
WIFSTOPPED(estado) | ¿Está detenido? Requiere la opción WUNTRACED |
WSTOPSIG(estado) | ¿Qué señal lo detuvo? |
waitpid acepta además opciones. La más usada es WNOHANG, que hace que la llamada retorne de inmediato con cero si el hijo todavía no terminó, en vez de bloquear al padre. Con eso se construyen servidores que lanzan trabajos en segundo plano y los recolectan sin dejar de atender peticiones.
Zombis y huérfanos
Los dos patrones patológicos del ciclo de vida tienen causas simétricas. Un zombi es un hijo que terminó y cuyo padre sigue vivo pero no llamó a wait: su entrada en la tabla de procesos no se libera. Uno aislado es inofensivo; un servidor que genera miles de hijos y nunca los recoge agota la tabla y llega un punto en que fork empieza a fallar. Un huérfano es un hijo cuyo padre murió primero: el kernel lo reasigna al proceso 1, que sí llama a wait continuamente, así que se limpia solo.
Este programa produce un zombi a propósito para que lo observes. Guárdalo como zombi.c:
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t hijo = fork();
if (hijo < 0) { perror("fork"); return 1; }
if (hijo == 0) {
printf("hijo %d termina de inmediato\n", getpid());
return 0;
}
printf("padre %d NO llama a wait; observa al hijo %d durante 30 s con:\n"
" ps -o pid,ppid,stat,comm -p %d\n", getpid(), hijo, hijo);
fflush(stdout);
sleep(30);
return 0;
}
gcc -Wall -O2 -o zombi zombi.c
./zombi &
sleep 1
ps -eo pid,ppid,stat,comm | grep -E 'Z|defunct'
Verás el estado Z y la palabra <defunct> en el nombre. Cuando el padre termine a los treinta segundos, el zombi desaparecerá: fue adoptado por el proceso 1, que lo recogió. Para evitar zombis en un servidor se llama wait desde un manejador de SIGCHLD, o se instala la disposición de ignorar SIGCHLD para que el kernel limpie automáticamente.
Hilos: separar la ejecución de la posesión
El proceso, tal como lo hemos descrito, junta dos cosas: es dueño de recursos y es un flujo de ejecución. Los hilos las separan. Un proceso puede tener varios hilos: cada uno con su propio contador de programa, su propio juego de registros y su propia pila, pero todos compartiendo el mismo espacio de direcciones, los mismos descriptores de archivo y los mismos demás recursos del proceso.
flowchart LR
subgraph PROC["Un proceso con tres hilos"]
direction TB
COMP["COMPARTIDO<br/>codigo, datos globales,<br/>monticulo, descriptores de archivo,<br/>directorio de trabajo, senales"]
H1["Hilo 1<br/>PC, registros, pila propia"]
H2["Hilo 2<br/>PC, registros, pila propia"]
H3["Hilo 3<br/>PC, registros, pila propia"]
COMP --- H1
COMP --- H2
COMP --- H3
end
subgraph DOS["Dos procesos separados"]
direction TB
PA["Proceso A<br/>espacio propio<br/>1 hilo"]
PB["Proceso B<br/>espacio propio<br/>1 hilo"]
end
PROC -.->|"comunicacion: variables compartidas<br/>coste: bajo, riesgo: carreras"| X["Consecuencias"]
DOS -.->|"comunicacion: tuberias, sockets,<br/>memoria compartida explicita"| X
style COMP fill:#5f4a1e,color:#fff
style PROC fill:#1e3a5f,color:#fff
style DOS fill:#1e5f3a,color:#fff
Comparación directa
| Aspecto | Proceso | Hilo dentro de un proceso |
|---|---|---|
| Espacio de direcciones | Propio y aislado | Compartido con sus hermanos |
| Coste de creación | Alto: nuevo PCB, nueva tabla de páginas | Bajo: nueva pila y nuevo contexto de registros |
| Coste de cambio de contexto | Alto: hay que cambiar de espacio de direcciones y vaciar estructuras de traducción | Bajo: el espacio de direcciones no cambia |
| Comunicación | Requiere mecanismos explícitos del kernel | Directa, escribiendo variables comunes |
| Aislamiento ante fallos | Un fallo mata solo a ese proceso | Un fallo de segmentación mata al proceso entero con todos sus hilos |
| Riesgo de condiciones de carrera | Bajo, la memoria no se comparte por defecto | Alto, la memoria se comparte por defecto |
| Descriptores de archivo | Propios, copiados en fork | Compartidos: si un hilo cierra uno, se cierra para todos |
| Unidad que planifica el kernel | El proceso | Cada hilo por separado, en el modelo uno a uno |
Modelos de implementación
Hay tres formas de implementar hilos, y la diferencia se nota justo cuando un hilo se bloquea.
| Modelo | Cómo funciona | Qué pasa si un hilo se bloquea |
|---|---|---|
| Muchos a uno, o hilos de usuario | Una biblioteca en modo usuario multiplexa sus hilos sobre un único hilo del kernel. Conmutar no cruza al kernel, así que es rapidísimo | El kernel bloquea al único hilo real y los demás se detienen aunque tuvieran trabajo. Tampoco puede usar más de un núcleo |
| Uno a uno, o hilos de kernel | Cada hilo de usuario corresponde a un hilo que el kernel conoce y planifica. Es lo que hacen Linux, Windows y macOS | Solo se bloquea ese hilo. Varios hilos pueden correr en núcleos distintos a la vez. El coste es que crear y conmutar exige entrar al kernel |
| Muchos a muchos, o híbrido | Un conjunto de hilos de usuario se reparte sobre un conjunto menor de hilos del kernel | Depende de la implementación; las máquinas virtuales con concurrencia ligera, como la de Elixir, mueven el trabajo pendiente a otro hilo real |
Hilos en C con la biblioteca de hilos POSIX
Este programa lanza cuatro hilos que incrementan dos contadores: uno sin protección y otro protegido con un candado de exclusión mutua. Sirve para ver de forma directa el riesgo de la memoria compartida. Guárdalo como hilos.c:
#include <stdio.h>
#include <pthread.h>
#define HILOS 4
#define VUELTAS 200000
static long contador_sin_proteger = 0;
static long contador_protegido = 0;
static pthread_mutex_t candado = PTHREAD_MUTEX_INITIALIZER;
static void *trabajo(void *arg) {
long id = (long) arg;
for (int i = 0; i < VUELTAS; i++) {
contador_sin_proteger++; /* sin proteccion */
pthread_mutex_lock(&candado);
contador_protegido++; /* dentro de la seccion critica */
pthread_mutex_unlock(&candado);
}
printf("hilo %ld termino\n", id);
return NULL;
}
int main(void) {
pthread_t hilos[HILOS];
for (long i = 0; i < HILOS; i++) {
if (pthread_create(&hilos[i], NULL, trabajo, (void *) i) != 0) {
perror("pthread_create");
return 1;
}
}
for (int i = 0; i < HILOS; i++) {
pthread_join(hilos[i], NULL);
}
printf("esperado: %d\n", HILOS * VUELTAS);
printf("sin proteger: %ld\n", contador_sin_proteger);
printf("con candado: %ld\n", contador_protegido);
return 0;
}
gcc -Wall -O2 -pthread -o hilos hilos.c
./hilos
El contador protegido siempre da el número esperado. El contador sin proteger casi nunca lo da, y da un valor distinto en cada ejecución. La razón es que contador++ no es una operación única: el procesador lee el valor, lo incrementa y lo escribe. Si dos hilos leen antes de que cualquiera escriba, uno de los dos incrementos se pierde. Este es el problema central del capítulo 7, y aquí lo dejamos solo enunciado.
Mientras corre, puedes ver los hilos desde otra terminal:
grep Threads /proc/$(pgrep -n hilos)/status # cuantos hilos tiene
ps -L -o pid,tid,psr,pcpu,comm -p $(pgrep -n hilos) # los hilos uno por uno
La columna TID es el identificador de hilo y la columna PSR te dice en qué núcleo está cada uno. Verás que varios corren en núcleos distintos al mismo tiempo: eso es paralelismo real, no solo concurrencia.
Tareas en Ada
Ada tiene concurrencia integrada en el lenguaje, no en una biblioteca. La unidad se llama tarea y es un tipo de primera clase. El compilador y el sistema de tiempo de ejecución se encargan de mapearla a hilos del sistema operativo.
Guárdalo como tareas.adb:
with Ada.Text_IO; use Ada.Text_IO;
procedure Tareas is
task type Trabajador (Id : Positive; Vueltas : Positive);
task body Trabajador is
begin
for I in 1 .. Vueltas loop
Put_Line ("tarea" & Positive'Image (Id) & " paso" & Integer'Image (I));
delay 0.005;
end loop;
Put_Line ("tarea" & Positive'Image (Id) & " termino");
end Trabajador;
T1 : Trabajador (1, 6);
T2 : Trabajador (2, 6);
T3 : Trabajador (3, 6);
begin
Put_Line ("el procedimiento principal ya arranco las tres tareas");
Put_Line ("no terminara hasta que las tres acaben");
end Tareas;
gnatmake tareas.adb
./tareas
Tres rasgos del modelo de Ada que merecen atención. Las tareas arrancan solas: no hay llamada de creación explícita, declarar el objeto T1 de tipo tarea la pone en marcha al entrar al ámbito. El ámbito espera a sus tareas: el procedimiento principal imprime sus dos líneas y llega al end, pero el programa no termina ahí hasta que las tres tareas acaben; es el equivalente estructural de un pthread_join que no puedes olvidarte de escribir. Y la sentencia delay 0.005 suspende la tarea, dejándola en un estado equivalente a bloqueado mientras el planificador da el procesador a otra. Ejecútalo varias veces y compara el orden de las líneas: cambia en cada corrida.
Procesos ligeros en Elixir
La máquina virtual de Elixir lleva el modelo híbrido al extremo: sus “procesos” no son ni procesos del sistema operativo ni hilos del sistema operativo. Son estructuras internas de la máquina virtual, con su propia pila y su propio montículo aislados, planificadas por planificadores propios que corren cada uno sobre un hilo del sistema operativo.
La consecuencia práctica es que crear cien mil de ellos es barato y que no comparten memoria: se comunican exclusivamente por mensajes. El aislamiento de un proceso del sistema operativo, con el coste de creación de un hilo.
Guárdalo como procesos.exs:
defmodule Observatorio do
def demostrar do
padre = self()
IO.puts("padre: #{inspect(padre)}")
IO.puts("planificadores en linea: #{:erlang.system_info(:schedulers_online)}")
IO.puts("procesos vivos antes: #{length(Process.list())}")
for i <- 1..5, do: spawn_link(fn -> trabajo(i, padre) end)
IO.puts("procesos vivos despues: #{length(Process.list())}")
# Un proceso dormido: equivalente al estado BLOQUEADO. Su ficha es un PCB reducido.
dormilon = spawn(fn -> Process.sleep(60_000) end)
IO.inspect(Process.info(dormilon, [:status, :message_queue_len, :reductions]))
recolectar(5, [])
end
defp trabajo(i, padre) do
total = Enum.reduce(1..(i * 300_000), 0, fn x, acc -> acc + x end)
send(padre, {:listo, i, total, self()})
end
defp recolectar(0, acumulado), do: Enum.reverse(acumulado)
defp recolectar(n, acumulado) do
receive do
{:listo, i, total, pid} ->
IO.puts("termino el trabajo #{i} en #{inspect(pid)}: #{total}")
recolectar(n - 1, [{i, total} | acumulado])
after
30_000 -> Enum.reverse(acumulado)
end
end
end
Observatorio.demostrar()
elixir procesos.exs
Los cinco trabajos tienen cargas crecientes: el primero suma trescientos mil números y el quinto un millón y medio. Los mensajes llegan aproximadamente en orden de duración creciente, y eso demuestra algo concreto: el planificador de la máquina virtual es apropiativo. Ningún proceso puede monopolizar su planificador aunque esté en un bucle de cálculo puro, porque el sistema de tiempo de ejecución cuenta el trabajo realizado y lo desaloja al llegar a un límite.
La ficha que imprime Process.info es el equivalente reducido de un PCB: el estado, cuántos mensajes tiene sin leer y cuánto trabajo ha consumido.
El cambio de contexto
Ya sabemos que el kernel puede quitarle el procesador a un proceso; falta ver cómo lo hace, porque en esa operación se juega buena parte del rendimiento del sistema. Un cambio de contexto es guardar el estado del proceso que sale y restaurar el del que entra, de modo que el primero pueda continuar más tarde como si nada hubiera pasado.
sequenceDiagram
participant A as Proceso A (modo usuario)
participant HW as Hardware
participant K as Kernel (modo privilegiado)
participant B as Proceso B (modo usuario)
A->>A: ejecuta instrucciones normalmente
HW->>HW: el temporizador expira
HW->>K: interrupcion; cambia a modo privilegiado<br/>y salta al vector del temporizador
Note over HW,K: el hardware ya salvo PC y banderas<br/>en la pila de kernel de A
K->>K: guarda el resto de registros de A en su PCB
K->>K: actualiza contabilidad de A: tiempo usado
K->>K: pasa A de EJECUTANDO a LISTO
K->>K: el planificador elige a B
K->>HW: carga la tabla de paginas de B
HW->>HW: invalida entradas de traduccion no globales
K->>K: restaura los registros de B desde su PCB
K->>K: pasa B de LISTO a EJECUTANDO
K->>B: retorno de interrupcion; vuelve a modo usuario
B->>B: continua exactamente donde habia quedado
Note over A,B: A no se entera de nada:<br/>para el no paso el tiempo
Qué se guarda y qué cuesta
El coste tiene dos componentes muy distintos. El coste directo es el trabajo explícito: salvar y restaurar registros, actualizar estructuras del kernel y ejecutar el algoritmo de planificación; es del orden de unos pocos microsegundos y razonablemente predecible. El coste indirecto es el daño colateral en las cachés: el proceso que sale había llenado la caché de datos, la de instrucciones, el predictor de saltos y la memoria intermedia de traducción con información útil suya, y el que entra empieza a expulsar todo eso, de modo que cuando el primero vuelva sus primeros miles de accesos irán a memoria principal. Ese coste no aparece en ninguna cuenta, es difícil de medir aisladamente y suele superar al directo.
Por eso el cambio entre hilos del mismo proceso es más barato: el espacio de direcciones no cambia, la memoria intermedia de traducción sigue siendo válida y buena parte de la caché de datos sigue caliente.
| Tipo de conmutación | Qué hay que cambiar | Coste relativo |
|---|---|---|
| Entre hilos del mismo proceso | Registros y puntero de pila | Bajo |
| Entre procesos distintos | Registros, puntero de pila, tabla de páginas, invalidación de traducciones | Medio |
| Entre procesos en núcleos distintos, con migración | Todo lo anterior más pérdida completa de caché local del núcleo | Alto |
| Entrada y salida al kernel sin cambio de proceso | Solo modo de privilegio y pila | Muy bajo |
Medirlo en tu máquina
# Cambios de contexto acumulados por un proceso concreto
grep ctxt_switches /proc/$$/status
# Tasa del sistema completo: columna "cs" cambios/s, columna "in" interrupciones/s
vmstat 1 5
# Comparar un programa que calcula contra uno que espera
perf stat -e context-switches bash -c 'x=0; for i in $(seq 1 300000); do x=$((x+i)); done'
perf stat -e context-switches bash -c 'for i in $(seq 1 300); do sleep 0.001; done'
El segundo comando produce muchísimos más cambios de contexto que el primero, aunque haga muchísimo menos trabajo. Cada sleep es un bloqueo voluntario y cada despertar es una vuelta al planificador.
Planificación: el problema
Ahora tenemos las piezas: procesos con estados, una cola de listos y un mecanismo para conmutar entre ellos. Falta la política. ¿A quién se le da el procesador cuando hay varios candidatos? Esa decisión la toma el planificador, y no hay una respuesta única porque los objetivos se contradicen entre sí.
Criterios de evaluación
Para comparar algoritmos necesitamos medidas precisas. Estas son las estándar:
| Métrica | Definición | Se quiere |
|---|---|---|
| Uso del procesador | Porcentaje de tiempo que el procesador está ejecutando trabajo útil | Maximizar |
| Productividad | Procesos completados por unidad de tiempo | Maximizar |
| Tiempo de retorno | Instante de finalización menos instante de llegada. Incluye espera y ejecución | Minimizar |
| Tiempo de espera | Tiempo total que el proceso pasó en la cola de listos sin ejecutar. Es el retorno menos la duración | Minimizar |
| Tiempo de respuesta | Desde que llega hasta que ejecuta por primera vez | Minimizar |
| Equidad | Que ningún proceso quede postergado indefinidamente | Garantizar |
| Previsibilidad | Que la misma carga produzca tiempos similares en ejecuciones distintas | Maximizar |
Las contradicciones son directas: minimizar el tiempo de retorno promedio favorece atender primero a los procesos cortos, pero eso puede dejar a los largos esperando indefinidamente, lo que rompe la equidad. Minimizar el tiempo de respuesta exige rotar rápido entre procesos, y cada rotación es un cambio de contexto que baja la productividad total.
Apropiativa contra no apropiativa
| Planificación no apropiativa | Planificación apropiativa | |
|---|---|---|
| Cuándo se reconsidera | Solo cuando el proceso termina o se bloquea | Además, cuando expira el quantum o llega un proceso más prioritario |
| Requiere temporizador | No | Sí, obligatoriamente |
| Un bucle infinito | Congela el sistema | Es solo un proceso que consume su cuota |
| Estructuras compartidas del kernel | Menos puntos de conflicto | Requiere protección cuidadosa |
| Tiempo de respuesta | Malo si hay procesos largos | Acotado por el quantum |
| Sobrecarga | Mínima | Proporcional a la frecuencia de conmutación |
El ciclo de ráfagas
La observación empírica que sostiene los algoritmos que siguen es que los procesos alternan entre ráfagas de procesador, en las que calculan, y ráfagas de entrada/salida, en las que esperan. Según qué fase domine se agrupan en dos familias: los dominados por entrada/salida, con muchas ráfagas de procesador muy cortas separadas por esperas largas —un editor, un shell, un servidor de archivos—, y los dominados por procesador, con pocas ráfagas muy largas —una compilación, un cálculo numérico, una compresión de video—.
Un buen reparto mezcla ambos tipos: mientras los dominados por entrada/salida esperan al disco, los dominados por procesador ocupan el núcleo. Si la mezcla se desequilibra, el sistema desperdicia una de las dos clases de recurso.
La carga de prueba
Para comparar los algoritmos con números en vez de con adjetivos, usaremos siempre la misma carga:
| Proceso | Instante de llegada | Duración de la ráfaga | Prioridad (1 es la más alta) |
|---|---|---|---|
| P1 | 0 | 7 | 3 |
| P2 | 2 | 4 | 1 |
| P3 | 4 | 1 | 4 |
| P4 | 5 | 4 | 2 |
Para cada algoritmo calcularemos el diagrama de ocupación, el tiempo de retorno, el de espera y el de respuesta. Recuerda las fórmulas:
- Retorno = finalización − llegada
- Espera = retorno − duración
- Respuesta = primera vez que ejecuta − llegada
FCFS: primero en llegar, primero en ser servido
El algoritmo más simple posible: una cola en orden de llegada, sin apropiación. El que llega primero corre hasta que termina o se bloquea.
Ocupación del procesador: P1[0-7] P2[7-11] P3[11-12] P4[12-16].
| Proceso | Finaliza | Retorno | Espera | Respuesta |
|---|---|---|---|---|
| P1 | 7 | 7 | 0 | 0 |
| P2 | 11 | 9 | 5 | 5 |
| P3 | 12 | 8 | 7 | 7 |
| P4 | 16 | 11 | 7 | 7 |
| Promedio | 8.75 | 4.75 | 4.75 |
Propiedades. Es trivial de implementar y no puede haber inanición: todo el mundo avanza en la cola. No requiere conocer la duración de las ráfagas.
El efecto convoy. Fíjate en P3: necesita un solo instante de procesador y espera siete. Está atrapado detrás de P1. Esto es el efecto convoy: un proceso largo bloquea a una fila de procesos cortos, igual que un camión lento en una carretera de un carril. En un sistema real, donde los procesos cortos suelen ser los interactivos, el efecto convoy se traduce directamente en una interfaz que no responde.
El promedio depende del orden de llegada. Si P3 hubiera llegado primero, el retorno promedio bajaría sin que ningún proceso cambie de duración. Esa sensibilidad al orden es la debilidad estructural del algoritmo.
SJF: el trabajo más corto primero
Si el problema de FCFS es que los cortos esperan detrás de los largos, la solución evidente es elegir siempre al más corto de los disponibles. Este es SJF (Shortest Job First) en su versión no apropiativa.
Ocupación: P1[0-7] P3[7-8] P2[8-12] P4[12-16].
| Proceso | Finaliza | Retorno | Espera | Respuesta |
|---|---|---|---|---|
| P1 | 7 | 7 | 0 | 0 |
| P2 | 12 | 10 | 6 | 6 |
| P3 | 8 | 4 | 3 | 3 |
| P4 | 16 | 11 | 7 | 7 |
| Promedio | 8.00 | 4.00 | 4.00 |
La propiedad de optimalidad. SJF produce el tiempo de espera promedio mínimo posible para un conjunto de procesos disponibles al mismo tiempo; es un teorema, no una observación. La intuición: si un proceso largo va antes que uno corto, su duración se suma a la espera del corto, e intercambiarlos reduce la suma total.
Los dos problemas. Primero, hay que conocer la duración de la ráfaga por adelantado, y en un sistema real no se conoce. Segundo, la inanición: si llegan continuamente procesos cortos, uno largo puede no ejecutar nunca. Como la duración no se conoce, se predice a partir del historial mediante un promedio exponencial: la nueva estimación es una mezcla entre la duración real de la última ráfaga y la estimación anterior, con un factor entre cero y uno que decide cuánto pesa la historia reciente frente a la acumulada. Con un factor cercano a uno el planificador reacciona rápido a cambios de comportamiento; con un factor cercano a cero se apoya sobre todo en el pasado largo.
SRTF: el tiempo restante más corto primero
La versión apropiativa de SJF. Cada vez que llega un proceso nuevo, el planificador compara su duración con el tiempo restante del que está corriendo y desaloja si el recién llegado es más corto.
Traza paso a paso: en 0 solo está P1 y empieza; en 2 llega P2 con duración 4 y a P1 le quedan 5, así que P1 es desalojado; en 4 llega P3 con duración 1 y a P2 le quedan 2, así que P2 es desalojado; en 5 P3 ya terminó y llega P4 con 4, de modo que compiten P1 con 5, P2 con 2 y P4 con 4, y gana P2; en 7 P2 termina y gana P4 con 4 frente a P1 con 5; en 11 P4 termina y P1 corre hasta el 16.
Ocupación: P1[0-2] P2[2-4] P3[4-5] P2[5-7] P4[7-11] P1[11-16].
| Proceso | Finaliza | Retorno | Espera | Respuesta |
|---|---|---|---|---|
| P1 | 16 | 16 | 9 | 0 |
| P2 | 7 | 5 | 1 | 0 |
| P3 | 5 | 1 | 0 | 0 |
| P4 | 11 | 6 | 2 | 2 |
| Promedio | 7.00 | 3.00 | 0.50 |
Los promedios de espera y respuesta son los mejores de todos los algoritmos que veremos sobre esta carga. Pero mira la fila de P1: su tiempo de espera pasó de 0 en FCFS a 9. El proceso más largo pagó la factura completa de la mejora del promedio. Si en lugar de cuatro procesos hubiera un flujo continuo de trabajos cortos, P1 no terminaría nunca.
Round Robin: turnos de duración fija
Round Robin es FCFS con apropiación por tiempo. Hay una cola circular y un quantum: cada proceso recibe como máximo esa cantidad de procesador y, si no terminó, vuelve al final de la cola.
Con quantum de 2 unidades, y con la convención de que los procesos que llegan en un instante se encolan antes que el proceso desalojado en ese mismo instante:
Ocupación: P1[0-2] P2[2-4] P1[4-6] P3[6-7] P2[7-9] P4[9-11] P1[11-13] P4[13-15] P1[15-16].
| Proceso | Finaliza | Retorno | Espera | Respuesta |
|---|---|---|---|---|
| P1 | 16 | 16 | 9 | 0 |
| P2 | 9 | 7 | 3 | 0 |
| P3 | 7 | 3 | 2 | 2 |
| P4 | 15 | 10 | 6 | 4 |
| Promedio | 9.00 | 5.00 | 1.50 |
Los promedios de retorno y espera son los peores de la comparación, y el de respuesta es el segundo mejor. Eso resume exactamente qué compra Round Robin: acota el tiempo de respuesta a costa de alargar el retorno. Con n procesos listos y quantum q, ningún proceso espera más de (n − 1) × q antes de su siguiente turno. Esa garantía es lo que hace que una interfaz gráfica se sienta viva.
Elegir el quantum es el diseño completo del algoritmo:
| Quantum | Consecuencia |
|---|---|
| Muy grande, mayor que casi toda ráfaga | Degenera en FCFS: nadie llega a agotarlo. Vuelve el efecto convoy |
| Muy pequeño, comparable al coste de conmutar | La mayoría del tiempo de procesador se gasta cambiando de contexto en lugar de trabajar |
| Equilibrado | La regla práctica habitual es que el ochenta por ciento de las ráfagas quepan dentro del quantum |
Cuantifícalo: si el cambio de contexto cuesta 10 microsegundos y el quantum es de 100 microsegundos, el 9 por ciento del procesador se va en administración. Con un quantum de 10 milisegundos, la sobrecarga baja al 0.1 por ciento pero el tiempo de respuesta con diez procesos listos sube a 90 milisegundos.
Planificación por prioridades
A cada proceso se le asigna un número de prioridad y se elige siempre el de prioridad más alta. SJF es un caso particular: la prioridad es el inverso de la duración.
Con las prioridades de la tabla de carga, en versión no apropiativa:
Ocupación: P1[0-7] P2[7-11] P4[11-15] P3[15-16].
| Proceso | Prioridad | Finaliza | Retorno | Espera | Respuesta |
|---|---|---|---|---|---|
| P1 | 3 | 7 | 7 | 0 | 0 |
| P2 | 1 | 11 | 9 | 5 | 5 |
| P3 | 4 | 16 | 12 | 11 | 11 |
| P4 | 2 | 15 | 10 | 6 | 6 |
| Promedio | 9.50 | 5.50 | 5.50 |
P3, que solo necesita una unidad de procesador, espera once por tener la prioridad más baja. Con más procesos de prioridad alta llegando, esperaría indefinidamente.
El remedio estándar contra la inanición es el envejecimiento: subir gradualmente la prioridad de los procesos que llevan mucho tiempo esperando, de modo que uno de prioridad ínfima acabe siendo elegido si espera lo suficiente.
Las prioridades tienen dos fuentes. Las externas las fija un humano o una política administrativa; en sistemas tipo Unix se expresan con el valor de cortesía, un número entre −20 y 19 donde los valores bajos significan más prioridad, y solo el superusuario puede bajar de cero. Las internas las calcula el kernel observando el comportamiento: un proceso que se bloquea mucho recibe un empujón porque probablemente es interactivo, y uno que agota su quantum sistemáticamente recibe una penalización porque probablemente es de cálculo.
nice -n 19 sha256sum /dev/zero & # lanzar con baja prioridad
ps -o pid,ni,pri,comm -p $!
renice -n 5 -p $! # cambiarla en caliente; subirla exige privilegios
ps -o pid,ni,pri,comm -p $!
kill %1
chrt -m # politicas disponibles y sus rangos de prioridad
chrt -p $$ # politica actual de un proceso
Colas multinivel
Un solo algoritmo no atiende bien a poblaciones heterogéneas. La idea de las colas multinivel es partir la cola de listos en varias colas, cada una con su propia disciplina interna, y añadir una regla para decidir entre colas. Un reparto habitual usa cuatro: procesos de tiempo real con prioridad absoluta, procesos interactivos del sistema, procesos interactivos de usuario con Round Robin de quantum corto, y procesos por lotes con FCFS o con Round Robin de quantum largo.
La regla entre colas puede ser prioridad absoluta —nada de la cola 3 corre si hay algo en la 2— o reparto proporcional de tiempo —a la cola 3 le toca el 20 por ciento del procesador aunque la 2 tenga trabajo—. La primera es simple pero permite inanición completa de las colas bajas; la segunda la evita a costa de complicar el cálculo.
En la versión clásica, un proceso nace en una cola y se queda ahí toda su vida. Eso es rígido: un proceso interactivo que arranca una fase de cálculo intenso seguirá compitiendo con quantum corto y provocará conmutaciones inútiles.
Colas multinivel con retroalimentación
La variante con retroalimentación permite que los procesos cambien de cola según su comportamiento observado, y es el esquema que más se acerca a lo que hacen los sistemas reales. Las reglas típicas: todo proceso nuevo entra en la cola de mayor prioridad, que tiene el quantum más corto; el que agota su quantum completo baja un nivel, porque parece de cálculo y necesita turnos largos más que respuesta rápida; el que se bloquea antes de agotar el quantum se queda en su nivel o sube, porque parece interactivo y atenderlo pronto cuesta poco; y periódicamente todos vuelven a la cola superior, que es el envejecimiento que evita la inanición de los que quedaron abajo.
flowchart TD
NUEVO["Proceso nuevo"] --> Q0
Q0["Cola 0 - prioridad maxima<br/>Round Robin, quantum 8 ms"]
Q1["Cola 1 - prioridad media<br/>Round Robin, quantum 16 ms"]
Q2["Cola 2 - prioridad baja<br/>Round Robin, quantum 32 ms"]
Q3["Cola 3 - por lotes<br/>FCFS sin apropiacion por tiempo"]
Q0 -->|"agoto el quantum entero<br/>= parece de calculo"| Q1
Q1 -->|"agoto el quantum entero"| Q2
Q2 -->|"agoto el quantum entero"| Q3
Q0 -->|"se bloqueo antes<br/>= parece interactivo"| PERM0["se queda en cola 0"]
Q1 -->|"se bloqueo antes"| Q0
Q2 -->|"se bloqueo antes"| Q1
Q3 -->|"se bloqueo antes"| Q2
Q3 -.->|"envejecimiento periodico<br/>evita inanicion"| Q0
Q2 -.->|"envejecimiento periodico"| Q0
CPU["Procesador"]
Q0 --> CPU
Q1 --> CPU
Q2 --> CPU
Q3 --> CPU
style Q0 fill:#1e5f3a,color:#fff
style Q3 fill:#5f1e1e,color:#fff
style CPU fill:#1e3a5f,color:#fff
Lo notable de este esquema es que no necesita que nadie declare qué tipo de proceso es cada cual. Lo deduce midiendo. Un editor de texto se queda arriba porque pasa la vida esperando teclas; un compilador baja solo a los pocos milisegundos porque nunca se bloquea. Y si el editor arranca una búsqueda pesada, baja; cuando termina y vuelve a esperar teclas, sube.
Un simulador ejecutable de los cinco algoritmos
Este programa en Elixir implementa un motor de simulación por unidad de tiempo y reproduce exactamente las tablas de las secciones anteriores. Sirve para experimentar con otras cargas y otros quantums. Guárdalo como planificador.exs:
defmodule Planificador do
@moduledoc "Simulador de planificacion. El tiempo se mide en unidades enteras."
defmodule P do
defstruct [:nombre, :llegada, :duracion, :prioridad, :restante, :inicio, :fin]
end
def p(nombre, llegada, duracion, prioridad \\ 0) do
%P{nombre: nombre, llegada: llegada, duracion: duracion, prioridad: prioridad,
restante: duracion, inicio: nil, fin: nil}
end
# ---- Motor generico: gana la clave menor ----
# apropiativo? = true -> reconsidera en cada unidad de tiempo (SRTF)
# apropiativo? = false -> mantiene al elegido hasta que termine (FCFS, SJF, prioridades)
def por_clave(procesos, clave, apropiativo?), do: ciclo(procesos, 0, nil, [], clave, apropiativo?)
defp ciclo(ps, t, actual, traza, clave, apr?) do
listos = Enum.filter(ps, &(&1.llegada <= t and &1.restante > 0))
cond do
Enum.all?(ps, &(&1.restante == 0)) ->
{Enum.reverse(traza), ps}
listos == [] ->
ciclo(ps, t + 1, nil, [{t, :ocioso} | traza], clave, apr?)
true ->
elegido =
if actual != nil and not apr? and vigente?(ps, actual),
do: actual,
else: Enum.min_by(listos, &{clave.(&1), &1.llegada, &1.nombre}).nombre
ps = ejecutar_unidad(ps, elegido, t)
siguiente = if vigente?(ps, elegido), do: elegido, else: nil
ciclo(ps, t + 1, siguiente, [{t, elegido} | traza], clave, apr?)
end
end
# ---- Round Robin con cola explicita ----
def round_robin(procesos, quantum), do: rr(procesos, 0, [], [], quantum, nil, 0)
defp rr(ps, t, cola, traza, q, actual, usado) do
if Enum.all?(ps, &(&1.restante == 0)) do
{Enum.reverse(traza), ps}
else
# Convencion: los que llegan en t se encolan ANTES del desalojado en t
llegan = ps |> Enum.filter(&(&1.llegada == t and &1.restante > 0)) |> Enum.map(& &1.nombre)
cola = cola ++ llegan
{cola, actual, usado} =
cond do
actual == nil or not vigente?(ps, actual) -> {cola, nil, 0}
usado >= q -> {cola ++ [actual], nil, 0}
true -> {cola, actual, usado}
end
cond do
actual != nil ->
rr(ejecutar_unidad(ps, actual, t), t + 1, cola, [{t, actual} | traza], q, actual, usado + 1)
cola == [] ->
rr(ps, t + 1, cola, [{t, :ocioso} | traza], q, nil, 0)
true ->
[sig | resto] = cola
rr(ejecutar_unidad(ps, sig, t), t + 1, resto, [{t, sig} | traza], q, sig, 1)
end
end
end
# ---- Utilidades comunes ----
defp vigente?(ps, nombre), do: Enum.any?(ps, &(&1.nombre == nombre and &1.restante > 0))
defp ejecutar_unidad(ps, nombre, t) do
Enum.map(ps, fn x ->
if x.nombre == nombre do
restante = x.restante - 1
%{x | inicio: x.inicio || t, restante: restante,
fin: if(restante == 0, do: t + 1, else: x.fin)}
else
x
end
end)
end
def gantt({traza, _ps}) do
traza
|> Enum.chunk_by(fn {_t, quien} -> quien end)
|> Enum.map_join(" ", fn bloque ->
{inicio, quien} = hd(bloque)
"#{quien}[#{inicio}-#{inicio + length(bloque)}]"
end)
end
def informe(titulo, {_traza, ps} = resultado) do
filas =
ps
|> Enum.sort_by(& &1.nombre)
|> Enum.map(fn x ->
r = x.fin - x.llegada
{x.nombre, x.fin, r, r - x.duracion, x.inicio - x.llegada}
end)
n = length(filas)
prom = fn pos -> Enum.sum(Enum.map(filas, &elem(&1, pos))) / n end
IO.puts("\n=== #{titulo} ===\nocupacion: #{gantt(resultado)}")
Enum.each(filas, fn {nom, fin, ret, esp, resp} ->
IO.puts(" #{nom} fin=#{fin} retorno=#{ret} espera=#{esp} respuesta=#{resp}")
end)
IO.puts(" promedios: retorno=#{Float.round(prom.(2), 2)} " <>
"espera=#{Float.round(prom.(3), 2)} respuesta=#{Float.round(prom.(4), 2)}")
end
end
carga = [
Planificador.p("P1", 0, 7, 3),
Planificador.p("P2", 2, 4, 1),
Planificador.p("P3", 4, 1, 4),
Planificador.p("P4", 5, 4, 2)
]
Planificador.informe("FCFS", Planificador.por_clave(carga, & &1.llegada, false))
Planificador.informe("SJF no apropiativo", Planificador.por_clave(carga, & &1.duracion, false))
Planificador.informe("SRTF apropiativo", Planificador.por_clave(carga, & &1.restante, true))
Planificador.informe("Prioridades", Planificador.por_clave(carga, & &1.prioridad, false))
Planificador.informe("Round Robin q=2", Planificador.round_robin(carga, 2))
Planificador.informe("Round Robin q=1", Planificador.round_robin(carga, 1))
Planificador.informe("Round Robin q=8", Planificador.round_robin(carga, 8))
elixir planificador.exs
Las tres últimas líneas son el experimento más instructivo del capítulo: con quantum 8, mayor que cualquier ráfaga, Round Robin produce exactamente la misma ocupación que FCFS. Con quantum 1 se acerca a un reparto perfectamente equitativo, con el tiempo de respuesta más bajo y el mayor número de conmutaciones. El algoritmo no cambió: solo el parámetro.
Tabla comparativa de los algoritmos
| Algoritmo | Apropiativo | Necesita conocer la duración | Riesgo de inanición | Retorno promedio en la carga | Espera promedio | Respuesta promedio | Uso típico |
|---|---|---|---|---|---|---|---|
| FCFS | No | No | No | 8.75 | 4.75 | 4.75 | Colas de trabajos por lotes, planificación de disco simple |
| SJF | No | Sí, estimada | Sí, para los largos | 8.00 | 4.00 | 4.00 | Sistemas por lotes con historial de duraciones |
| SRTF | Sí | Sí, estimada | Sí, mayor que SJF | 7.00 | 3.00 | 0.50 | Referencia teórica de espera mínima |
| Prioridades | Ambas variantes | No | Sí, sin envejecimiento | 9.50 | 5.50 | 5.50 | Sistemas con clases de servicio diferenciadas |
| Round Robin q=2 | Sí | No | No | 9.00 | 5.00 | 1.50 | Sistemas interactivos y de tiempo compartido |
| Colas multinivel con retroalimentación | Sí | No | No, gracias al envejecimiento | Depende de la configuración | Sistemas de propósito general reales |
Lee la tabla en columnas, no en filas. SRTF gana en espera y pierde en equidad. Round Robin pierde en retorno y gana en respuesta. Y el que efectivamente se usa en sistemas de propósito general es el último, que no gana ninguna columna concreta pero se comporta razonablemente sin necesitar información que nadie tiene.
Cómo lo hacen los sistemas reales
Linux usó un planificador de tiempo constante hasta la versión 2.6.23, cuando lo reemplazó por el planificador completamente justo, que abandona la idea de quantum fijo: cada proceso acumula un tiempo virtual de ejecución ponderado por su valor de cortesía, y siempre corre el que tenga el tiempo virtual más bajo. La estructura de datos es un árbol binario balanceado ordenado por ese tiempo virtual. Desde la versión 6.6 el planificador por defecto de la clase normal es EEVDF, que añade a esa idea una noción explícita de plazo para acotar la latencia de los procesos que consumen poco. En paralelo conviven clases de tiempo real con prioridad absoluta sobre las anteriores.
Windows usa un esquema de prioridades con 32 niveles y Round Robin dentro de cada nivel, con aumentos temporales de prioridad cuando un hilo termina una espera de entrada/salida o cuando pertenece a la ventana en primer plano.
La máquina virtual de Elixir ejecuta un planificador por núcleo, cada uno con su propia cola y con robo de trabajo entre ellas. La apropiación no se basa en tiempo sino en un contador de trabajo realizado: cada operación consume unidades de ese contador y, al agotarse la cuota, el proceso se desaloja. Como el contador se incrementa también en operaciones de recepción de mensajes y llamadas, ningún proceso puede monopolizar su planificador.
Los sistemas de tiempo real usan familias distintas, con planificación por plazos y análisis de planificabilidad demostrable antes de ejecutar. Ada define un modelo de tareas con prioridades y políticas de despacho especificables desde el propio programa mediante directivas al compilador.
# Politica y prioridad de cada proceso
# cls: TS tiempo compartido, FF FIFO de tiempo real, RR Round Robin de tiempo real
ps -eo pid,cls,rtprio,ni,pri,comm | head -20
# En que nucleo corre y cuantas conmutaciones y migraciones lleva
ps -o pid,psr,comm -p $$
grep -E 'nr_switches|nr_migrations' /proc/$$/sched
# Fijar un proceso a un nucleo concreto
taskset -c 0 sha256sum /dev/zero &
ps -o pid,psr,comm -p $!; kill %1
Errores comunes
| Error frecuente | Por qué se produce | Cómo corregirlo |
|---|---|---|
Creer que fork devuelve el identificador del hijo en ambos procesos | Solo se lee la línea de la llamada, no las dos ramas | Devuelve 0 en el hijo y el identificador del hijo en el padre. Siempre hay que distinguir el caso con un if |
| Esperar que el padre vea los cambios que hace el hijo en una variable | Se asume que “duplicar el proceso” mantiene la memoria unida | La memoria se duplica lógicamente. Para compartir hace falta un mecanismo explícito: memoria compartida, tubería o archivo |
Que la salida aparezca duplicada tras un fork | El búfer de la biblioteca de C tenía texto pendiente al bifurcarse y se copió | Llamar fflush(stdout) antes de fork, o usar salida sin búfer |
Esperar que exec retorne al programa que lo llamó | Se lo trata como una llamada a función normal | Si tiene éxito no retorna: la imagen fue reemplazada. El código posterior solo se ejecuta si falló |
| Acumular procesos zombis en un servidor | El padre lanza hijos y nunca llama a wait | Recoger con waitpid en un manejador de SIGCHLD, o usar WNOHANG en el bucle principal |
Implementar cd como un comando externo en un intérprete propio | Se trata igual que cualquier otro comando | El cambio de directorio afecta al PCB del que lo ejecuta. Debe hacerse en el propio shell con chdir |
| Suponer que un proceso pasa de bloqueado a en ejecución | El diagrama se lee de memoria y no de la estructura | Un proceso desbloqueado entra a la cola de listos y compite. La transición directa no existe |
Interpretar el estado D de ps como un proceso colgado por un error | El proceso no responde ni a SIGKILL | Es sueño no interrumpible en entrada/salida. No se puede matar hasta que la operación del dispositivo termine o falle |
| Creer que más hilos siempre significa más velocidad | Se equipara concurrencia con paralelismo | Pasado el número de núcleos, los hilos extra solo agregan cambios de contexto y contención por los candados |
| Elegir un quantum muy pequeño para “mejorar la respuesta” | Se ve solo el beneficio de rotar rápido | Si el quantum se acerca al coste de conmutar, la mayor parte del procesador se gasta cambiando de contexto |
| Subir la prioridad de un proceso para que “vaya más rápido” | Se confunde prioridad con velocidad | La prioridad solo decide quién corre cuando hay competencia. Sin competencia no cambia nada, y si el proceso está bloqueado en disco tampoco |
| Creer que SJF se puede implementar tal cual en un sistema real | El teorema de optimalidad lo hace atractivo | Requiere conocer la duración futura de cada ráfaga. En la práctica se estima con promedios exponenciales y el resultado es aproximado |
| Confundir tiempo de espera con tiempo de retorno | Ambos suenan a “cuánto tardó” | Retorno = finalización − llegada. Espera = retorno − duración. El retorno incluye el tiempo ejecutando |
| Usar planificación por prioridades sin envejecimiento en un sistema con carga variable | Funciona bien en las pruebas con pocos procesos | Sin envejecimiento, los de prioridad baja pueden no ejecutar nunca. Hay que subir la prioridad con el tiempo de espera |
Ejercicios propuestos
Los primeros son de observación sobre tu propio sistema; los últimos requieren escribir y medir código.
Ejercicio 1 — Censo de estados. Ejecuta ps -eo stat= | cut -c1 | sort | uniq -c | sort -rn en tu máquina en tres momentos distintos: recién arrancada, durante una compilación y durante una descarga grande. Explica cómo cambia la proporción entre S, R y D en cada caso.
Ejercicio 2 — Leer el PCB. Toma el identificador de tu shell y extrae de /proc/PID/status los campos State, Threads, VmRSS, voluntary_ctxt_switches y nonvoluntary_ctxt_switches. Ejecuta un bucle de cálculo puro en el shell y vuelve a leerlos. Explica cuál de los dos contadores de conmutación creció y por qué.
Ejercicio 3 — Nietos. Modifica bifurcar.c para que el hijo haga a su vez un fork, creando un nieto. Haz que los tres impriman su identificador y el de su padre. Inserta un sleep en los puntos necesarios para observar el árbol completo con pstree -p desde otra terminal.
Ejercicio 4 — Medir el coste de fork. Escribe un programa que haga mil fork seguidos, con el hijo llamando a _exit(0) de inmediato y el padre esperando. Mídelo con time. Repítelo reservando previamente cien megabytes de memoria y escribiendo en ellos. Compara los tiempos y explica el resultado en términos de copia sobre escritura.
Ejercicio 5 — Ampliar el minishell. Agrega a minishell.c soporte para ejecutar comandos en segundo plano cuando la línea termine en &: el padre no debe esperar, pero debe recoger los hijos terminados con waitpid y la opción WNOHANG al principio de cada iteración del bucle. Verifica con ps que no quedan zombis.
Ejercicio 6 — Redirección. Agrega al minishell soporte para comando > archivo. La pista está en la herencia: el hijo, entre el fork y el exec, puede abrir el archivo y hacer que el descriptor 1 apunte a él. Investiga la llamada dup2 y explica por qué esto tiene que hacerse en el hijo y no en el padre.
Ejercicio 7 — Cuantificar la carrera. Ejecuta hilos.c veinte veces guardando el valor del contador sin proteger. Calcula cuánto se pierde en promedio y cuál es la mayor pérdida observada. Ahora ejecútalo con taskset -c 0 para forzar un solo núcleo y repite la medición. Explica la diferencia.
Ejercicio 8 — Hilos contra procesos. Escribe dos versiones del mismo cálculo: una que use cuatro hilos y otra que use cuatro procesos creados con fork, donde cada uno sume un cuarto de un arreglo grande. Mide ambas con perf stat -e context-switches,page-faults. Explica de dónde salen las diferencias de fallos de página.
Ejercicio 9 — Trazas a mano. Con la carga P1(0,7), P2(2,4), P3(4,1), P4(5,4), calcula a mano el diagrama de ocupación y las tres métricas para Round Robin con quantum 3 y con quantum 5. Verifica tus resultados con el simulador.
Ejercicio 10 — Curva del quantum. Modifica planificador.exs para que recorra los quantums de 1 a 10 sobre una carga de ocho procesos e imprima una tabla con el tiempo de respuesta promedio, el de retorno promedio y el número de conmutaciones de cada uno. Identifica el punto donde dejar de bajar el quantum ya no mejora la respuesta.
Ejercicio 11 — Añadir envejecimiento. Extiende el simulador con planificación por prioridades apropiativa más envejecimiento: cada cinco unidades de espera, la prioridad numérica de un proceso listo baja en uno. Compara la traza de P3 con y sin envejecimiento sobre la carga del capítulo.
Ejercicio 12 — Simular colas multinivel. Implementa en el simulador tres colas con quantums 2, 4 y 8, con las reglas de descenso por agotar el quantum y de ascenso por bloquearse. Para modelarlo, añade a cada proceso una lista de instantes en los que se bloquea. Compara el tiempo de respuesta de un proceso interactivo frente a uno de cálculo puro.
Ejercicio 13 — Prioridad en la práctica. Lanza dos procesos idénticos de cálculo intenso fijados al mismo núcleo con taskset -c 0, uno con nice -n 0 y otro con nice -n 19. Mide con ps -o pid,ni,time,comm cuánto tiempo de procesador acumuló cada uno tras un minuto. Calcula la razón entre ambos.
Ejercicio 14 — Tareas en Ada. Modifica tareas.adb para que las tres tareas hagan cálculo puro sin ninguna sentencia delay. Observa si las líneas se siguen intercalando y explica el resultado en términos del modelo de hilos que usa el sistema de tiempo de ejecución de Ada en tu plataforma.
Lo que viene
Este capítulo convirtió el proceso de una definición de una línea en una estructura concreta y observable. Vimos qué guarda el bloque de control y por qué vive en memoria del kernel; recorrimos los estados y comprobamos que las transiciones que faltan son tan informativas como las que están; construimos el árbol de procesos con fork, lo poblamos con exec y lo limpiamos con wait; separamos la posesión de recursos de la ejecución para llegar a los hilos; medimos el coste real de conmutar; y comparamos con números cinco políticas de reparto del procesador sobre la misma carga.
Hay un cabo suelto y es grande. En el ejemplo de los hilos en C, dos contadores que recibían exactamente los mismos incrementos terminaron con valores distintos: el protegido siempre daba el número correcto y el desprotegido casi nunca. Eso no fue un error del programa ni un fallo del hardware. Fue la consecuencia inevitable de que el planificador puede desalojar un hilo en medio de una operación que parecía indivisible, y de que la memoria compartida no tiene ninguna noción de “esto no se interrumpe”.
Ese es el tema del capítulo 7: las condiciones de carrera, la sección crítica, los requisitos que debe cumplir cualquier solución correcta, y el catálogo de formas en que un programa concurrente puede fallar sin que ninguna línea de código esté mal escrita por separado. A partir de ahí llegan los mecanismos que las resuelven.
El temario completo del curso está en el índice general.