← Fundamentos de Sistemas Operativos Bloque 2 · La computadora por dentro · Tema 7 de 34

Tema 07

Jerarquía de memoria y almacenamiento

Una computadora no tiene una memoria, sino varias, de velocidades que difieren en millones de veces. En este tema vemos por qué, cómo se organizan en niveles, qué principio permite que el conjunto parezca rápido y grande a la vez, y qué parte de ese trabajo le toca al sistema operativo.

1. El dilema: rápida, grande y barata

En el tema 5 dibujamos la memoria como un único bloque, y en el tema 6 vimos que la CPU ejecuta miles de millones de instrucciones por segundo, casi todas con algún acceso a datos. Para no frenarla, la memoria ideal tendría tres propiedades:

Esa memoria no existe. Las tecnologías disponibles obligan a elegir: las más rápidas necesitan más circuitos por cada bit, así que son caras y ocupan mucho espacio; las más baratas y densas son lentas. Además, las rápidas pierden su contenido al quedarse sin energía.

La solución no es elegir una, sino combinarlas: poca memoria muy rápida, bastante memoria de velocidad media y muchísimo almacenamiento lento, organizados de modo que lo que más se usa esté en la parte rápida.

2. La jerarquía

El resultado se conoce como jerarquía de memoria. Cada nivel es más lento, más grande y más barato por byte que el que tiene encima:

Pirámide de la jerarquía de memoria: registros, caché, RAM, SSD y disco rígido Registros Caché L1 · L2 · L3 Memoria principal (RAM) Disco de estado sólido (SSD) Disco rígido (HDD) volátil ▲ persistente ▼ ▲ hacia arriba: más rápido, más caro por byte, menos capacidad ▼ hacia abajo: más lento, más barato, más capacidad
Los tres niveles superiores pierden su contenido sin energía; los dos inferiores lo conservan.

La tabla siguiente pone cifras a la pirámide. Son órdenes de magnitud de un equipo personal actual; los valores exactos cambian con cada modelo.

NivelTiempo de accesoCapacidad típica¿Conserva los datos?
Registrosmenos de 1 nsunos cientos de bytesNo
Caché L11 ns32 a 64 KiB por núcleoNo
Caché L24 ns256 KiB a 2 MiB por núcleoNo
Caché L310 a 40 ns8 a 64 MiB, compartidaNo
RAM100 ns8 a 64 GiBNo
SSD100 µs256 GB a 2 TBSí
Disco rígido10 ms1 a 20 TBSí

Las unidades de la columna central esconden la magnitud del salto: un nanosegundo (ns) es la milmillonésima parte de un segundo; un microsegundo (µs) son mil nanosegundos; un milisegundo (ms), un millón. Entre el primer nivel y el último hay siete órdenes de magnitud.

3. Las distancias, a escala humana

Cifras como «100 nanosegundos» no dicen nada a la intuición. Para apreciarlas, estiremos el tiempo de manera que un acceso a la caché L1, de un nanosegundo, dure un segundo. Todo lo demás se estira en la misma proporción:

OperaciónTiempo realA escala humana
Leer de la caché L11 ns1 segundo
Leer de la caché L24 ns4 segundos
Leer de la caché L330 nsmedio minuto
Leer de la RAM100 nscasi 2 minutos
Leer de un SSD100 µsmás de un día
Leer de un disco rígido10 mscasi 4 meses
Recibir respuesta de un servidor lejano100 msmás de 3 años

Visto así, se entiende por qué los sistemas operativos están diseñados alrededor de la entrada y salida. Si pedir un dato al disco equivale a esperar meses, la CPU no puede quedarse mirando: tiene que ocuparse de otra cosa mientras tanto. Y evitar un solo acceso al disco, aunque cueste miles de instrucciones, es siempre un buen negocio. Las dos ideas —la multiprogramación y las cachés— nacen de esta tabla.

4. El principio de localidad

Tener poca memoria rápida solo sirve si lo que el programa necesita está en ella casi siempre. Que eso ocurra no es suerte, sino una regularidad observada en prácticamente todos los programas:

