← Fundamentos de Sistemas Operativos Bloque 7 · Visión general de los servicios · Tema 27 de 34

Tema 27

Memoria: visión general

En el tema 2, dos procesos mostraron la misma dirección de memoria con valores distintos. Desde entonces fuimos encontrando piezas de la explicación: páginas, fallos de página, memoria que se promete y se entrega después. En este tema las unimos: qué es una dirección, cómo está organizada la memoria de un proceso y cómo saber cuánta ocupa de verdad.

1. Dos clases de direcciones

El programa ilusion.c del tema 2 dejó planteado el hecho central de este tema. Dos procesos imprimían la dirección de una variable, 0x404044, y era la misma en ambos; sin embargo, cada uno guardaba allí un valor distinto. Dijimos entonces que esa dirección no era una posición real de la memoria. Ahora podemos decir qué es.

En una computadora con sistema operativo hay dos clases de direcciones:

Dirección virtualDirección física
EsUn número con el que un proceso nombra un byte de su memoriaLa posición de un byte en los circuitos de memoria del equipo
La usanLos programas. Todo puntero de C es una dirección virtualEl hardware, y el kernel para administrarlo
Su significadoDepende del proceso: el mismo número designa cosas distintas en cada unoEs único en toda la máquina
Rango, en x86-64128 TB por procesoLa memoria instalada: unos GB

La memoria virtual es la técnica por la que cada proceso trabaja con un espacio de direcciones propio, y cada acceso se traduce, en el momento, a una posición de la memoria física. La traducción la hace el procesador, con unas tablas que mantiene el kernel.

Un programa no ve nunca una dirección física. No tiene manera de obtenerla ni de usarla. Lo que printf("%p") muestra, lo que gdb informa y lo que aparece en un mensaje de dmesg son siempre direcciones virtuales.

El mecanismo responde a tres necesidades que una memoria única no puede satisfacer: que los procesos no se vean entre sí, que cada uno disponga de una memoria amplia y continua sin importar cómo esté ocupada la real, y que el conjunto pueda usar más memoria de la que hay instalada.

2. Páginas y marcos

Traducir cada dirección por separado requeriría una tabla tan grande como la memoria misma. La traducción se hace por bloques. El espacio de direcciones de cada proceso se divide en páginas, y la memoria física, en marcos del mismo tamaño: 4096 bytes en la mayoría de los equipos, el valor que informaba getconf PAGESIZE en el tema 15.

Para cada proceso, el kernel mantiene una tabla de páginas: la lista de qué marco corresponde a cada una de sus páginas, si es que le corresponde alguno.

Las páginas de dos procesos se corresponden con marcos de la memoria física en cualquier orden. La página de la biblioteca de ambos procesos apunta al mismo marco, y una página del proceso A no tiene ningún marco asignado Proceso A Memoria física Proceso B código datos sin asignar biblioteca código datos biblioteca pila marco 0 marco 1 marco 2 marco 3 marco 4 marco 5
Cada línea es una entrada de una tabla de páginas. El marco 4 está en las tablas de los dos procesos.

La figura muestra las tres propiedades del esquema:

3. La traducción

La traducción ocurre en cada acceso a memoria: cada instrucción captada, cada dato leído o escrito. La realiza un circuito del procesador, la unidad de gestión de memoria o MMU, sin intervención del kernel. Con páginas de 4096 bytes, que son 212, una dirección se parte en dos:

la dirección de ilusion.c, descompuesta
dirección virtual     0x404044
                        │   │
                        │   └── 0x044   desplazamiento dentro de la página (12 bits)
                        └────── 0x404   número de página

tabla de páginas del proceso 4211:   página 0x404  →  marco 0x1a7f3
tabla de páginas del proceso 4212:   página 0x404  →  marco 0x09c21

dirección física en el proceso 4211:   0x1a7f3044
dirección física en el proceso 4212:   0x09c21044

El número de página se busca en la tabla y se reemplaza por el número de marco; el desplazamiento pasa sin cambios. Los números de marco del ejemplo son inventados, pero el resultado es el del tema 2: la misma dirección virtual lleva a dos lugares distintos, porque cada proceso tiene su tabla. Al cambiar de proceso, el kernel carga en el registro cr3 la dirección de la tabla del que entra, como vimos en el tema 19, y desde ese instante todas las direcciones significan otra cosa.

