Los roles del sistema operativo: árbitro, ilusionista y pegamento

Por: Artiko
sistemas-operativosadaelixirconcurrenciaprocesosmemoria-virtualllamadas-al-sistemakernelproteccion

Los roles del sistema operativo: árbitro, ilusionista y pegamento

En el capítulo 2 seguimos la línea que va desde los operadores humanos que cargaban tarjetas perforadas hasta los kernels multiusuario y los hipervisores modernos. Vimos que cada salto histórico apareció porque el hardware se volvió más rápido que la persona que lo operaba, y que la respuesta siempre fue la misma: mover trabajo repetitivo desde el humano hacia una capa de software permanente. Ese recorrido nos dejó una pregunta abierta que este capítulo responde: una vez que esa capa existe, ¿qué hace exactamente?

La respuesta corta es que un sistema operativo hace tres cosas al mismo tiempo, y las hace sobre cinco recursos distintos. Los tres papeles son arbitrar, abstraer y estandarizar. Los cinco recursos son CPU, memoria, almacenamiento, dispositivos y comunicación. El cruce de esos dos ejes produce todo lo que llamamos “funciones del sistema operativo”: planificación de procesos, memoria virtual, sistemas de archivos, controladores de dispositivo, sockets, permisos. Y todo eso se expone al programador por una única puerta: la interfaz de llamadas al sistema.

Este capítulo desarma esa puerta. No nos vamos a quedar en la enumeración de funciones que aparece en cualquier índice; vamos a mirar el mecanismo real de cada una, hasta el punto en que puedas escribir un programa que lo ejercite y observar el efecto con tus propias herramientas.

El punto de partida: sin sistema operativo

Antes de describir los roles conviene tener claro qué se rompe cuando no hay nadie cumpliéndolos. Imagina una máquina donde los programas se ejecutan directamente sobre el hardware, sin ninguna capa intermedia. Ese escenario tiene cuatro problemas que aparecen inmediatamente.

El primero es que un programa puede quedarse con la CPU para siempre. Este programa en C es perfectamente válido y no tiene errores de sintaxis:

#include <stdio.h>

int main(void) {
    unsigned long contador = 0;
    while (1) {
        contador++;
        if (contador % 100000000UL == 0) {
            printf("sigo vivo: %lu\n", contador);
        }
    }
    return 0;
}

Sin sistema operativo, ese bucle es el fin de la sesión: la máquina no vuelve a atender a nadie más hasta que alguien corte la energía. Con sistema operativo puedes ejecutarlo, abrir otra terminal, y matarlo. Nada en el programa cambió; lo que cambió es que ya no es él quien decide cuánto dura su turno.

El segundo problema es que un programa puede escribir en la memoria de otro. Si dos programas comparten el mismo espacio de direcciones físicas, un puntero mal calculado en el primero corrompe silenciosamente los datos del segundo. El error se manifiesta en el programa inocente, lo cual hace la depuración casi imposible.

El tercer problema es que cada programa tendría que hablar con el hardware exacto que le tocó. Un editor de texto necesitaría código distinto para un disco SATA, para un NVMe y para una unidad de red. Cambiar el disco significaría recompilar todas las aplicaciones.

El cuarto es que no habría forma de que dos programas colaboren sin pisarse. Cualquier intercambio de datos exigiría que ambos acordaran de antemano una zona de memoria y un protocolo, y que nadie más la tocara.

El sistema operativo existe para eliminar esos cuatro problemas. Los tres roles que veremos a continuación son exactamente las tres estrategias que usa.

Los tres roles: árbitro, ilusionista y pegamento

Una manera clásica y muy útil de organizar todo lo que hace un sistema operativo es agruparlo en tres papeles. Cada uno responde a una pregunta distinta.

flowchart TD
    SO["Sistema operativo"]

    SO --> R1["Árbitro<br/>¿quién usa qué y por cuánto tiempo?"]
    SO --> R2["Ilusionista<br/>¿cómo se ve el hardware desde el programa?"]
    SO --> R3["Pegamento<br/>¿qué servicios comparten todos?"]

    R1 --> A1["Aislamiento de fallas"]
    R1 --> A2["Reparto de recursos"]
    R1 --> A3["Comunicación controlada"]

    R2 --> B1["Exclusividad aparente"]
    R2 --> B2["Recursos aparentemente ilimitados"]
    R2 --> B3["Capacidades que el hardware no tiene"]

    R3 --> C1["Sistema de archivos"]
    R3 --> C2["Protocolos de red"]
    R3 --> C3["Interfaz de usuario y bibliotecas"]

    style SO fill:#1f2937,color:#fff
    style R1 fill:#7f1d1d,color:#fff
    style R2 fill:#1e3a5f,color:#fff
    style R3 fill:#14532d,color:#fff

El árbitro

El sistema operativo es la única entidad autorizada a decir “tu turno terminó”. Ese poder se apoya en tres mecanismos que veremos en detalle más adelante: el modo dual de ejecución, el temporizador de hardware que genera interrupciones periódicas, y la unidad de gestión de memoria que hace imposible que un proceso direccione memoria que no le pertenece.

Como árbitro cumple tres funciones concretas:

  • Aislamiento de fallas: si un programa entra en bucle infinito, escribe en un puntero nulo o consume toda su memoria, el daño se detiene en su frontera. El resto del sistema sigue funcionando.
  • Reparto de recursos: decide qué proceso corre en cada núcleo, cuánta memoria física recibe cada uno, en qué orden se atienden las peticiones al disco.
  • Comunicación controlada: cuando dos procesos aislados necesitan hablar, el sistema operativo provee canales explícitos (tuberías, sockets, memoria compartida solicitada) en lugar de dejar que se descubran mutuamente por accidente.

El ilusionista

El segundo papel consiste en presentarle a cada programa una máquina más simple y más grande que la real. El programa cree que:

  • Tiene la CPU entera para sí. En realidad la comparte con cientos de procesos y la pierde miles de veces por segundo.
  • Tiene un espacio de direcciones continuo y privado que puede empezar en la misma dirección que el de cualquier otro programa. En realidad la memoria física está fragmentada, compartida y en parte guardada en disco.
  • Tiene “archivos”, que son secuencias de bytes con nombre. En realidad hay bloques numerados en un dispositivo que no entiende de nombres.

La tabla siguiente resume la traducción entre el mundo físico y el mundo que ve el programador. Es una de las tablas más importantes del curso, porque cada fila corresponde a un capítulo posterior.

Recurso físicoAbstracción que ofrece el SOUnidad manipulableEjemplo de API
Núcleo de CPUHilo de ejecuciónContexto de ejecución planificablepthread_create, spawn en Elixir, task en Ada
Memoria RAMEspacio de direcciones virtualPágina de memoriammap, malloc
Disco o SSDArchivo y directorioSecuencia de bytes con nombreopen, read, write
Interfaz de redSocketFlujo o datagramasocket, connect, send
Máquina completaProcesoPrograma en ejecución aisladofork, execve
Reloj de hardwareTemporizadores y relojes lógicosInstante y duraciónclock_gettime, nanosleep
Teclado, mouse, sensoresFlujo de eventosEvento con marca de tiempolectura sobre descriptores de dispositivo