El principio de localidad dice que, durante un intervalo corto, un programa usa una porción pequeña de sus instrucciones y de sus datos, y que esa porción cambia lentamente.

Tiene dos formas:

La localidad es la razón de ser de toda caché, palabra que designa una idea general y no un componente concreto: guardar en un lugar rápido una copia de los datos de un lugar lento que se usaron recientemente, con la expectativa de volver a necesitarlos. Cuando el dato buscado está en la caché se habla de acierto (hit); cuando no está y hay que ir al nivel lento, de fallo (miss).

Encontraremos esta misma idea en todos los niveles: en el procesador, en el kernel, en la biblioteca de C, en los navegadores y en los servidores. Cambia el tamaño; el principio es el mismo.

5. La caché de la CPU

Entre los registros y la RAM, los procesadores tienen una memoria intermedia muy rápida integrada en el mismo chip. Guarda copias de las zonas de RAM usadas recientemente. Funciona así:

  1. Cuando la CPU necesita el contenido de una dirección, busca primero en la caché.
  2. Si está (acierto), lo obtiene en uno o pocos nanosegundos.
  3. Si no está (fallo), lo trae de la RAM, lo que tarda del orden de cien veces más, y lo guarda en la caché desplazando algo más antiguo.

Hay un detalle decisivo en el paso 3: la caché no trae un byte, sino un bloque completo de bytes contiguos, llamado línea de caché, normalmente de 64 bytes. Es una apuesta por la localidad espacial: si el programa pidió una dirección, probablemente pida enseguida las siguientes, y ya estarán allí.

La caché se organiza a su vez en niveles: L1, pequeña y rapidísima, propia de cada núcleo y dividida en una parte para instrucciones y otra para datos; L2, mayor y algo más lenta; y L3, mucho mayor, compartida por todos los núcleos. Linux muestra su tamaño:

terminal
$ lscpu | grep -i cache
L1d cache:                128 KiB (4 instances)
L1i cache:                128 KiB (4 instances)
L2 cache:                 1 MiB (4 instances)
L3 cache:                 6 MiB (1 instance)

$ getconf LEVEL1_DCACHE_LINESIZE # tamaño de la línea de caché, en bytes
64

La salida se lee así: hay cuatro cachés L1 de datos (L1d) y cuatro de instrucciones (L1i), una por núcleo, de 32 KiB cada una; cuatro cachés L2 de 256 KiB; y una única L3 de 6 MiB para todo el procesador.

Es invisible, pero se nota

La caché de la CPU la administra el propio procesador. Ni el programa ni el sistema operativo deciden qué se guarda en ella, y no hay ninguna instrucción para «leer de la caché»: los programas siguen pidiendo direcciones de memoria como si nada. Lo único que se percibe es el tiempo, como muestra el experimento siguiente.

6. La caché en un experimento

El siguiente programa suma todos los elementos de una matriz de 4096 × 4096 enteros dos veces: la primera recorriéndola por filas y la segunda por columnas. Las dos sumas hacen exactamente las mismas operaciones sobre los mismos datos; solo cambia el orden.

matriz.c
#include <stdio.h>
#include <time.h>

#define N 4096

static int matriz[N][N];                 /* 64 MiB */

static double segundos(void)
{
    struct timespec t;

    clock_gettime(CLOCK_MONOTONIC, &t);
    return t.tv_sec + t.tv_nsec / 1e9;
}

int main(void)
{
    long suma = 0;
    double inicio;

    for (int i = 0; i < N; i++)          /* cargar valores */
        for (int j = 0; j < N; j++)
            matriz[i][j] = 1;

    inicio = segundos();
    for (int i = 0; i < N; i++)          /* recorrido por filas */
        for (int j = 0; j < N; j++)
            suma += matriz[i][j];
    printf("Por filas:    %.3f s  (suma %ld)\n", segundos() - inicio, suma);

    suma = 0;
    inicio = segundos();
    for (int j = 0; j < N; j++)          /* recorrido por columnas */
        for (int i = 0; i < N; i++)
            suma += matriz[i][j];
    printf("Por columnas: %.3f s  (suma %ld)\n", segundos() - inicio, suma);

    return 0;
}
terminal
$ gcc -Wall -Wextra -g matriz.c -o matriz
$ ./matriz
Por filas:    0.041 s  (suma 16777216)
Por columnas: 0.287 s  (suma 16777216)