Cada entrada de la tabla contiene, además del número de marco, unas marcas que la MMU comprueba en cada acceso. Son las que ya encontramos por sus efectos:

MarcaIndicaSi el acceso la viola
PresenteSi la página tiene un marco asignado.Fallo de página. El kernel decide si lo resuelve o envía SIGSEGV, como vimos en el tema 18.
EscrituraSi se puede escribir en ella.
UsuarioSi es accesible en modo usuario, o solo en modo kernel.
No ejecutableSi su contenido puede ejecutarse como código.

Las tres primeras marcas corresponden, una a una, a los tres bits del código de error que aprendimos a leer en dmesg. La tercera es la que protege la memoria del kernel, según el tema 12. Y la cuarta impide un tipo de ataque: que un programa sea engañado para ejecutar como código unos datos que recibió.

Quedan dos detalles de implementación, que conviene conocer de nombre:

4. Lo que la memoria virtual hace posible

Una vez que existe una tabla intermedia entre lo que el programa pide y lo que la memoria tiene, el kernel puede hacer con ella una serie de cosas. Casi todas aparecieron ya en el curso; esta es la lista completa:

CapacidadCómo se lograTema
AislamientoLa tabla de un proceso no contiene los marcos de otro. No puede nombrarlos.2
Protección del kernelSus páginas están en todas las tablas, marcadas como inaccesibles en modo usuario.12
Entrega a pedidoLas páginas se dejan sin marco hasta el primer acceso.18
Carga a pedidoLas páginas de un ejecutable se traen del disco solo si se usan.9
CompartirEl mismo marco figura en varias tablas.Sección 11
Copia al escribirTras fork, padre e hijo comparten marcos marcados como de solo lectura.26
IntercambioEl contenido de un marco se guarda en el disco y la página se marca como no presente.7
Detección de erroresLa dirección 0 y otras zonas se dejan siempre sin asignar.18

Todas son variaciones de dos operaciones: decidir qué marco pone el kernel en cada entrada, y qué marcas le pone. El resto de este tema mira el resultado desde el lado del proceso.

5. El espacio de direcciones, a la vista

En el tema 9 vimos cómo el kernel arma la memoria de un proceso al cargarlo, y en el tema 11, que ocupa la mitad baja del espacio de direcciones. El siguiente programa permite comprobarlo desde adentro. Tiene un dato en cada zona, imprime la dirección de cada uno y después muestra su propio mapa de memoria.

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

int con_valor = 42;                     /* global con valor inicial */
int sin_valor;                          /* global sin valor inicial */

int main(void)
{
    int local = 0;
    const char *literal = "texto constante";
    char *chico = malloc(100);
    char *grande = malloc(1024 * 1024);
    char comando[64];

    printf("codigo (main)        %p\n", (void *)main);
    printf("constante (literal)  %p\n", (void *)literal);
    printf("global con valor     %p\n", (void *)&con_valor);
    printf("global sin valor     %p\n", (void *)&sin_valor);
    printf("malloc de 100 bytes  %p\n", (void *)chico);
    printf("malloc de 1 MB       %p\n", (void *)grande);
    printf("biblioteca (printf)  %p\n", (void *)printf);
    printf("pila (local)         %p\n", (void *)&local);
    fflush(stdout);

    /* mostrar el mapa de memoria de este mismo proceso */
    snprintf(comando, sizeof(comando), "cat /proc/%d/maps", (int)getpid());
    if (system(comando) == -1)
        perror("system");

    free(chico);
    free(grande);
    return 0;
}

La función system ejecuta un comando en un proceso hijo y espera a que termine: es el fork, exec y wait del tema 26, empaquetado. El fflush previo evita el problema del búfer duplicado que vimos allí.