La ilusión no es gratuita. Cada abstracción tiene un costo en rendimiento y un punto donde se rompe. La memoria virtual permite pedir más memoria de la que hay, pero cuando el sistema empieza a mover páginas al disco el rendimiento cae de forma abrupta. Un buen ingeniero de sistemas sabe dónde está el borde de cada ilusión.

El pegamento

El tercer papel es menos glamoroso pero explica buena parte del tamaño de un kernel moderno. Si cada aplicación tuviera que implementar su propio sistema de archivos, su propia pila TCP/IP y su propio manejo de fuentes, no habría interoperabilidad posible. El sistema operativo ofrece un conjunto de servicios comunes y, con eso, define un contrato: todos los programas de la máquina hablan el mismo idioma para nombrar archivos, para abrir conexiones y para intercambiar datos.

Ese pegamento es también lo que hace posible el comando | de la terminal. Cuando escribes ls | grep .md, dos programas que jamás se conocieron intercambian datos porque ambos aceptaron la misma convención: entrada estándar y salida estándar como flujos de bytes.

Rol sobre la CPU: gestión de procesos

Un proceso es la abstracción de “una máquina completa para un programa”. Contiene el código, los datos, la pila, el espacio de direcciones, los archivos abiertos, la identidad del usuario que lo lanzó y el estado de los registros de CPU. Es la unidad de aislamiento: dos procesos no pueden verse la memoria a menos que lo pidan explícitamente.

Qué guarda el kernel por cada proceso

El kernel mantiene una estructura de datos por proceso, tradicionalmente llamada bloque de control de proceso (en Linux es struct task_struct). Contiene, entre otras cosas:

CampoPara qué sirve
Identificador del procesoNombrarlo desde otros procesos y desde el usuario
EstadoSaber si es candidato a correr, si espera algo, o si terminó
Contexto de registrosRestaurar la ejecución exactamente donde se interrumpió
Puntero a la tabla de páginasInstalar su espacio de direcciones al darle la CPU
Tabla de descriptores de archivoTraducir números pequeños a archivos abiertos
Identidad de usuario y grupoDecidir qué tiene permitido
Contabilidad de usoTiempo de CPU consumido, memoria residente, límites
Proceso padre y lista de hijosPropagar terminación y recolectar códigos de salida

El ciclo de vida de un proceso

Un proceso pasa por un conjunto acotado de estados. Entender este diagrama es entender por qué un programa “lento” puede no estar usando nada de CPU.

stateDiagram-v2
    [*] --> Nuevo: fork o spawn
    Nuevo --> Listo: admitido por el planificador
    Listo --> Ejecutando: el planificador le asigna un núcleo
    Ejecutando --> Listo: expira su cuanto de tiempo
    Ejecutando --> Bloqueado: pide E/S o espera un evento
    Bloqueado --> Listo: llega el dato o el evento
    Ejecutando --> Zombi: llama a exit
    Zombi --> [*]: el padre recolecta el código de salida

    note right of Bloqueado
        Aquí no consume CPU.
        Un proceso puede pasar
        el 99 por ciento de su vida
        en este estado.
    end note

    note right of Ejecutando
        Solo hay tantos procesos
        en este estado como
        núcleos disponibles.
    end note

La distinción entre Listo y Bloqueado es la más importante en la práctica. Un proceso Listo quiere CPU y no la tiene: si tu sistema está lento y hay muchos procesos en este estado, el cuello de botella es la CPU. Un proceso Bloqueado no quiere CPU: espera un disco, una respuesta de red o una tecla. Si tu sistema está lento y todos los procesos están bloqueados, el problema es de entrada/salida y agregar núcleos no ayuda.

Crear procesos en C: fork y exec

El modelo de creación de procesos de Unix separa dos operaciones que en otros sistemas van juntas: duplicar un proceso y reemplazar su programa. Este ejemplo completo lo muestra.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    printf("Padre: mi PID es %d\n", getpid());
    fflush(stdout);

    pid_t hijo = fork();

    if (hijo < 0) {
        perror("fork");
        return 1;
    }

    if (hijo == 0) {
        /* Estamos en el hijo: mismo código, memoria copiada. */
        printf("Hijo: mi PID es %d, mi padre es %d\n", getpid(), getppid());
        fflush(stdout);

        char *argumentos[] = { "echo", "Hijo: ahora soy otro programa", NULL };
        execvp("echo", argumentos);

        /* Solo se llega aquí si execvp falló. */
        perror("execvp");
        _exit(127);
    }

    /* Estamos en el padre. */
    int estado = 0;
    if (waitpid(hijo, &estado, 0) < 0) {
        perror("waitpid");
        return 1;
    }

    if (WIFEXITED(estado)) {
        printf("Padre: el hijo %d terminó con código %d\n", hijo, WEXITSTATUS(estado));
    } else if (WIFSIGNALED(estado)) {
        printf("Padre: el hijo %d murió por la señal %d\n", hijo, WTERMSIG(estado));
    }

    return 0;
}

Para compilarlo y ejecutarlo:

gcc -Wall -Wextra -o procesos procesos.c
./procesos

Hay tres detalles que vale la pena aislar.

fork devuelve dos veces. Una vez en el padre, con el PID del hijo, y otra vez en el hijo, con cero. Es la única función en C que hace eso, y solo puede hacerlo porque quien la implementa es el kernel, no la biblioteca.

La copia de memoria es una ilusión dentro de otra ilusión. El hijo cree tener una copia completa del espacio de direcciones del padre, pero el kernel no copia nada al principio: marca todas las páginas como solo lectura y compartidas. Recién cuando alguno de los dos intenta escribir, el hardware genera una falla, el kernel duplica esa página específica y deja continuar la escritura. Esa técnica se llama copia al escribir y es lo que hace que fork sea barato incluso en procesos de varios gigabytes.

El proceso zombi no es un error. Cuando el hijo termina, el kernel no puede descartar su entrada inmediatamente: alguien tiene que leer el código de salida. Hasta que el padre llame a waitpid, el hijo queda en estado zombi ocupando solo su bloque de control. Si el padre nunca lo recoge y sigue creando hijos, la tabla de procesos se llena. Este es un error real y frecuente en servidores escritos a mano.

Procesos ligeros en Elixir: la misma idea, otro nivel

El modelo de procesos no es exclusivo del kernel. La máquina virtual BEAM, sobre la que corre Elixir, implementa su propio planificador y sus propios procesos aislados dentro de un único proceso del sistema operativo. Es un buen espejo conceptual: hace las mismas cosas que el kernel, pero en el espacio de usuario y con un costo mucho menor por proceso.

defmodule Contador do
  @moduledoc "Un proceso aislado que mantiene estado propio."

  def iniciar(valor_inicial \\ 0) do
    spawn(fn -> bucle(valor_inicial) end)
  end

  defp bucle(valor) do
    receive do
      {:incrementar, n} ->
        bucle(valor + n)

      {:leer, solicitante} ->
        send(solicitante, {:valor, self(), valor})
        bucle(valor)

      :detener ->
        IO.puts("Contador #{inspect(self())} termina con #{valor}")
        :ok
    end
  end