El resultado es el mismo y el tiempo no: el recorrido por columnas tarda varias veces más. La proporción exacta depende del procesador, pero la diferencia aparece siempre. La explicación está en cómo guarda C una matriz y en la línea de caché:

El primer recorrido tiene buena localidad espacial; el segundo, ninguna. Es la tabla de la sección 3 convertida en una medición.

Sobre la medición

El bucle inicial que carga valores no es decorativo: obliga al sistema operativo a asignar de verdad la memoria de la matriz antes de empezar a medir. Sin él, el primer recorrido cargaría con ese costo y la comparación sería injusta. Veremos por qué en el tema 27.

7. Discos rígidos y de estado sólido

Por debajo de la RAM están los dispositivos de almacenamiento, que conservan los datos sin energía. Hay dos tecnologías, con comportamientos muy distintos:

Disco rígido (HDD)Disco de estado sólido (SSD)
Cómo guarda los datosMagnéticamente, en platos que giran. Un brazo mecánico mueve el cabezal de lectura.En celdas de memoria flash, sin partes móviles.
Tiempo de accesoMilisegundos: hay que mover el brazo y esperar a que el plato gire.Decenas o cientos de microsegundos.
Acceso secuencial frente a dispersoEnorme diferencia: leer datos contiguos es rápido; saltar de un lugar a otro es lentísimo.Diferencia pequeña.
Punto débilGolpes y desgaste mecánico.Cada celda admite un número limitado de escrituras.
Costo por byteEl más bajo.Varias veces mayor.

Pese a sus diferencias, ambos se presentan ante el sistema operativo de la misma forma: como una larga secuencia de bloques numerados de tamaño fijo, normalmente 512 o 4096 bytes. Solo se puede leer o escribir un bloque entero. Todo lo demás —archivos, directorios, nombres— lo construye el sistema operativo encima.

terminal
$ lsblk -d -o NAME,ROTA,SIZE,MODEL
NAME    ROTA   SIZE MODEL
nvme0n1    0 476,9G SAMSUNG MZVLB512HAJQ-000L7
sda        1   1,8T WDC WD20EZRZ-00Z5HB0

La columna ROTA indica si el dispositivo es giratorio: 1 para un disco rígido, 0 para un SSD. El kernel usa ese dato para decidir cómo ordenar los pedidos que envía a cada uno. En una máquina virtual o en WSL verás discos virtuales.

8. La caché de disco del kernel

Entre la RAM y el disco hay un salto de mil a cien mil veces, mucho mayor que entre la caché de la CPU y la RAM. Aplicar aquí la idea de caché rinde todavía más, y en este nivel quien la aplica es el sistema operativo.

El kernel de Linux mantiene en memoria RAM una copia de los bloques de disco leídos o escritos recientemente. Se la llama caché de páginas (page cache) o caché de disco. Cuando un programa pide leer un archivo, el kernel mira primero ahí; solo si los datos no están los pide al dispositivo. El programa no hace nada especial: sigue llamando a read.

El efecto se mide con facilidad. Creamos un archivo de 512 MiB, le pedimos al kernel que vacíe su caché y lo leemos dos veces seguidas:

terminal
$ dd if=/dev/urandom of=grande.bin bs=1M count=512 status=none

$ sync                                          # asegurar que todo esté en disco
$ echo 3 | sudo tee /proc/sys/vm/drop_caches    # vaciar la caché de disco
3

$ time cat grande.bin > /dev/null               # primera lectura: desde el disco
real	0m0.612s
user	0m0.004s
sys	0m0.170s

$ time cat grande.bin > /dev/null               # segunda lectura: desde la RAM
real	0m0.085s
user	0m0.001s
sys	0m0.084s

$ rm grande.bin