terminal (mapa abreviado)
$ gcc -Wall -Wextra -g mapa.c -o mapa
$ ./mapa
codigo (main)        0x5b3a1c2f51a9
constante (literal)  0x5b3a1c2f6004
global con valor     0x5b3a1c2f8010
global sin valor     0x5b3a1c2f8018
malloc de 100 bytes  0x5b3a2d0a12a0
malloc de 1 MB       0x7c41e8cff010
biblioteca (printf)  0x7c41e8e60100
pila (local)         0x7ffd4a1b3c2c
5b3a1c2f4000-5b3a1c2f5000 r--p 00000000 103:02 1840112   /home/diego/so/mapa
5b3a1c2f5000-5b3a1c2f6000 r-xp 00001000 103:02 1840112   /home/diego/so/mapa
5b3a1c2f6000-5b3a1c2f7000 r--p 00002000 103:02 1840112   /home/diego/so/mapa
5b3a1c2f7000-5b3a1c2f8000 r--p 00002000 103:02 1840112   /home/diego/so/mapa
5b3a1c2f8000-5b3a1c2f9000 rw-p 00003000 103:02 1840112   /home/diego/so/mapa
5b3a2d0a1000-5b3a2d0c2000 rw-p 00000000 00:00 0          [heap]
7c41e8cff000-7c41e8e00000 rw-p 00000000 00:00 0
7c41e8e00000-7c41e8e28000 r--p 00000000 103:02 1315020   /usr/lib/x86_64-linux-gnu/libc.so.6
7c41e8e28000-7c41e8fb0000 r-xp 00028000 103:02 1315020   /usr/lib/x86_64-linux-gnu/libc.so.6
…
7ffd4a194000-7ffd4a1b5000 rw-p 00000000 00:00 0          [stack]
7ffd4a1f2000-7ffd4a1f4000 r-xp 00000000 00:00 0          [vdso]

Cada dirección impresa cae dentro de una línea del mapa, y la línea explica por qué ese dato se comporta como se comporta:

DatoRegiónPermisosConsecuencia
La función mainCódigo del ejecutabler-xSe puede ejecutar; no se puede modificar.
La cadena literalConstantes del ejecutabler--Escribir en ella produce el fallo de segmentación con código 7 del tema 18.
Las dos variables globalesDatos del ejecutablerw-Se leen y se escriben. Existen durante toda la ejecución.
El bloque de 100 bytes[heap]rw-Memoria pedida en ejecución.
El bloque de 1 MBUna región sin nombrerw-También pedida en ejecución, pero aparte. Lo explicamos en la sección 9.
La función printfCódigo de libc.so.6r-xEs código de otro archivo, mapeado en este proceso.
La variable local[stack]rw-Existe solo mientras se ejecuta la función.

Las direcciones muestran además la disposición general. El ejecutable y el heap están en la zona de 0x5b…; las bibliotecas, muy lejos, en 0x7c…; la pila, casi en el límite superior, en 0x7ffd…. Entre esas zonas hay extensiones enormes sin asignar: entre el heap y las bibliotecas caben unos 33 TB. Un espacio de direcciones es, sobre todo, vacío. Y gracias a la tabla de varios niveles, ese vacío no ocupa nada.

6. Leer /proc/PID/maps

El archivo maps lista las regiones del espacio de direcciones de un proceso: los tramos contiguos de páginas con los mismos permisos y el mismo origen. Cada línea tiene seis campos:

una línea de maps
7c41e8e28000-7c41e8fb0000 r-xp 00028000 103:02 1315020   /usr/lib/x86_64-linux-gnu/libc.so.6
│                         │    │        │      │         │
│                         │    │        │      │         └─ archivo de origen, o un nombre entre corchetes
│                         │    │        │      └─ número de inodo del archivo
│                         │    │        └─ dispositivo donde está el archivo
│                         │    └─ posición dentro del archivo donde empieza la región
│                         └─ permisos
└─ dirección inicial y final

La región del ejemplo empieza en la posición 0x28000 del archivo de la biblioteca: es la parte que contiene su código. Los permisos son cuatro caracteres:

CarácterSignificado
r, w, xSe puede leer, escribir, ejecutar. Un guion indica que no.
pPrivada: lo que el proceso escriba no llega al archivo ni a otros procesos.
sCompartida: las escrituras son visibles para los demás procesos que la tengan mapeada.

La última columna distingue dos clases de regiones. Las que tienen un archivo detrás obtienen su contenido de él; las que no lo tienen, llamadas anónimas, empiezan llenas de ceros. Algunas llevan un nombre entre corchetes:

NombreContenido
[heap]La zona de la que malloc toma los bloques pequeños.
[stack]La pila del hilo principal.
[vdso]El código que el kernel coloca en cada proceso para responder consultas sin entrar al kernel, del tema 13.
Sin nombreMemoria anónima: bloques grandes de malloc, pilas de otros hilos, datos de las bibliotecas.

Que una región aparezca en el mapa no significa que ocupe memoria física. El mapa describe lo que el proceso tiene permitido usar. Es contra esta lista que el kernel compara la dirección cuando llega un fallo de página: si pertenece a una región, asigna el marco y continúa; si no pertenece a ninguna, envía SIGSEGV. Es la decisión que describimos en el tema 18, y este archivo es la tabla con que se toma.

El comando pmap presenta la misma información en forma más legible, con el tamaño de cada región.

7. Direcciones al azar

Si se ejecuta mapa varias veces, las direcciones cambian en cada una:

terminal
$ ./mapa | head -1; ./mapa | head -1; ./mapa | head -1
codigo (main)        0x5b3a1c2f51a9
codigo (main)        0x61d0e84a71a9
codigo (main)        0x55f2b9c131a9
$ cat /proc/sys/kernel/randomize_va_space
2

Es deliberado. La técnica se llama ASLR, de Address Space Layout Randomization: en cada ejecución, el kernel elige al azar dónde ubicar el ejecutable, el heap, las bibliotecas y la pila. El valor 2 del archivo indica que está activada por completo.

Es una medida de seguridad. Muchos ataques consisten en lograr que un programa defectuoso salte a una dirección elegida por el atacante: una función de la biblioteca, por ejemplo. Si las direcciones cambian en cada ejecución, el atacante no sabe adónde apuntar. No corrige el defecto, pero vuelve mucho más difícil aprovecharlo.

Dos detalles de la salida merecen atención. Los tres últimos dígitos, 1a9, no cambian nunca: el azar se aplica por páginas, y el desplazamiento dentro de la página es parte del programa. Y las direcciones cambian en bloque: la distancia entre main y las variables globales es siempre la misma, porque el ejecutable se desplaza entero.

La aleatoriedad se puede desactivar para una ejecución, con el comando setarch:

terminal
$ setarch -R ./mapa | head -1; setarch -R ./mapa | head -1
codigo (main)        0x5555555551a9
codigo (main)        0x5555555551a9

Esa dirección que empieza con una fila de cincos resulta conocida: es la que mostraba gdb en el tema 18. Los depuradores desactivan la aleatoriedad para que una sesión sea repetible.

Con esto queda saldada una cuenta del tema 2. Para que un ejecutable pueda cargarse en cualquier dirección, su código no debe contener direcciones fijas; se lo llama independiente de la posición, PIE por sus siglas en inglés, y es lo que gcc produce por defecto. La opción -no-pie que usamos entonces pedía lo contrario: un ejecutable que se carga siempre en el mismo lugar. Por eso las dos copias de ilusion mostraban la misma dirección, y con un ejecutable normal habrían mostrado dos distintas, lo que habría ocultado justamente lo que queríamos ver.

8. La pila

La pila es la región donde viven las variables locales, los argumentos y las direcciones de retorno de las funciones en curso, como vimos en el tema 6. Su manejo es automático: cada llamada a función reserva un tramo, y cada retorno lo libera. El programador no interviene.

profundidad.c
#include <stdio.h>

static void bajar(int nivel)
{
    int local = nivel;

    printf("nivel %d: variable local en %p\n", nivel, (void *)&local);
    if (nivel < 4)
        bajar(nivel + 1);
}

int main(void)
{
    bajar(1);
    return 0;
}
terminal
$ gcc -Wall -Wextra -g profundidad.c -o profundidad
$ ./profundidad
nivel 1: variable local en 0x7ffd3c1a5b14
nivel 2: variable local en 0x7ffd3c1a5ae4
nivel 3: variable local en 0x7ffd3c1a5ab4
nivel 4: variable local en 0x7ffd3c1a5a84
$ ulimit -s                         # tamaño máximo de la pila, en KB
8192

La función es una sola, y su variable local existe cuatro veces a la vez, en cuatro direcciones. Cada llamada tiene la suya. Las direcciones disminuyen: la pila crece hacia abajo, y cada nivel ocupa aquí 48 bytes, que incluyen la variable, la dirección de retorno y otros datos de la llamada.