end

defmodule Demo do
  def correr do
    a = Contador.iniciar(0)
    b = Contador.iniciar(100)

    IO.puts("Proceso principal: #{inspect(self())}")
    IO.puts("Procesos vivos en la VM: #{:erlang.system_info(:process_count)}")

    send(a, {:incrementar, 5})
    send(a, {:incrementar, 7})
    send(b, {:incrementar, 1})

    send(a, {:leer, self()})
    send(b, {:leer, self()})

    recibir_dos()

    send(a, :detener)
    send(b, :detener)
    Process.sleep(50)
  end

  defp recibir_dos do
    receive do
      {:valor, pid, v} -> IO.puts("#{inspect(pid)} vale #{v}")
    after
      1000 -> IO.puts("sin respuesta")
    end

    receive do
      {:valor, pid, v} -> IO.puts("#{inspect(pid)} vale #{v}")
    after
      1000 -> IO.puts("sin respuesta")
    end
  end
end

Demo.correr()

Se ejecuta con:

elixir contador.exs

Las coincidencias con el modelo del kernel son deliberadas. Cada proceso de la BEAM tiene su propia pila y su propio montón, no comparte memoria con nadie, se comunica solo por mensajes, tiene un identificador y puede terminar sin arrastrar a los demás. La diferencia está en la escala: un proceso del sistema operativo cuesta del orden de kilobytes de estructuras del kernel y su cambio de contexto implica cruzar el modo de ejecución; un proceso de la BEAM arranca con unos cientos de bytes y su cambio de contexto ocurre íntegramente en espacio de usuario.

Esa diferencia de costo es la razón por la que un servidor en Elixir puede tener cientos de miles de procesos concurrentes y un servidor que usa un proceso del sistema por conexión no puede. La abstracción es la misma; el precio, no.

Tareas en Ada: concurrencia con contrato

Ada aborda la misma abstracción desde otro ángulo. En lugar de una función que lanza un proceso, el lenguaje tiene un tipo de primera clase, la tarea, y un mecanismo de sincronización directa llamado cita o rendezvous, donde dos tareas se encuentran en un punto acordado.

with Ada.Text_IO; use Ada.Text_IO;