El mismo comando, sobre el mismo archivo, tarda varias veces menos la segunda vez, porque los datos ya no vienen del dispositivo. Con un disco rígido la diferencia sería de decenas de veces. Vaciar la caché es inofensivo: el sistema solo queda momentáneamente más lento, hasta que vuelve a llenarla.

La caché de disco explica una duda muy frecuente al mirar la memoria de un sistema Linux:

terminal
$ free -h
               total        used        free      shared  buff/cache   available
Mem:            15Gi       4.2Gi       1.1Gi       412Mi        10Gi        10Gi
Swap:          2.0Gi          0B       2.0Gi

La columna free muestra poca memoria libre, y sin embargo el sistema no tiene ningún problema. La memoria que ningún programa necesita no se deja vacía: el kernel la usa como caché de disco, y aparece en buff/cache. Si un programa pide memoria, el kernel descarta parte de esa caché al instante y se la entrega. Por eso la cifra que importa es available, la memoria realmente disponible para programas nuevos. La memoria sin usar es memoria desperdiciada.

9. Volátil y persistente

La línea punteada de la pirámide separa dos mundos. Por encima, todo se pierde al cortarse la energía; por debajo, se conserva. Esa frontera tiene una consecuencia práctica que el sistema operativo debe gestionar con cuidado.

Para ganar velocidad, la caché de disco también interviene en las escrituras. Cuando un programa escribe en un archivo, el kernel copia los datos a su caché en RAM y devuelve el control enseguida; la escritura real en el dispositivo se hace más tarde, agrupada con otras. Durante ese intervalo, que puede durar varios segundos, los datos «guardados» existen únicamente en memoria volátil.

terminal
$ grep -E '^(Dirty|Writeback):' /proc/meminfo
Dirty:              1284 kB
Writeback:             0 kB

Dirty es la cantidad de datos modificados en memoria que todavía no llegaron al disco. Si se corta la energía en ese momento, se pierden. De aquí salen varias prácticas que quizá conozcas sin saber su motivo:

Es un ejemplo claro del equilibrio entre rendimiento y seguridad que vimos en el tema 2, y uno de los problemas centrales del curso sobre sistemas de archivos.

10. El camino inverso: el intercambio

La caché de disco usa la RAM para acelerar el disco. El sistema operativo también hace lo contrario: usa el disco para ampliar la RAM.

Cuando la memoria se agota, el kernel puede tomar zonas de memoria que algún programa no usa desde hace tiempo, copiarlas a un área reservada del disco y reutilizar esa RAM para otra cosa. Si el programa vuelve a necesitar esos datos, el kernel los trae de vuelta. Esa técnica es el intercambio (swap), y el área de disco que utiliza aparece en la segunda línea de free.

terminal
$ swapon --show
NAME      TYPE SIZE USED PRIO
/swap.img file   2G   0B   -2

El intercambio permite ejecutar más programas de los que caben en la memoria física, y de nuevo se apoya en la localidad: en un momento dado, cada programa usa solo una parte de su memoria. Pero tiene un precio evidente a la luz de la tabla de la sección 3. Cada dato que hay que recuperar del disco cuesta miles de veces más que uno que está en RAM. Si el sistema tiene tan poca memoria que debe intercambiar continuamente, pasa más tiempo moviendo datos que ejecutando programas, una situación llamada hiperpaginación (thrashing), y todo se vuelve desesperadamente lento.

El mecanismo que hace posible el intercambio es la memoria virtual, que presentamos en el tema 27.

11. Quién gestiona cada nivel

Cada frontera de la jerarquía tiene un responsable distinto, y conviene saber cuál es para entender dónde interviene el sistema operativo y dónde no:

Movimiento de datosQuién lo decide¿Lo ve el programa?
Entre registros y memoriaEl compilador, al traducir el programaNo en C; sí en ensamblador
Entre caché de la CPU y RAMEl procesadorNo
Entre RAM y disco, como caché de archivosEl sistema operativoNo
Entre RAM y disco, como intercambioEl sistema operativoNo
Entre RAM y archivosEl programa, mediante llamadas al sistemaSí: lo pide explícitamente