La pila tiene un tamaño máximo, 8 MB por defecto. El kernel no los entrega de entrada: asigna unas pocas páginas y añade más, mediante fallos de página, a medida que la pila desciende. Si desciende más allá del límite, el fallo de página no se resuelve y el proceso recibe SIGSEGV. Es el caso de la recursión infinita de la tabla del tema 18, y lo que se conoce como desbordamiento de pila.

De estas propiedades salen las reglas de uso que todo programador de C aprende:

9. El heap

Para los datos cuyo tamaño o duración no se conocen al compilar, un programa pide memoria durante la ejecución, con malloc, y la devuelve con free. Esa memoria se llama dinámica, y la zona de donde sale, heap.

PilaHeap
Se reservaAl entrar a una función, automáticamenteAl llamar a malloc
Se liberaAl salir de la función, automáticamenteAl llamar a free
Duración de un datoLa de la llamada a la funciónLa que decida el programa
TamañoLimitado: 8 MB por defectoLimitado por la memoria disponible
Costo de reservarCasi nulo: mover un registroMayor: buscar un bloque libre
Error típicoDesbordamiento; usar una variable de una función que ya retornóOlvidar liberar; usar un bloque ya liberado

En el tema 15 vimos que malloc no es una llamada al sistema, sino una función de la biblioteca que administra la memoria por su cuenta y recurre al kernel de vez en cuando. La salida de mapa muestra que lo hace de dos maneras, según el tamaño del pedido:

Bloques pequeñosBloques grandes
A partir de—128 KB, por defecto
Se obtienen deLa región [heap], que se amplía con la llamada brkUna región propia, creada con la llamada mmap
Al liberarlosQuedan en poder de la biblioteca, para futuros pedidosLa región se devuelve al kernel de inmediato
En el experimentoEl bloque de 100 bytesEl bloque de 1 MB

La tercera fila explica una observación frecuente: un programa libera mucha memoria y, visto desde afuera, sigue ocupando lo mismo. Los bloques pequeños liberados no vuelven al sistema; quedan disponibles para el propio programa.

El error característico del heap es la fuga de memoria: un bloque que el programa pidió, dejó de usar y nunca liberó. El kernel no puede detectarlo, porque para él esa memoria está legítimamente asignada. En un programa breve no importa, ya que al terminar el proceso toda su memoria se recupera. En un servicio que funciona durante meses, una fuga pequeña y repetida termina por agotar la memoria del equipo.

10. Reservar no es ocupar

En el tema 18 contamos los fallos de página que produce usar memoria recién pedida. El siguiente programa mide lo mismo en kilobytes, leyendo de /proc/self/status dos valores: VmSize, el tamaño de su espacio de direcciones, y VmRSS, la memoria física que ocupa.

memoria.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define TAM (200L * 1024 * 1024)        /* 200 MB */

/* Muestra el tamaño del espacio de direcciones y la memoria física en uso. */
static void mostrar(const char *etapa)
{
    FILE *f = fopen("/proc/self/status", "r");
    char linea[128];
    long tamano = 0, residente = 0;

    if (f == NULL)
        return;
    while (fgets(linea, sizeof(linea), f) != NULL) {
        sscanf(linea, "VmSize: %ld", &tamano);
        sscanf(linea, "VmRSS: %ld", &residente);
    }
    fclose(f);
    printf("%-14s VmSize %7ld kB   VmRSS %7ld kB\n", etapa, tamano, residente);
}

int main(void)
{
    char *bloque;

    mostrar("inicio");

    bloque = malloc(TAM);
    if (bloque == NULL) {
        perror("malloc");
        return 1;
    }
    mostrar("tras malloc");

    memset(bloque, 1, TAM);             /* escribir en todo el bloque */
    mostrar("tras escribir");

    free(bloque);
    mostrar("tras free");
    return 0;
}
terminal
$ gcc -Wall -Wextra -g memoria.c -o memoria
$ ./memoria
inicio         VmSize    2812 kB   VmRSS    1536 kB
tras malloc    VmSize  207616 kB   VmRSS    1536 kB
tras escribir  VmSize  207616 kB   VmRSS  206336 kB
tras free      VmSize    2812 kB   VmRSS    1536 kB
EtapaQué cambióQué hizo el kernel
mallocEl espacio de direcciones creció 200 MB. La memoria física, nada.Añadió una región al mapa. No asignó ningún marco.
EscribirLa memoria física creció 200 MB.Atendió unos cincuenta mil fallos de página, asignando un marco en cada uno.
freeLos dos valores volvieron al comienzo.Eliminó la región y recuperó los marcos. Era un bloque grande.