procedure Demo_Tareas is

   task type Trabajador (Id : Positive) is
      entry Arrancar (Repeticiones : Positive);
      entry Detener;
   end Trabajador;

   task body Trabajador is
      Total : Positive := 1;
   begin
      accept Arrancar (Repeticiones : Positive) do
         Total := Repeticiones;
      end Arrancar;

      for Paso in 1 .. Total loop
         Put_Line ("Trabajador" & Positive'Image (Id) &
                   " ejecuta el paso" & Integer'Image (Paso));
         delay 0.05;
      end loop;

      accept Detener;
      Put_Line ("Trabajador" & Positive'Image (Id) & " finalizado");
   end Trabajador;

   A : Trabajador (1);
   B : Trabajador (2);

begin
   Put_Line ("Principal: las tareas ya existen y esperan en Arrancar");

   A.Arrancar (3);
   B.Arrancar (2);

   A.Detener;
   B.Detener;

   Put_Line ("Principal: termina");
end Demo_Tareas;

Para compilarlo con GNAT:

gnatmake demo_tareas.adb
./demo_tareas

Lo interesante aquí es que la sincronización es explícita en la declaración del tipo. La tarea Trabajador publica que acepta dos puntos de encuentro, Arrancar y Detener, y quien llame a A.Arrancar (3) queda bloqueado hasta que la tarea llegue a su accept correspondiente. Es el mismo aislamiento que da un proceso, con un contrato de comunicación verificado por el compilador.

Los tres modelos que acabas de ver (procesos del kernel, procesos de la BEAM, tareas de Ada) resuelven el mismo problema en tres niveles distintos. En el capítulo 6 los vamos a comparar con más detalle.

Rol sobre la memoria: espacios de direcciones

La gestión de memoria es probablemente la ilusión más elaborada que sostiene un sistema operativo. Cada proceso ve un espacio de direcciones que empieza en cero, es continuo, es privado y es enorme. Nada de eso es cierto a nivel físico.

La prueba en tres líneas

Este programa imprime la dirección de una variable en el montón y luego se queda incrementando su contenido.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    int *p = malloc(sizeof(int));
    if (p == NULL) {
        perror("malloc");
        return 1;
    }

    printf("PID %d: la dirección de p es %p\n", getpid(), (void *)p);

    *p = 0;
    for (int i = 0; i < 5; i++) {
        *p = *p + 1;
        printf("PID %d: *p = %d\n", getpid(), *p);
        sleep(1);
    }

    free(p);
    return 0;
}

Compílalo y ejecútalo dos o tres veces al mismo tiempo:

gcc -Wall -o memoria memoria.c
./memoria & ./memoria & ./memoria &

Verás procesos con PIDs distintos imprimiendo direcciones que pueden coincidir exactamente, y sin embargo sus contadores avanzan de forma independiente. La misma dirección numérica, en dos procesos distintos, apunta a memoria física diferente. Eso es un espacio de direcciones virtual.

En Linux con ASLR activo las direcciones variarán entre ejecuciones, lo cual es una medida de seguridad. Puedes desactivarlo temporalmente para una ejecución concreta con setarch $(uname -m) -R ./memoria y comprobar que entonces las direcciones son idénticas entre procesos.

Cómo funciona la traducción

Entre la dirección que usa tu programa y la celda física de RAM hay una pieza de hardware llamada unidad de gestión de memoria. Su trabajo es traducir, en cada acceso, una dirección virtual a una dirección física, consultando unas tablas que el kernel construyó.

flowchart LR
    CPU["CPU ejecuta<br/>mov rax, [0x7f3a2c00]"]
    CPU --> MMU["MMU"]
    MMU --> TLB{"¿La traducción<br/>está en el TLB?"}

    TLB -->|"sí, acierto"| FIS["Dirección física<br/>lista en 1 ciclo"]
    TLB -->|"no, fallo"| TABLA["Recorre la tabla de páginas<br/>apuntada por el registro CR3"]

    TABLA --> PRES{"¿La página está<br/>presente en RAM?"}
    PRES -->|"sí"| CARGA["Carga la traducción en el TLB"]
    CARGA --> FIS

    PRES -->|"no"| FALLO["Excepción: fallo de página"]
    FALLO --> KERNEL["El kernel toma el control"]
    KERNEL --> DEC{"¿La dirección es<br/>válida para este proceso?"}

    DEC -->|"no"| SEGV["Señal SIGSEGV<br/>al proceso"]
    DEC -->|"sí, está en disco"| SWAP["Lee la página desde disco<br/>o desde el archivo mapeado"]
    DEC -->|"sí, es primera escritura"| COW["Asigna un marco nuevo<br/>o duplica por copia al escribir"]

    SWAP --> REINT["Reintenta la instrucción"]
    COW --> REINT
    REINT --> MMU

    FIS --> RAM["Acceso a RAM"]

    style KERNEL fill:#7f1d1d,color:#fff
    style SEGV fill:#991b1b,color:#fff
    style FIS fill:#14532d,color:#fff

Cinco observaciones sobre este diagrama.

El TLB es lo que hace viable el esquema. Recorrer la tabla de páginas requiere cuatro accesos a memoria en x86-64, porque la tabla tiene cuatro niveles. Hacer eso en cada acceso multiplicaría por cinco el costo de leer un byte. El TLB es una caché de traducciones recientes que resuelve la enorme mayoría de los accesos en un ciclo.

El fallo de página no es un error. Es el mecanismo normal por el cual el kernel entera de que un proceso necesita memoria que aún no tiene. La primera vez que escribes en una página recién pedida con malloc, se produce un fallo de página, y eso es lo esperado.

El error de segmentación es la validación funcionando. Cuando ves Segmentation fault, el hardware detectó un acceso a una dirección que la tabla de páginas de tu proceso no describe, avisó al kernel, y el kernel decidió que no había forma legítima de resolverlo. Sin memoria virtual ese acceso habría corrompido la memoria de otro programa en silencio.

La memoria “usada” por un proceso es ambigua. Un proceso puede tener reservado un espacio virtual de 10 GB y ocupar 40 MB de RAM real. Por eso las herramientas distinguen entre tamaño virtual y tamaño residente.

El cambio de contexto entre procesos implica cambiar la tabla de páginas. Al cambiar el registro que apunta a la raíz de la tabla, se invalida buena parte del TLB. Ese es uno de los costos ocultos de tener demasiados procesos alternándose.

El mapa de un proceso, en vivo

Linux expone el espacio de direcciones de cada proceso como un archivo de texto. Este comando muestra el tuyo:

cat /proc/self/maps

Verás líneas con el rango de direcciones, los permisos (r, w, x, p de privado o s de compartido) y el archivo del que proviene cada región. Reconocerás el ejecutable, sus bibliotecas compartidas, el montón y la pila. Es el mapa literal de la ilusión.

Las regiones típicas de un proceso son estas:

RegiónPermisos habitualesContenidoCrece hacia
Textolectura y ejecuciónCódigo máquina del programano crece
Datos inicializadoslectura y escrituraVariables globales con valor inicialno crece
Datos sin inicializarlectura y escrituraVariables globales en cerono crece
Montónlectura y escrituraMemoria dinámica de mallocdirecciones altas
Regiones mapeadasvariableBibliotecas compartidas y archivos mapeadosasignadas por el kernel
Pilalectura y escrituraMarcos de llamada y variables localesdirecciones bajas

Pedir memoria al kernel directamente

malloc no es una llamada al sistema; es una función de biblioteca que administra un montón y pide bloques grandes al kernel cuando se queda sin espacio. La llamada real es mmap. Este ejemplo la usa de forma directa:

#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

int main(void) {
    long tam_pagina = sysconf(_SC_PAGESIZE);
    size_t tam = (size_t)tam_pagina * 4;

    void *region = mmap(NULL, tam,
                        PROT_READ | PROT_WRITE,
                        MAP_PRIVATE | MAP_ANONYMOUS,
                        -1, 0);

    if (region == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    printf("Tamaño de página: %ld bytes\n", tam_pagina);
    printf("Región de %zu bytes obtenida en %p\n", tam, region);

    char *texto = (char *)region;
    strcpy(texto, "esta memoria vino directo del kernel");
    printf("Contenido: %s\n", texto);

    if (munmap(region, tam) != 0) {
        perror("munmap");
        return 1;
    }

    printf("Región devuelta al kernel\n");
    return 0;
}
gcc -Wall -o mapeo mapeo.c
./mapeo

Fíjate que la dirección devuelta siempre estará alineada al tamaño de página. El kernel no reparte bytes sueltos: reparte páginas, normalmente de 4096 bytes.

Rol sobre el almacenamiento: sistemas de archivos

Un disco no sabe qué es un archivo. Sabe leer y escribir bloques numerados de tamaño fijo. Todo lo demás (nombres, carpetas, permisos, fechas, la posibilidad de que un archivo crezca) lo construye el sistema operativo encima.

Las tres capas del acceso a un archivo

Cuando tu programa escribe en un archivo, el dato atraviesa tres traducciones sucesivas.

flowchart TD
    subgraph usuario["Espacio de usuario"]
        APP["El programa tiene el descriptor 3<br/>y llama a write(3, buf, 100)"]
    end

    subgraph kernel["Kernel"]
        TDF["Tabla de descriptores del proceso<br/>índice 3 apunta a una entrada global"]
        TAG["Tabla global de archivos abiertos<br/>guarda el desplazamiento actual y el modo"]
        INODO["Inodo<br/>metadatos, permisos, tamaño,<br/>lista de bloques del archivo"]
        VFS["Capa de sistema de archivos virtual<br/>elige la implementación concreta"]
        FSC["Implementación concreta<br/>ext4, XFS, Btrfs, FAT"]
        CACHE["Caché de páginas<br/>acumula escrituras en RAM"]
        DRV["Controlador del dispositivo de bloques"]
    end

    subgraph hw["Hardware"]
        DISCO["SSD o disco<br/>bloques numerados"]
    end

    APP --> TDF
    TDF --> TAG
    TAG --> INODO
    INODO --> VFS
    VFS --> FSC
    FSC --> CACHE
    CACHE --> DRV
    DRV --> DISCO

    style kernel fill:#1e293b,color:#fff
    style usuario fill:#164e63,color:#fff
    style hw fill:#3f3f46,color:#fff

La separación entre la tabla de descriptores del proceso y la tabla global de archivos abiertos explica un comportamiento que confunde a mucha gente. Cuando un proceso hace fork, el hijo hereda la tabla de descriptores, pero ambos apuntan a la misma entrada global. Es decir, comparten el desplazamiento: si el padre escribe 100 bytes, el hijo escribirá a partir del byte 100. En cambio, si dos procesos abren el mismo archivo por separado con open, cada uno tiene su propia entrada global y sus desplazamientos son independientes.

Un ejemplo completo de entrada y salida

Este programa crea un archivo, escribe en él, vuelve al principio, lee lo escrito y consulta sus metadatos. Usa exclusivamente llamadas al sistema, sin la capa de stdio.

#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

int main(void) {
    const char *ruta = "prueba_so.txt";
    const char *mensaje = "Los archivos son una abstraccion del sistema operativo.\n";

    int fd = open(ruta, O_CREAT | O_RDWR | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    printf("Archivo abierto con descriptor %d\n", fd);

    ssize_t escritos = write(fd, mensaje, strlen(mensaje));
    if (escritos < 0) {
        perror("write");
        close(fd);
        return 1;
    }
    printf("Se escribieron %zd bytes\n", escritos);

    if (lseek(fd, 0, SEEK_SET) < 0) {
        perror("lseek");
        close(fd);
        return 1;
    }

    char buffer[256];
    ssize_t leidos = read(fd, buffer, sizeof(buffer) - 1);
    if (leidos < 0) {
        perror("read");
        close(fd);
        return 1;
    }
    buffer[leidos] = '\0';
    printf("Se leyeron %zd bytes: %s", leidos, buffer);

    struct stat info;
    if (fstat(fd, &info) < 0) {
        perror("fstat");
        close(fd);
        return 1;
    }

    printf("Inodo: %lu\n", (unsigned long)info.st_ino);
    printf("Tamaño: %lld bytes\n", (long long)info.st_size);
    printf("Bloques asignados: %lld\n", (long long)info.st_blocks);
    printf("Permisos en octal: %o\n", info.st_mode & 07777);

    if (fsync(fd) < 0) {
        perror("fsync");
    }

    close(fd);
    return 0;
}
gcc -Wall -o archivos archivos.c
./archivos

Dos puntos que este ejemplo hace visibles.

El descriptor 3 no es casualidad. Los descriptores 0, 1 y 2 ya están ocupados por la entrada estándar, la salida estándar y el error estándar, que el shell le entregó al proceso al lanzarlo. El kernel asigna siempre el descriptor libre más bajo.

write no significa “el dato está en el disco”. Significa “el dato está en la caché de páginas del kernel y será escrito eventualmente”. Si la máquina pierde energía en ese intervalo, el dato se pierde aunque write haya devuelto éxito. fsync es la llamada que fuerza la bajada al medio físico y espera confirmación. Toda base de datos seria depende de esa distinción.

Los mismos conceptos desde Elixir

Elixir no expone descriptores numéricos, pero por debajo ocurre exactamente lo mismo. Este script hace el equivalente del ejemplo anterior:

defmodule Archivos do
  def demo do
    ruta = "prueba_elixir.txt"
    contenido = "Un archivo es una secuencia de bytes con nombre.\n"

    {:ok, dispositivo} = File.open(ruta, [:write, :binary])
    :ok = IO.binwrite(dispositivo, contenido)
    :ok = File.close(dispositivo)

    {:ok, leido} = File.read(ruta)
    IO.puts("Leído: #{leido}")

    {:ok, info} = File.stat(ruta)
    IO.puts("Tamaño en bytes: #{info.size}")
    IO.puts("Tipo: #{info.type}")
    IO.puts("Permisos en octal: #{Integer.to_string(info.mode, 8)}")

    File.rm!(ruta)
    IO.puts("Archivo eliminado")
  end
end

Archivos.demo()
elixir archivos.exs

La ruta que sigue el byte es idéntica: IO.binwrite termina en una llamada write del kernel. La diferencia está en la ergonomía y en el manejo de errores, no en el mecanismo.

Rol sobre los dispositivos: controladores y uniformidad

Un sistema operativo moderno debe hablar con teclados, pantallas, discos NVMe, tarjetas de red, sensores, impresoras y controles de videojuego. Cada uno tiene su propio protocolo eléctrico y su propio conjunto de registros. Si esa diversidad llegara hasta las aplicaciones, escribir software portable sería imposible.

La solución tiene dos partes. La primera es el controlador de dispositivo: un módulo de código que conoce los detalles de un hardware concreto y expone hacia arriba una interfaz genérica. La segunda es la decisión de presentar los dispositivos como archivos, de modo que las mismas llamadas open, read, write y close sirvan para todo.

flowchart TB
    APP["Aplicación<br/>read sobre un descriptor"]
    APP --> GEN["Interfaz genérica del kernel<br/>dispositivo de caracteres o de bloques"]

    GEN --> D1["Controlador del teclado"]
    GEN --> D2["Controlador NVMe"]
    GEN --> D3["Controlador de red"]

    D1 --> H1["Hardware: teclado USB"]
    D2 --> H2["Hardware: SSD NVMe"]
    D3 --> H3["Hardware: tarjeta Ethernet"]

    H1 -.->|"interrupción: hay una tecla"| ISR["Rutina de atención<br/>de interrupciones"]
    H2 -.->|"interrupción: bloque listo"| ISR
    H3 -.->|"interrupción: paquete recibido"| ISR

    ISR --> DESP["El kernel despierta al proceso<br/>que estaba bloqueado esperando"]
    DESP --> APP

    style GEN fill:#1e3a5f,color:#fff
    style ISR fill:#7f1d1d,color:#fff

El flujo punteado del diagrama es el que suele faltar en las explicaciones. Cuando un proceso llama a read sobre un dispositivo lento, no se queda preguntando en un bucle: el kernel lo marca como bloqueado, entrega la CPU a otro proceso y programa la petición en el hardware. Cuando el hardware termina, levanta una interrupción, que es una señal eléctrica que obliga a la CPU a abandonar lo que estaba haciendo y saltar a una rutina del kernel. Esa rutina copia el dato, marca el proceso como listo, y el planificador eventualmente lo vuelve a poner a correr. Desde el punto de vista del programa, read simplemente “tardó”.

Puedes comprobar la uniformidad leyendo un dispositivo como si fuera un archivo:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(void) {
    int fd = open("/dev/urandom", O_RDONLY);
    if (fd < 0) {
        perror("open /dev/urandom");
        return 1;
    }

    unsigned char bytes[16];
    ssize_t leidos = read(fd, bytes, sizeof(bytes));
    if (leidos < 0) {
        perror("read");
        close(fd);
        return 1;
    }

    printf("Se leyeron %zd bytes del dispositivo:\n", leidos);
    for (ssize_t i = 0; i < leidos; i++) {
        printf("%02x", bytes[i]);
    }
    printf("\n");

    close(fd);
    return 0;
}
gcc -Wall -o aleatorio aleatorio.c
./aleatorio

El código es indistinguible del que leería un archivo de texto. Sin embargo /dev/urandom no existe en ningún disco: es un generador dentro del kernel. Esa es la abstracción del ilusionista funcionando en su forma más pura.

Los dispositivos se agrupan tradicionalmente en dos familias, y la distinción explica sus APIs:

FamiliaUnidad de accesoEjemplosCaracterística clave
Dispositivos de caracteresbyte a byte, secuencialteclado, puerto serie, /dev/urandomno se puede reposicionar libremente
Dispositivos de bloquesbloque de tamaño fijo, acceso aleatoriodiscos, SSD, memorias USBadmiten caché y planificación de peticiones
Dispositivos de redpaqueteinterfaces Ethernet y wifino se exponen como archivos en el árbol, usan sockets

Rol sobre la seguridad: protección y aislamiento

Todos los mecanismos anteriores dependen de una condición previa: que un programa cualquiera no pueda pasar por encima del kernel. Si un proceso pudiera modificar su propia tabla de páginas, la memoria virtual dejaría de proteger nada. Esa condición se sostiene sobre el modo dual de ejecución, que es una característica del procesador, no del software.

Modo usuario y modo kernel

La CPU tiene un bit de estado que indica en qué modo se ejecuta. En modo kernel, todas las instrucciones están permitidas: se puede modificar la tabla de páginas, deshabilitar interrupciones, acceder a puertos de entrada y salida. En modo usuario, ese subconjunto de instrucciones privilegiadas simplemente falla, y la falla genera una excepción que devuelve el control al kernel.

Las arquitecturas x86 implementan esto con cuatro niveles llamados anillos, numerados de 0 a 3. Linux y Windows usan solo dos: el anillo 0 para el kernel y el anillo 3 para las aplicaciones. Los procesadores con soporte de virtualización agregan un nivel adicional por debajo del anillo 0 para el hipervisor.

La única forma de pasar de modo usuario a modo kernel es a través de un punto de entrada que el propio kernel definió. Hay exactamente tres:

Vía de entradaOrigenEjemplo típico
Llamada al sistemael propio programa la pidewrite, open, fork
Excepciónel programa hizo algo inválidodivisión por cero, fallo de página, acceso no permitido
Interrupciónel hardware avisa de un evento externoel temporizador expiró, llegó un paquete de red

No hay una cuarta. Esa restricción es el fundamento de toda la seguridad del sistema.

Identidad y permisos

Sobre el modo dual se apoya un segundo nivel: la identidad. Cada proceso lleva un identificador de usuario y uno o más identificadores de grupo. Cada archivo lleva un dueño, un grupo y un conjunto de permisos. Cuando un proceso intenta abrir un archivo, el kernel compara ambas cosas antes de conceder el descriptor.

Este programa muestra la identidad efectiva del proceso que lo ejecuta:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(void) {
    printf("PID:  %d\n", getpid());
    printf("PPID: %d\n", getppid());
    printf("UID real: %d, UID efectivo: %d\n", getuid(), geteuid());
    printf("GID real: %d, GID efectivo: %d\n", getgid(), getegid());

    gid_t grupos[64];
    int n = getgroups(64, grupos);
    if (n < 0) {
        perror("getgroups");
        return 1;
    }

    printf("Grupos suplementarios (%d): ", n);
    for (int i = 0; i < n; i++) {
        printf("%d ", (int)grupos[i]);
    }
    printf("\n");

    return 0;
}
gcc -Wall -o identidad identidad.c
./identidad

La distinción entre identificador real y efectivo permite que un programa cambie temporalmente de identidad de forma controlada. Es el mecanismo detrás de comandos que necesitan privilegios puntuales.

Los permisos clásicos de un archivo en sistemas tipo Unix se organizan en tres tríos:

Dígito octalSe aplica aBitsSignificado en un archivoSignificado en un directorio
Primerodueñolectura, escritura, ejecuciónleer contenido, modificarlo, ejecutarlolistar, crear o borrar entradas, atravesarlo
Segundogrupolectura, escritura, ejecuciónigual, para miembros del grupoigual, para miembros del grupo
Tercerootroslectura, escritura, ejecuciónigual, para el restoigual, para el resto

Así, 0644 significa que el dueño puede leer y escribir, y que el grupo y el resto solo pueden leer. Y 0755 en un directorio significa que el dueño puede crear y borrar entradas, y que todos pueden listarlo y atravesarlo.

Poner límites concretos: el árbitro con reglas

El aislamiento no se limita a impedir accesos: también incluye acotar el consumo. Este programa se impone a sí mismo un límite de tiempo de CPU y luego entra en un bucle infinito, para que veas al árbitro actuar.

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/resource.h>
#include <unistd.h>

static volatile sig_atomic_t aviso = 0;

static void manejador(int senal) {
    (void)senal;
    aviso = 1;
}

int main(void) {
    struct rlimit limite;
    limite.rlim_cur = 2;   /* límite blando: 2 segundos de CPU */
    limite.rlim_max = 3;   /* límite duro: 3 segundos */

    if (setrlimit(RLIMIT_CPU, &limite) != 0) {
        perror("setrlimit");
        return 1;
    }

    struct sigaction accion;
    accion.sa_handler = manejador;
    sigemptyset(&accion.sa_mask);
    accion.sa_flags = 0;

    if (sigaction(SIGXCPU, &accion, NULL) != 0) {
        perror("sigaction");
        return 1;
    }

    printf("Límite de CPU impuesto. Entrando en bucle...\n");
    fflush(stdout);

    unsigned long vueltas = 0;
    while (1) {
        vueltas++;
        if (aviso) {
            printf("El kernel avisó: se agotó el tiempo de CPU tras %lu vueltas\n", vueltas);
            fflush(stdout);
            return 0;
        }
    }
}
gcc -Wall -o limite limite.c
./limite

El programa no tiene ninguna instrucción que verifique el reloj. Es el kernel quien lleva la cuenta del tiempo de CPU consumido, y cuando cruza el límite blando envía la señal SIGXCPU. Si el programa la ignorara y siguiera corriendo, al llegar al límite duro el kernel lo terminaría sin preguntar. Ese es el árbitro en su forma más literal.

La interfaz de llamadas al sistema

Todos los servicios que hemos descrito se piden por el mismo canal. La interfaz de llamadas al sistema es el conjunto de funciones que el kernel expone al espacio de usuario, y es la frontera exacta entre los dos mundos.

Qué ocurre realmente en una llamada

La secuencia completa de un write desde una aplicación en C tiene más pasos de los que sugiere la línea de código.

sequenceDiagram
    autonumber
    participant App as Aplicación (modo usuario)
    participant Libc as Biblioteca C
    participant HW as CPU
    participant K as Kernel (modo privilegiado)
    participant Drv as Controlador

    App->>Libc: write(1, "hola\n", 5)
    Note over Libc: Coloca el número de llamada en un registro<br/>y los argumentos en los registros acordados
    Libc->>HW: instrucción syscall
    Note over HW: Cambia a modo privilegiado,<br/>salta a la dirección fijada por el kernel,<br/>cambia a la pila de kernel
    HW->>K: Entra en el manejador de llamadas
    K->>K: Valida el número de llamada
    K->>K: Valida que el puntero pertenezca<br/>al proceso que llama
    K->>K: Copia los datos desde el espacio de usuario
    K->>Drv: Encola la escritura
    Drv-->>K: Aceptada
    K->>HW: instrucción de retorno de llamada
    Note over HW: Vuelve a modo usuario,<br/>restaura la pila del proceso
    HW-->>Libc: Devuelve el valor en un registro
    Libc-->>App: Retorna 5 o -1 con errno

Los pasos críticos, y las razones por las que existen, son estos.

El cambio de pila. El kernel jamás usa la pila del proceso que lo llamó. Si lo hiciera, un proceso malicioso podría manipular esa pila para influir en la ejecución privilegiada. Cada proceso tiene una pila de kernel separada.

La validación de punteros. El argumento buf es una dirección elegida por el programa. El kernel debe verificar que apunte a memoria realmente asignada a ese proceso antes de tocarla. Si no lo hiciera, un programa podría pedirle al kernel que leyera memoria del propio kernel y se la copiara. Esta clase de descuido es el origen de una familia entera de vulnerabilidades.

La copia explícita. El kernel no usa punteros del espacio de usuario directamente; copia los datos a memoria propia con funciones dedicadas que saben manejar el caso de un puntero inválido. Esa copia tiene un costo, y por eso las llamadas al sistema son caras comparadas con una función normal.

El número de llamada. El kernel no expone nombres, sino números. En Linux sobre x86-64, read es 0, write es 1, open es 2 y close es 3. La biblioteca C es la que traduce el nombre al número.

Llamar al kernel sin intermediarios

Este programa hace la misma llamada de dos formas: por la función de biblioteca y por el número de llamada directo. El resultado es idéntico, y eso demuestra que la biblioteca es solo una envoltura.

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>

int main(void) {
    const char *m1 = "Vía la función de biblioteca\n";
    const char *m2 = "Vía el número de llamada directo\n";

    write(1, m1, strlen(m1));
    syscall(SYS_write, 1, m2, strlen(m2));

    pid_t a = getpid();
    long b = syscall(SYS_getpid);

    printf("getpid por biblioteca: %d\n", a);
    printf("getpid por syscall directa: %ld\n", b);

    return 0;
}
gcc -Wall -o directo directo.c
./directo

Las dos líneas de PID coinciden porque atraviesan exactamente la misma frontera. La función syscall de glibc es un envoltorio genérico que coloca el número que le des en el registro correspondiente y ejecuta la instrucción de trampa.

Los detalles de bajo nivel varían por arquitectura:

ArquitecturaInstrucción de entradaRegistro del númeroRegistros de argumentosRegistro de retorno
x86-64syscallraxrdi, rsi, rdx, r10, r8, r9rax
AArch64svc #0x8x0 a x5x0
RISC-V de 64 bitsecalla7a0 a a5a0

No necesitas memorizar esta tabla; su valor está en mostrar que el mecanismo es universal aunque los nombres cambien: un registro dice qué se pide, otros dicen con qué datos, una instrucción cruza la frontera, y un registro trae el resultado.

Observar las llamadas de cualquier programa

La herramienta strace intercepta todas las llamadas al sistema de un proceso. Es la forma más directa de ver el contrato en acción:

strace -f ./archivos

Verás la secuencia completa: la carga del ejecutable, el mapeo de las bibliotecas compartidas, el openat de tu archivo, los write, los read, el close. Para obtener solo un resumen estadístico:

strace -c ls /usr/bin

Esa vista es especialmente útil para diagnosticar rendimiento: si un programa lento hace cien mil llamadas al sistema, el problema no está en su algoritmo sino en que cruza la frontera demasiadas veces.

El catálogo de llamadas más comunes

Un kernel moderno como Linux expone del orden de trescientas cincuenta llamadas. Las que cubren la mayor parte del trabajo real son bastante menos:

CategoríaLlamadas representativasQué recurso administran
Procesosfork, execve, exit, wait4, getpidCPU y aislamiento
Hilos y sincronizaciónclone, futexCPU dentro de un mismo espacio de direcciones
Memoriammap, munmap, mprotect, brkespacio de direcciones
Archivosopenat, read, write, close, lseek, fsyncalmacenamiento
Metadatosstat, fstat, chmod, chown, rename, unlinkespacio de nombres del sistema de archivos
Directoriosmkdir, rmdir, getdents64, chdirorganización jerárquica
Redsocket, bind, listen, accept, connect, sendto, recvfromcomunicación
Espera de eventospoll, epoll_wait, selectmultiplexación de descriptores
Señaleskill, sigaction, sigprocmasknotificación asíncrona
Tiempoclock_gettime, nanosleeprelojes y temporizadores
Permisossetuid, setgid, setrlimit, getrlimitprotección y cuotas

Una observación práctica: en un sistema tipo Unix, muchísimas cosas que parecerían necesitar una llamada nueva se resuelven con open, read y write sobre rutas especiales. Consultar la cantidad de memoria libre, la temperatura del procesador o el estado de la batería son operaciones de lectura de archivos. Esa decisión de diseño mantiene la interfaz pequeña.

Mecanismo y política: la distinción que ordena todo

Hay un concepto transversal que conviene fijar antes de cerrar, porque reaparecerá en cada capítulo posterior.

Un mecanismo es la capacidad de hacer algo. Una política es la regla que decide cuándo y a favor de quién se hace.

La analogía habitual es un automóvil: los frenos son el mecanismo, y detenerse ante un disco “Pare” es la política. Los frenos no saben nada de señales de tránsito; simplemente frenan cuando se los acciona.

Cada uno de los roles que vimos tiene el mismo par:

ÁreaMecanismoPolítica
CPUcambio de contexto e interrupción del temporizadoralgoritmo de planificación y prioridades
Memoriatabla de páginas y fallo de páginaalgoritmo de reemplazo de páginas y cuotas por proceso
Almacenamientoinodos, caché de páginas, cola de peticionesorden de atención de peticiones y momento de bajar a disco
Protecciónmodo dual e identificadores de usuarioqué usuario puede leer qué archivo
Redcolas de paquetes y socketscontrol de congestión y prioridades de tráfico

La separación es valiosa por una razón de ingeniería concreta: los mecanismos son caros de construir, dependen del hardware y cambian poco; las políticas son opiniones, dependen del uso, y cambian a menudo. Un kernel bien diseñado permite cambiar la política sin tocar el mecanismo. Es exactamente lo que hace Linux cuando permite elegir entre varios planificadores de entrada y salida sin recompilar el kernel.

Cómo se juzga un sistema operativo

Los roles describen qué hace un sistema operativo. Los criterios siguientes describen qué tan bien lo hace, y son útiles porque casi siempre están en tensión entre sí.

CriterioQué mideCómo se observaCon qué entra en conflicto
SobrecargaRecursos que el propio SO consumecomparar el rendimiento con y sin la abstraccióncon la seguridad y con el aislamiento
EquidadReparto razonable entre procesosvarianza del tiempo de CPU entre procesos comparablescon el rendimiento agregado
Tiempo de respuestaLatencia desde la petición hasta el resultadomedir latencia en el percentil alto, no el promediocon el rendimiento agregado
Rendimiento agregadoTrabajo total completado por unidad de tiempooperaciones por segundo bajo cargacon el tiempo de respuesta
PredictibilidadQué tan constante es el tiempo de respuestadispersión de las medicionescon el aprovechamiento máximo del hardware
ConfiabilidadCuánto tarda en fallar y cuánto en recuperarsetiempo medio hasta la falla y tiempo medio de reparacióncon la complejidad de funcionalidades
DisponibilidadFracción del tiempo en que el sistema sirvecociente entre tiempo hasta la falla y ese tiempo más el de reparacióncon el costo
SeguridadResistencia a uso no autorizadosuperficie de ataque y validación de entradascon el rendimiento y la comodidad
PortabilidadFacilidad de correr sobre hardware distintotamaño de la capa dependiente de arquitecturacon el aprovechamiento de hardware específico

Vale la pena detenerse en la relación entre confiabilidad y disponibilidad, porque suelen confundirse. Un sistema que falla una vez al año pero tarda una semana en recuperarse tiene una confiabilidad excelente y una disponibilidad mediocre. Un sistema que falla cada hora pero se reinicia en un segundo tiene una confiabilidad pésima y una disponibilidad muy alta. La disponibilidad se calcula como el tiempo medio hasta la falla dividido por la suma del tiempo medio hasta la falla más el tiempo medio de reparación.

La tensión entre tiempo de respuesta y rendimiento agregado también merece atención porque aparece en cada capa. Procesar peticiones en lotes grandes maximiza el trabajo total pero castiga a la primera petición del lote, que espera a que el lote se llene. Es la misma tensión que en el capítulo 2 separó el procesamiento por lotes de los sistemas de tiempo compartido.

Errores comunes al razonar sobre los roles del sistema operativo

ErrorCausaCómo se manifiestaSolución
Creer que malloc es una llamada al sistemaconfundir la biblioteca C con el kernelsorpresa al ver en strace que mil malloc producen dos mmaprecordar que malloc administra un montón en espacio de usuario y solo pide páginas al kernel cuando se le acaban
Asumir que write deja el dato en el discoignorar la caché de páginaspérdida de datos tras un corte de energía pese a que write devolvió éxitousar fsync sobre el descriptor y, si el orden importa, también sobre el directorio contenedor
Dejar procesos zombiel padre nunca llama a waitpidla tabla de procesos se llena y fork empieza a fallarrecolectar siempre los hijos con waitpid, o manejar la señal de terminación de hijo
Interpretar un error de segmentación como un fallo del sistema operativono distinguir entre fallo de página normal y acceso inválidofrustración al depurar punterosentender que el kernel está protegiendo al resto del sistema; revisar el puntero con un depurador o con detectores de errores de memoria
Leer la memoria “usada” de un proceso como memoria físicaconfundir tamaño virtual con residentealarma por procesos que aparentan consumir decenas de gigabytesmirar el tamaño residente y las páginas compartidas, no el tamaño virtual
Suponer que más hilos siempre da más velocidadignorar el costo del cambio de contexto y la naturaleza del cuello de botellael rendimiento cae al aumentar la concurrenciamedir primero si los procesos están esperando CPU o esperando entrada y salida
Escribir un bucle que consulta un descriptor sin bloquearseevitar el bloqueo por miedo a “perder tiempo”uso de CPU al máximo sin trabajo útilusar la espera bloqueante o multiplexación de eventos; el kernel despierta al proceso cuando hay dato
Compartir memoria entre procesos que hicieron fork esperando ver los cambiosno considerar la copia al escribirlas escrituras del hijo no se ven en el padreusar memoria compartida explícita, tuberías o sockets según el caso
Suponer que el desplazamiento de un archivo es independiente tras forkdesconocer que la entrada de archivo abierto se comparteescrituras entrelazadas en posiciones inesperadasabrir el archivo por separado en cada proceso si se quieren desplazamientos independientes
Confundir mecanismo con política al diseñar softwaremezclar la capacidad con la regla en el mismo módulocambiar una regla de negocio obliga a reescribir código de bajo nivelseparar la implementación de la decisión, igual que hace el kernel
Creer que los procesos de la BEAM son procesos del sistema operativoambos se llaman igualexpectativas equivocadas sobre aislamiento a nivel de kernel y sobre costorecordar que los procesos de la BEAM viven dentro de un único proceso del sistema y comparten su espacio de direcciones

Ejercicios propuestos

1. Observar el árbitro. Compila el programa del bucle infinito de la primera sección y ejecútalo cuatro veces en paralelo con &. Usa top o htop para ver cómo se reparte la CPU. Anota el porcentaje que recibe cada uno y compáralo con la cantidad de núcleos de tu máquina. Luego repite el experimento aplicando prioridades distintas con nice -n 19 a dos de ellos y describe qué cambió.

2. Medir el costo de la frontera. Escribe dos programas que escriban un millón de líneas en un archivo: uno usando write directamente para cada línea, y otro usando fprintf sobre un flujo con búfer. Mide ambos con time y cuenta las llamadas con strace -c. Explica la diferencia en términos de cruces de la frontera usuario-kernel.

3. Ver la ilusión de la memoria. Ejecuta el programa de malloc en tres terminales simultáneas y compara las direcciones. Después lanza uno con setarch $(uname -m) -R ./memoria en dos terminales y observa qué cambia. Revisa el archivo /proc/PID/maps de un proceso vivo e identifica el montón, la pila y al menos dos bibliotecas compartidas.

4. Comprobar el desplazamiento compartido. Escribe un programa que abra un archivo, haga fork, y que padre e hijo escriban cada uno una cadena distinta. Predice el contenido final antes de ejecutarlo. Después modifica el programa para que cada uno abra el archivo por su cuenta y compara los dos resultados.

5. Traducir entre modelos. Reescribe el ejemplo del Contador de Elixir usando dos procesos del sistema operativo en C que se comuniquen por una tubería creada con pipe. Compara la cantidad de líneas de código, la cantidad de llamadas al sistema que aparecen en strace, y qué ocurre en cada versión cuando uno de los dos participantes muere inesperadamente.

6. Cuotas de recursos. Modifica el programa de setrlimit para que además imponga un límite al tamaño máximo de memoria que puede pedir, y comprueba qué devuelve malloc al superarlo. Documenta la diferencia entre el límite blando y el límite duro.

7. Un dispositivo que no es un disco. Escribe un programa que lea de /dev/urandom en bloques de distintos tamaños (1 byte, 64 bytes, 4096 bytes) para completar un total de un megabyte, y mide el tiempo en cada caso. Explica el resultado en términos de cantidad de llamadas al sistema y no de velocidad del dispositivo.

8. Clasificar los roles. Toma cinco comandos que uses habitualmente en tu terminal y, para cada uno, escribe qué parte de su trabajo corresponde al rol de árbitro, cuál al de ilusionista y cuál al de pegamento. Justifica cada asignación con una llamada al sistema concreta que puedas ver en strace.

9. Concurrencia con contrato. Extiende el ejemplo de tareas en Ada agregando un objeto protegido que lleve un contador compartido entre las dos tareas, y verifica que el resultado final sea correcto sin usar bloqueos manuales. Compara ese enfoque con el de paso de mensajes de Elixir.

10. Mecanismo y política. Elige una función de un proyecto de software en el que trabajes y sepárala en su mecanismo y su política, siguiendo el criterio del kernel. Describe qué tendrías que cambiar para reemplazar la política sin tocar el mecanismo.

Qué sigue

Hasta aquí sabemos qué hace un sistema operativo y por qué cada una de esas funciones existe. Vimos que administra la CPU repartiendo turnos, la memoria construyendo espacios de direcciones privados, el almacenamiento convirtiendo bloques en archivos, los dispositivos ocultando su diversidad detrás de una interfaz uniforme, y la seguridad apoyándose en un bit de estado del procesador. Y vimos que todo eso se pide por una única puerta cuyo mecanismo interno ya no es una caja negra: registros, una instrucción de trampa, un cambio de pila, una validación y un retorno.

Lo que falta es el suelo sobre el que todo esto se apoya. Cada mecanismo que describimos necesita una pieza de hardware que lo haga posible: el temporizador que permite quitarle la CPU a un proceso, la unidad de gestión de memoria que hace cumplir el aislamiento, el bit de modo que separa privilegios, el controlador de interrupciones que ordena los avisos del hardware, las cachés que explican por qué el rendimiento real no se parece al que sugiere el código. Sin entender esas piezas, las decisiones de diseño de un kernel parecen arbitrarias.

En el capítulo 4 bajamos a ese nivel: veremos cómo está organizada una computadora por dentro, cómo se ejecuta realmente una instrucción, qué hace la jerarquía de memoria y cuáles son exactamente las capacidades de hardware sobre las que se construye cada uno de los roles que acabamos de estudiar. Puedes revisar el recorrido completo del curso en el índice.