330 lines
14 KiB
Markdown
330 lines
14 KiB
Markdown
# Memoria dinámica: `malloc` y `free`
|
||
|
||
## Pila y montón
|
||
|
||
Un proceso tiene dos sitios principales donde guardar datos mientras se
|
||
ejecuta:
|
||
|
||
| | Pila (*stack*) | Montón (*heap*) |
|
||
|---|---|---|
|
||
| Qué va ahí | Variables locales de una función | Lo que pides con `malloc` |
|
||
| Cuándo se libera | Sola, al salir de la función | Cuando llamas a `free` |
|
||
| Tamaño | Fijo y pequeño (unos 8 MB en Linux) | Grande, crece bajo demanda |
|
||
| Coste | Casi gratis | Llamada a función. A veces, llamada al sistema |
|
||
|
||
El problema de la pila: al salir de la función, sus variables dejan de existir.
|
||
Si devuelves la dirección de una variable local, el que llama recibe un puntero
|
||
a memoria que ya no es suya (*dangling pointer*). Leerlo es **comportamiento
|
||
indefinido**: puede parecer que funciona, dar basura o colgar el programa.
|
||
|
||
### Dónde están físicamente
|
||
|
||
En la **misma RAM**. La pila no está en un chip especial ni el montón en
|
||
otro. La diferencia es de organización, no física:
|
||
|
||
- Cada proceso ve un **espacio de direcciones virtual** propio (ver
|
||
[03-process-memory.md](03-process-memory.md)). Dentro de ese espacio, el
|
||
sistema operativo coloca cada zona en un rango de direcciones distinto.
|
||
- La MMU traduce cada página virtual (4 KB) a un **marco físico cualquiera** de
|
||
la RAM. Páginas contiguas de la pila pueden estar en marcos dispersos, e
|
||
incluso en disco (*swap*) si falta memoria.
|
||
|
||
Disposición típica en Linux x86-64, de direcciones bajas a altas:
|
||
|
||
```
|
||
0x0000... código del programa (.text) r-x
|
||
datos globales (.data, .bss) rw-
|
||
[heap] ↓ crece hacia arriba rw- (malloc, brk)
|
||
... librerías y mmap ... (malloc grandes)
|
||
[stack] ↑ crece hacia abajo rw- (8 MB máx., ulimit -s)
|
||
0x7fff...
|
||
```
|
||
|
||
Se ve en vivo: `cat /proc/self/maps` muestra el mapa del propio `cat`, con
|
||
las líneas `[heap]` y `[stack]`. Para un proceso en marcha:
|
||
`cat /proc/<pid>/maps`.
|
||
|
||
**`[heap]` no es todo el montón.** La línea `[heap]` del mapa es solo la
|
||
zona clásica que `malloc` hace crecer con la llamada al sistema `brk`. Los
|
||
bloques grandes (más de 128 KB en glibc) los pide `malloc` con `mmap`, y
|
||
aparecen como zonas sin nombre. Chromium, V8 y el runtime de Go usan sus
|
||
propios gestores sobre `mmap` y pueden no tener `[heap]` en absoluto.
|
||
Comprobado con el proceso principal de VS Code: tiene `[stack]` pero no
|
||
`[heap]`.
|
||
|
||
### Memoria virtual frente a memoria real
|
||
|
||
`/proc/<pid>/status` muestra dos cifras:
|
||
|
||
- `VmSize`: direcciones virtuales reservadas. Reservar direcciones no gasta
|
||
RAM.
|
||
- `VmRSS` (*Resident Set Size*): páginas que están de verdad en RAM porque
|
||
alguien ha escrito en ellas. Es lo que cuenta para el OOM killer y para el
|
||
límite de memoria de un contenedor.
|
||
|
||
Ejemplo real con el proceso principal de VS Code:
|
||
|
||
```
|
||
VmSize: 1519018808 kB ~1,4 TB
|
||
VmRSS: 293276 kB ~286 MB
|
||
```
|
||
|
||
V8 reserva un rango enorme de direcciones (su *sandbox*) para que los objetos
|
||
de JavaScript no puedan apuntar fuera de él. En x86-64 cada proceso tiene
|
||
128 TB de espacio virtual, así que 1,4 TB es un 1 %.
|
||
|
||
**Por qué la pila es tan rápida** si está en la misma RAM:
|
||
|
||
- Reservar en la pila es restar un número a un registro (`rsp`, el *stack
|
||
pointer*): una instrucción. Al salir de la función se suma de vuelta.
|
||
- Esas páginas se usan constantemente, así que están en la caché de la CPU.
|
||
- `malloc` en cambio tiene que buscar un hueco libre en sus listas, y a veces
|
||
pedir más memoria al núcleo con una llamada al sistema (`brk` o `mmap`).
|
||
|
||
**En Go**: la pila de cada goroutine **no** es la pila del sistema operativo.
|
||
El runtime la reserva en su propio montón: empieza pequeña (unos KB) y la
|
||
copia a un bloque mayor cuando se queda corta. Por eso puedes tener millones de
|
||
goroutines y en C no puedes tener millones de hilos.
|
||
|
||
## Qué hacía Go por mí
|
||
|
||
En Go puedes escribir esto sin problema:
|
||
|
||
```go
|
||
func newBuffer() *Buffer {
|
||
b := Buffer{}
|
||
return &b
|
||
}
|
||
```
|
||
|
||
El compilador hace *escape analysis*: ve que `b` sobrevive a la función y la
|
||
coloca en el montón en lugar de la pila. Luego el recolector de basura (GC) la
|
||
libera cuando nadie la referencia. Se ve con `go build -gcflags=-m`
|
||
(`moved to heap: b`).
|
||
|
||
En C nada de eso existe. **Tú decides** dónde va cada dato y **tú lo liberas**.
|
||
|
||
## `malloc` y `free`
|
||
|
||
Se declaran en `<stdlib.h>`.
|
||
|
||
- `malloc(n)` reserva `n` bytes en el montón y devuelve un puntero al primero.
|
||
- El contenido **no está inicializado**: es basura. En Go, `new` y `make`
|
||
ponen todo a cero; aquí no.
|
||
- Si no hay memoria, devuelve `NULL`. **Hay que comprobarlo siempre.**
|
||
- Devuelve `void *`, un puntero "a cualquier cosa". En C se convierte solo al
|
||
tipo de puntero que lo recibe, sin *cast*.
|
||
- `free(p)` devuelve esa memoria.
|
||
- Después, `p` sigue apuntando a la misma dirección, pero ya no es tuya. Usarla
|
||
(*use after free*) es comportamiento indefinido.
|
||
- Liberar dos veces lo mismo (*double free*) también lo es.
|
||
- `free(NULL)` no hace nada. Es seguro.
|
||
- `calloc(count, size)` es como `malloc`, pero pone a cero la memoria.
|
||
|
||
Forma idiomática de reservar un `struct`:
|
||
|
||
```c
|
||
struct escpos_buffer *buf = malloc(sizeof *buf);
|
||
```
|
||
|
||
`sizeof *buf` es "el tamaño de aquello a lo que apunta `buf`". Se calcula al
|
||
compilar, no lee nada en ejecución. Así, si cambia el tipo de `buf`, el tamaño
|
||
cambia solo.
|
||
|
||
## `realloc`: cambiar el tamaño de un bloque
|
||
|
||
`realloc(p, n)` cambia el tamaño del bloque `p` a `n` bytes y devuelve un
|
||
puntero al bloque redimensionado:
|
||
|
||
- Si hay sitio justo detrás, lo agranda **en el mismo sitio** y devuelve la
|
||
misma dirección.
|
||
- Si no, reserva un bloque nuevo, **copia** el contenido, libera el viejo y
|
||
devuelve la dirección **nueva**. Cualquier otro puntero al bloque viejo queda
|
||
colgando.
|
||
- `realloc(NULL, n)` equivale a `malloc(n)`. Por eso un buffer vacío puede
|
||
empezar con `data = NULL`.
|
||
- Si falla, devuelve `NULL` y **el bloque viejo sigue intacto y sigue siendo
|
||
tuyo**.
|
||
|
||
**Qué bloque se redimensiona.** En un buffer hay dos bloques:
|
||
|
||
```
|
||
buffer ──▶ [ data | len | cap ] struct: 24 bytes fijos, no crece nunca
|
||
│
|
||
└──▶ [ A A A A ... ] bytes: este es el que crece
|
||
```
|
||
|
||
Se hace `realloc` de `buffer->data`, nunca de `buffer`. Hacer `realloc` del
|
||
struct puede moverlo a otra dirección y liberar el viejo, y entonces el
|
||
puntero `buffer` que tiene el que llama queda colgando.
|
||
|
||
El error clásico:
|
||
|
||
```c
|
||
p = realloc(p, n); /* MAL: si falla, p pasa a NULL y el bloque viejo se pierde (fuga) */
|
||
```
|
||
|
||
Lo correcto es guardar el resultado en una variable temporal, comprobarlo y
|
||
solo entonces asignarlo.
|
||
|
||
**En Go** es exactamente lo que hace `append` por dentro. Por eso hay que
|
||
escribir `s = append(s, x)`: si el array subyacente se movió, el slice viejo
|
||
apunta al sitio antiguo.
|
||
|
||
### Estrategia de crecimiento
|
||
|
||
Si el buffer creciera de byte en byte, cada byte nuevo podría provocar una
|
||
copia de todo lo anterior: añadir `n` bytes costaría del orden de `n²`. La
|
||
solución es **duplicar la capacidad** cada vez que se llena. Así hay pocas
|
||
copias, y el coste medio de añadir un byte es constante (*coste amortizado
|
||
O(1)*).
|
||
|
||
`append` en Go duplica hasta 256 elementos y a partir de ahí crece más despacio
|
||
(unos 1,25×).
|
||
|
||
## Fugas de memoria
|
||
|
||
Si pierdes el último puntero a un bloque sin hacer `free`, ese bloque queda
|
||
reservado hasta que acabe el proceso: es una **fuga** (*memory leak*). En un
|
||
programa que dura poco no se nota. En un servicio que imprime tiques todo el
|
||
día, la memoria crece hasta que el sistema lo mata.
|
||
|
||
**En Go** el GC lo evita. Las fugas que hay en Go son de otro tipo: goroutines
|
||
bloqueadas o referencias que guardas sin querer.
|
||
|
||
### Qué pasa cuando se acaba la memoria
|
||
|
||
**Linux: *overcommit* y OOM killer.**
|
||
|
||
- `malloc` casi nunca devuelve `NULL` en Linux. El núcleo solo reserva
|
||
**direcciones virtuales**, y promete más memoria de la que tiene
|
||
(*overcommit*). La RAM física se asigna página a página, **la primera vez
|
||
que escribes** en ella, mediante un fallo de página.
|
||
- Si llega un momento en que alguien escribe y no queda RAM ni *swap*, el núcleo
|
||
no puede cumplir la promesa. Entonces actúa el **OOM killer** (*Out Of
|
||
Memory*): elige el proceso con mayor `oom_score`, que es básicamente el que
|
||
más memoria usa, y le envía `SIGKILL`.
|
||
- `SIGKILL` no se puede capturar ni ignorar. El proceso muere en el acto, sin
|
||
`defer`, sin cerrar ficheros y sin dejar un error en su propio log.
|
||
- Queda rastro en el log del núcleo: `journalctl -k | grep -i "out of memory"`
|
||
(`Killed process 1234 (hello) ...`).
|
||
- Fedora tiene además `systemd-oomd`, que actúa antes, cuando detecta presión
|
||
de memoria sostenida, y mata grupos de procesos enteros.
|
||
- **Contenedores**: un límite de memoria de Docker o Kubernetes es un *cgroup*.
|
||
Al superarlo actúa el mismo OOM killer, pero solo dentro del contenedor. Es
|
||
el `OOMKilled` de Kubernetes (código de salida 137 = 128 + 9, la señal
|
||
`SIGKILL`).
|
||
|
||
**Windows** no tiene OOM killer. Lleva la cuenta de la memoria prometida
|
||
(*commit charge*) frente a RAM + fichero de paginación. Si se supera, `malloc`
|
||
**sí devuelve `NULL`**.
|
||
|
||
**Android** tiene `lmkd` (*low memory killer*), que mata primero las apps en
|
||
segundo plano.
|
||
|
||
Por eso hay que comprobar `NULL` aunque en Linux casi nunca salga: en Windows
|
||
sí ocurre, también con límites como `ulimit -v`, y con peticiones absurdas
|
||
(por ejemplo, un tamaño calculado mal que da varios exabytes).
|
||
|
||
**En Go** pasa lo mismo. Si el runtime no consigue memoria, el programa muere
|
||
con `fatal error: runtime: out of memory`, que no se puede recuperar con
|
||
`recover`. Y si lo mata el OOM killer, ni siquiera eso. `GOMEMLIMIT` le dice al
|
||
GC que trabaje más cuando se acerca a un límite, para no llegar ahí.
|
||
|
||
## Propiedad (*ownership*)
|
||
|
||
Como no hay GC, cada puntero tiene un **dueño**: el código responsable de
|
||
liberarlo. La API tiene que dejarlo claro. La convención del proyecto es
|
||
documentar en la cabecera, en cada función, quién reserva y quién libera.
|
||
|
||
El patrón típico es una pareja de funciones:
|
||
|
||
- `x_new()` reserva y devuelve el puntero. El que llama pasa a ser el dueño.
|
||
- `x_free(p)` libera. Después de llamarla, el que llama no debe usar `p`.
|
||
|
||
Es parecido a `defer f.Close()` en Go: un recurso que tienes que devolver tú.
|
||
La diferencia es que en C también la memoria es un recurso que se devuelve a
|
||
mano.
|
||
|
||
**El dueño es quien tiene la obligación de liberar.** No es quien reservó la
|
||
memoria, sino quien debe acabar llamando a `free`. Esa obligación puede pasar
|
||
de una función a otra, y por eso hay que escribirla en la cabecera: el tipo
|
||
`escpos_buffer *` no dice nada sobre quién libera.
|
||
|
||
Dos funciones de la API que devuelven punteros con dueños distintos:
|
||
|
||
| Función | Quién reserva | Dueño | Qué debe hacer el que llama |
|
||
|---|---|---|---|
|
||
| `escpos_buffer_new()` | la librería, con `malloc` | **el que llama** (se le traspasa) | llamar a `escpos_buffer_free` una vez, y no usar el puntero después |
|
||
| `escpos_version()` | nadie: es un literal de cadena en memoria de solo lectura | **la librería** (vive mientras vive el programa) | solo leerlo. Si hace `free`, el comportamiento es indefinido (normalmente el programa aborta) |
|
||
|
||
Mirando solo las firmas, ambas "devuelven un puntero". El comentario es lo que
|
||
dice qué hacer con cada una.
|
||
|
||
**Con cgo** (fase 12) importa todavía más: el GC de Go no ve la memoria de C.
|
||
El código Go que llame a `escpos_buffer_new` tendrá que hacer
|
||
`defer C.escpos_buffer_free(b)`, igual que con un fichero.
|
||
|
||
## Valgrind
|
||
|
||
`valgrind` ejecuta tu programa en una CPU simulada y vigila cada acceso a
|
||
memoria. Detecta fugas, lecturas de memoria sin inicializar, *use after free*
|
||
y accesos fuera de un bloque. El programa va unas 20-50 veces más lento.
|
||
|
||
```
|
||
valgrind --leak-check=full ./build/hello
|
||
```
|
||
|
||
Al final del informe:
|
||
|
||
- `All heap blocks were freed -- no leaks are possible`: todo bien.
|
||
- `definitely lost: N bytes in M blocks`: fuga. Con `-g`, Valgrind indica
|
||
la línea del `malloc` que nunca se liberó.
|
||
|
||
Solo funciona en Linux. Por eso la parte portable se prueba en Fedora.
|
||
|
||
### Cómo leer un informe de fuga
|
||
|
||
Informe real, quitando el `escpos_buffer_free` de `hello.c`:
|
||
|
||
```
|
||
HEAP SUMMARY:
|
||
in use at exit: 24 bytes in 1 blocks
|
||
total heap usage: 2 allocs, 1 frees, 4,120 bytes allocated
|
||
|
||
24 bytes in 1 blocks are definitely lost in loss record 1 of 1
|
||
at 0x483FB26: malloc (vg_replace_malloc.c:447)
|
||
by 0x4004DD: escpos_buffer_new (buffer.c:16)
|
||
by 0x4004A7: main (hello.c:10)
|
||
```
|
||
|
||
- **`2 allocs`**: una es nuestra (24 bytes: el struct). La otra (4.096 bytes)
|
||
es el búfer interno que crea `printf` para la salida estándar, que la
|
||
librería de C libera al terminar. Valgrind vigila todo el proceso, no solo
|
||
tu código.
|
||
- **La pila de llamadas se lee de abajo arriba**: `main` (línea 10 de
|
||
`hello.c`) llamó a `escpos_buffer_new`, que en la línea 16 de `buffer.c`
|
||
llamó a `malloc`. Los ficheros y las líneas salen gracias a `-g`.
|
||
- Valgrind dice **dónde se reservó** el bloque perdido, no dónde faltó el
|
||
`free`. Eso lo deduces tú: ¿quién era el dueño de ese puntero?
|
||
|
||
**Cómo sabe Valgrind que es una fuga.** Al terminar el programa, recorre toda
|
||
la memoria que todavía es accesible (pila, variables globales, registros y los
|
||
demás bloques del montón) buscando algún valor que sea la dirección del bloque.
|
||
Si no encuentra ninguno, ningún código podría ya hacer `free` de ese bloque: la
|
||
fuga es **segura**, no una sospecha. Es la fase de marcado del GC de Go: el GC
|
||
libera lo que no alcanza, y Valgrind lo denuncia.
|
||
|
||
En el ejemplo, el único puntero era la variable local `buffer` de `main`, que
|
||
estaba en la pila. Cuando `main` terminó, esa variable desapareció y con ella
|
||
la última referencia al bloque.
|
||
|
||
Tipos de pérdida en `LEAK SUMMARY`:
|
||
|
||
| Tipo | Significado |
|
||
|---|---|
|
||
| `definitely lost` | Ningún puntero apunta ya al bloque. Fuga segura |
|
||
| `indirectly lost` | Solo se llega al bloque desde otro bloque perdido. Por ejemplo, el `data` de un buffer cuyo struct se perdió |
|
||
| `possibly lost` | Solo hay punteros al interior del bloque, no a su inicio |
|
||
| `still reachable` | Al salir aún había un puntero (por ejemplo, una variable global). No es una fuga, pero nadie lo liberó |
|