Son las dos columnas de ps y de top que explicamos en el tema 26, vistas en movimiento: VmSize es VSZ o VIRT, y VmRSS es RSS o RES. La primera mide promesas; la segunda, memoria entregada. Cuando se quiere saber cuánto ocupa un programa, la que cuenta es la segunda.

Los valores exactos de las líneas primera y última varían de un sistema a otro; la diferencia de 200 MB entre las etapas, no.

11. Memoria compartida, y cómo contarla

La figura de la sección 2 mostraba un marco presente en las tablas de dos procesos. Es lo que ocurre con el código de las bibliotecas. En el mapa de mapa, la biblioteca de C aparecía como una región con el archivo libc.so.6 detrás. Todos los procesos del sistema tienen esa misma región, respaldada por el mismo archivo, y el kernel usa para todos los mismos marcos. Hay una sola copia del código de la biblioteca en la memoria física, por más procesos que la usen.

Es seguro porque esas páginas son de solo lectura. Las que un proceso puede modificar, como los datos de la biblioteca, se comparten hasta el momento en que alguien escribe, y entonces se duplican: la copia al escribir.

El ahorro es enorme, y complica una pregunta que parece simple: ¿cuánta memoria usa un proceso? Hay cuatro respuestas, y todas son correctas en su sentido:

MedidaCuentaInconveniente
VSZTodo el espacio de direcciones reservado.Incluye lo que nunca se usó. Sobrestima mucho.
RSSLos marcos que el proceso tiene asignados, incluidos los compartidos, completos.Cada marco compartido se cuenta entero en cada proceso. La suma de todos supera la memoria real.
PSSLo mismo, pero cada marco compartido se divide entre los procesos que lo usan.Es la medida más justa. La suma de todos coincide con la memoria ocupada.
USSSolo los marcos que no comparte con nadie.Es lo que se liberaría si ese proceso terminara.
terminal
$ grep -E '^(Rss|Pss):' /proc/$$/smaps_rollup
Rss:                5632 kB
Pss:                1894 kB

La shell tiene asignados 5,6 MB, de los cuales buena parte son páginas de bibliotecas que comparte con decenas de procesos. Repartidas, le corresponden menos de 2 MB. Es la razón por la que no se puede calcular el uso de memoria de un sistema sumando la columna RSS de ps: el resultado excede con facilidad la memoria instalada.

12. La memoria del sistema

La vista global la da free, que presentamos en el tema 7 al explicar la caché de disco. Ahora se puede leer completo:

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
ColumnaSignificado
totalLa memoria física que administra el kernel.
usedLa ocupada por los procesos y por el propio kernel.
freeLa que no se usa para nada. Que sea poca es normal.
sharedMemoria compartida explícitamente entre procesos, y sistemas de archivos en memoria.
buff/cacheLa caché de disco. Se puede recuperar en cuanto haga falta.
availableLo que podrían obtener programas nuevos sin recurrir al intercambio. Es el dato que importa.
SwapEl área de intercambio en disco, y cuánto de ella está en uso.

La caché de disco usa los mismos marcos y las mismas tablas que todo lo demás. Cuando un proceso mapea un archivo, como ocurre con las bibliotecas, las páginas de su región son las de la caché: no hay una copia en la caché y otra en el proceso. Por eso el kernel puede descartarlas sin perder nada, ya que el contenido está en el disco, y por eso una biblioteca usada hace poco se carga al instante.

free obtiene sus cifras de /proc/meminfo, que tiene varias decenas de líneas. Dos de ellas introducen el asunto de la sección siguiente:

terminal
$ grep -E '^(MemTotal|MemAvailable|Committed_AS):' /proc/meminfo
MemTotal:       16252416 kB
MemAvailable:   10931548 kB
Committed_AS:   19874320 kB