La columna de la derecha resume el mérito del diseño. Un programa trabaja con dos ideas sencillas —variables en memoria y archivos en disco— y toda la maquinaria que hace que eso funcione con rapidez queda oculta. Es otra de las abstracciones del tema 2.

12. Consecuencias para quien programa

Que la jerarquía sea invisible no significa que dé igual cómo se escriba el código. Algunas reglas prácticas se desprenden directamente de este tema:

La propia biblioteca de C aplica la misma idea. printf no hace una llamada al sistema por cada carácter: acumula el texto en una zona de memoria y lo envía de una vez. Es una caché más, situada un nivel por encima, cuyo objetivo es evitar una operación costosa: el cruce al kernel. La estudiamos en el tema 15.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué una computadora no tiene simplemente toda su memoria del tipo más rápido?

Porque la memoria más rápida es mucho más cara por byte y ocupa más espacio en el chip. Además es volátil, de modo que igualmente haría falta un almacenamiento persistente. La jerarquía consigue un rendimiento cercano al del nivel rápido con un costo cercano al del nivel lento.

2. Enuncia las dos formas de localidad y da un ejemplo de cada una.

Localidad temporal: lo que se usó recientemente se volverá a usar pronto, como las instrucciones de un bucle. Localidad espacial: se usan direcciones cercanas a las que se acaban de usar, como los elementos consecutivos de un arreglo.

3. ¿Por qué la caché de la CPU trae de la RAM 64 bytes cuando el programa pidió 4?

Porque apuesta por la localidad espacial. Es muy probable que el programa pida a continuación los bytes vecinos, y así ya estarán en la caché. Ir a la RAM es costoso, y traer un bloque cuesta casi lo mismo que traer un dato.

4. En matriz.c, ¿por qué el recorrido por columnas es más lento si realiza las mismas sumas?

C guarda la matriz fila por fila. Por columnas, cada acceso salta 16 KiB respecto del anterior, cae en una línea de caché distinta y casi siempre produce un fallo que obliga a ir a la RAM. Por filas, 15 de cada 16 accesos encuentran el dato ya en la caché.

5. Un servidor Linux muestra 200 MB en la columna free sobre 16 GB totales. ¿Está por quedarse sin memoria?

No necesariamente. El kernel usa la memoria que nadie pide como caché de disco, y la libera en cuanto un programa la necesita. Hay que mirar la columna available, que indica cuánta memoria puede entregarse a los programas.

6. Un programa escribe datos en un archivo, write devuelve éxito y un segundo después se corta la energía. ¿Los datos están en el disco?

No hay garantía. write copia los datos a la caché de disco del kernel, en RAM, y vuelve. La escritura en el dispositivo se hace después. Para asegurarla, el programa debe llamar a fsync.

7. ¿En qué se parecen y en qué se diferencian la caché de disco y el intercambio?

Los dos los gestiona el kernel y mueven datos entre RAM y disco de forma invisible para los programas. La caché de disco guarda en RAM copias de datos del disco, para acelerarlo. El intercambio guarda en el disco contenido de la RAM, para ampliarla. La primera mejora el rendimiento; el segundo lo sacrifica a cambio de capacidad.

Para practicar

  1. Ejecuta matriz.c en tu equipo y calcula cuántas veces más lento es el recorrido por columnas. Luego cambia N a 512 y repite: ¿por qué la diferencia casi desaparece? Compara el tamaño de la matriz con el de tus cachés.
  2. Compila matriz.c con gcc -O2 y vuelve a medir. ¿Cambian los tiempos? ¿Cambia la proporción?
  3. Repite el experimento de la sección 8 mirando free -h antes de la primera lectura y después de la segunda. ¿En cuánto aumentó buff/cache?
  4. Ejecuta grep -E '^(Dirty|Writeback):' /proc/meminfo, luego crea un archivo grande con dd y repite el grep enseguida. Ejecuta sync y míralo otra vez.
  5. Con lscpu, anota los tamaños de tus cachés L1d, L2 y L3 por núcleo. ¿Cuántos enteros de 4 bytes caben en la L1 de datos?