Committed_AS es la suma de toda la memoria que el kernel tiene prometida a los procesos. En este equipo son 19 GB, y la memoria instalada es de 15,5.

13. Cuando la memoria se acaba

El kernel promete más memoria de la que existe. No es un descuido: es una política, llamada sobrecompromiso (overcommit), y se apoya en lo que mostró la sección 10. Los programas piden mucho más de lo que usan, de modo que conceder solo lo respaldado por memoria real desperdiciaría la mayor parte. Es el razonamiento de una aerolínea que vende más pasajes que asientos, contando con que algunos pasajeros no se presenten.

El problema aparece cuando se presentan todos. Si los procesos empiezan a usar lo que tienen prometido, el kernel recurre a tres medidas, en orden:

  1. Descarta caché de disco. Es gratis: los datos están en el disco. El único efecto es que las próximas lecturas serán más lentas. sin daño
  2. Envía páginas al área de intercambio. Guarda en el disco el contenido de páginas que no se usan hace tiempo, y reutiliza sus marcos. El sistema sigue funcionando, cada vez más despacio; si la presión continúa, llega a la hiperpaginación que describimos en el tema 7. lento
  3. Finaliza un proceso. Cuando no queda nada que recuperar, un componente del kernel elige un proceso —por lo general, el que más memoria ocupa— y lo elimina con SIGKILL. último recurso

El componente del tercer paso se llama OOM killer, de Out Of Memory. Deja constancia en el registro del kernel:

en dmesg, tras agotarse la memoria
[18422.107745] Out of memory: Killed process 9321 (devorador) total-vm:18402112kB, anon-rss:15102848kB, …

Para el programa, la consecuencia es desconcertante. Su malloc tuvo éxito, y mucho después, al escribir en esa memoria, o incluso sin hacer nada, es eliminado sin aviso. No hay valor de retorno que comprobar ni señal que atender. Quien investigue encontrará la explicación en dmesg, o en journalctl -k si hubo un reinicio de por medio, como vimos en el tema 22.

Esto no vuelve inútil comprobar el resultado de malloc. Hay casos en que sí devuelve NULL: cuando el pedido es absurdamente grande, y cuando el proceso tiene un límite propio. Este último se puede provocar sin riesgo, con ulimit -v, que restringe el tamaño del espacio de direcciones de los procesos lanzados desde esa shell:

terminal
$ (ulimit -v 100000; ./memoria)     # un límite de 100 MB, solo para este comando
inicio         VmSize    2812 kB   VmRSS    1536 kB
malloc: Cannot allocate memory

Los paréntesis hacen que el límite valga solo dentro de ellos. Con un máximo de 100 MB, el kernel rechaza la creación de una región de 200, malloc devuelve NULL, y el programa lo informa y termina con orden. Es la diferencia entre un límite conocido de antemano, que se puede manejar, y el agotamiento global, que no.

La memoria es el recurso que más condiciona el rendimiento de un sistema, y la memoria virtual, su mecanismo más elaborado. Aquí vimos el panorama; el curso de gestión de memoria desarrolla las tablas de páginas, los algoritmos que deciden qué página enviar al disco y el funcionamiento interno de malloc. En el tema 28 pasamos al servicio con que los datos sobreviven al proceso que los creó: los archivos.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué clase de dirección muestra printf("%p", …)?

Una dirección virtual, válida solo dentro de ese proceso. Un programa no tiene acceso a las direcciones físicas.

2. ¿Cómo pueden dos procesos tener valores distintos en la misma dirección?

Cada uno tiene su propia tabla de páginas. La misma página virtual corresponde, en cada tabla, a un marco distinto de la memoria física.

3. Con páginas de 4096 bytes, ¿en qué partes se divide la dirección 0x7ffd4a1b3c2c?

Los 12 bits inferiores, 0xc2c, son el desplazamiento dentro de la página. El resto, 0x7ffd4a1b3, es el número de página, que es lo que se traduce.

4. ¿Quién hace la traducción, y quién mantiene las tablas?

La traducción la hace el procesador, mediante la MMU, en cada acceso y sin intervención del kernel. Las tablas las construye y las mantiene el kernel, que solo interviene cuando la MMU produce un fallo de página.

5. ¿Qué es la TLB, y por qué encarece los cambios de contexto?

Una caché del procesador con las traducciones recientes, que evita recorrer la tabla en cada acceso. Al cambiar de proceso cambian las tablas, sus entradas dejan de servir y hay que volver a llenarla.

6. En mapa, la cadena literal está en una región r--p. ¿Qué pasa si el programa intenta modificarla?

La MMU detecta una escritura en una página sin permiso de escritura y genera un fallo de página. El kernel comprueba que la región no admite escritura y envía SIGSEGV.

7. ¿Qué diferencia hay entre una región con archivo y una anónima?

La primera obtiene su contenido de un archivo, como el código de un ejecutable o de una biblioteca. La anónima no tiene archivo detrás y empieza llena de ceros: el heap, la pila, los bloques grandes de malloc.

8. Una dirección figura dentro de una región de maps. ¿Significa que ocupa memoria física?

No. Significa que el proceso tiene permitido usarla. El marco se asigna en el primer acceso, mediante un fallo de página.

9. ¿Para qué sirve ASLR, y por qué no cambian los tres últimos dígitos de las direcciones?

Para que un atacante no pueda conocer de antemano las direcciones del código y los datos de un programa. Los tres últimos dígitos hexadecimales son el desplazamiento dentro de la página; el azar se aplica a la ubicación de las regiones, que se alinean a páginas completas.

10. ¿Por qué es un error devolver la dirección de una variable local?

Porque la variable está en la pila, en el tramo de esa llamada. Al retornar la función, ese tramo queda libre y la siguiente llamada lo reutiliza, de modo que el puntero pasa a señalar datos ajenos.

11. Tras un malloc de 200 MB, VmSize crece y VmRSS no. ¿Por qué?

El kernel añadió una región de 200 MB al espacio de direcciones, sin asignar ningún marco. La memoria física se ocupa recién cuando el programa escribe en cada página.

12. ¿Por qué la suma de los RSS de todos los procesos puede superar la memoria instalada?

Porque los marcos compartidos, como los del código de las bibliotecas, se cuentan completos en cada proceso que los usa. PSS los reparte entre ellos, y su suma sí coincide con la memoria ocupada.

13. free muestra muy poca memoria en la columna free. ¿Hay un problema?

No necesariamente. El kernel usa la memoria que nadie necesita como caché de disco, y la libera en cuanto hace falta. Lo que hay que mirar es available.

14. ¿Qué es el sobrecompromiso, y qué ocurre cuando falla?

Es la política de conceder más memoria de la que existe, contando con que los procesos no usan todo lo que piden. Si llegan a usarla, el kernel descarta caché, recurre al intercambio y, cuando no queda nada, el OOM killer elimina un proceso.

Para practicar

  1. Ejecuta mapa y ubica cada dirección impresa en su línea del mapa. Calcula la distancia entre el final de [heap] y el comienzo de la primera biblioteca.
  2. Ejecuta mapa tres veces y anota la dirección de main y la de la variable global con valor. ¿Cambia la diferencia entre ambas? Repite con setarch -R.
  3. Compila mapa.c con -no-pie y ejecútalo varias veces. ¿Qué direcciones dejaron de cambiar y cuáles siguen cambiando?
  4. Cambia el segundo malloc de mapa.c a 100 KB y luego a 200 KB. ¿En qué región cae el bloque en cada caso?
  5. Modifica profundidad.c quitando la condición de corte y añadiendo un contador. ¿Hasta qué nivel llega antes del fallo? Multiplica por el tamaño de cada nivel y compara con ulimit -s.
  6. Repite el ejercicio anterior con (ulimit -s 1024; ./profundidad). ¿Cómo cambia la profundidad alcanzada?
  7. En memoria.c, cambia el memset para que escriba solo la mitad del bloque. Predice los cuatro valores de VmRSS antes de ejecutar.
  8. Reduce TAM de memoria.c a 64 KB. ¿Vuelve VmSize a su valor inicial después de free? Explícalo con la tabla de la sección 9.
  9. Compara Rss y Pss de tu shell, y luego los de un programa grande, como el navegador, usando su PID. ¿En cuál es mayor la proporción compartida?
  10. Calcula con /proc/meminfo el cociente entre Committed_AS y MemTotal de tu equipo. ¿Cuánta memoria tiene prometida el kernel por cada gigabyte real?