dev - malloc sizeof
¿Por qué malloc+memset es más lento que el calloc? (3)
Se sabe que calloc
es diferente de malloc
en que inicializa la memoria asignada. Con calloc
, la memoria se pone a cero. Con malloc
, la memoria no se borra.
Así que en el trabajo diario, considero a calloc
como malloc
+ memset
. Por cierto, por diversión, escribí el siguiente código para un punto de referencia.
El resultado es confuso.
Código 1:
#include<stdio.h>
#include<stdlib.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
int i=0;
char *buf[10];
while(i<10)
{
buf[i] = (char*)calloc(1,BLOCK_SIZE);
i++;
}
}
Salida del Código 1:
time ./a.out
**real 0m0.287s**
user 0m0.095s
sys 0m0.192s
Código 2:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
int i=0;
char *buf[10];
while(i<10)
{
buf[i] = (char*)malloc(BLOCK_SIZE);
memset(buf[i],''/0'',BLOCK_SIZE);
i++;
}
}
Salida de Código 2:
time ./a.out
**real 0m2.693s**
user 0m0.973s
sys 0m1.721s
Reemplazar memset
con bzero(buf[i],BLOCK_SIZE)
en el Código 2 produce el mismo resultado.
Mi pregunta es: ¿Por qué malloc
+ memset
es mucho más lento que el calloc
? ¿Cómo puede calloc
hacer eso?
Debido a que en muchos sistemas, en el tiempo de procesamiento de repuesto, el sistema operativo va configurando la memoria libre a cero por su cuenta y marcándola como segura para calloc()
, por lo que cuando llama a calloc()
, es posible que ya tenga una memoria libre con cero para darle .
En algunas plataformas, en algunos modos, malloc inicializa la memoria a un valor típicamente distinto de cero antes de devolverla, por lo que la segunda versión podría inicializar la memoria dos veces.
La versión corta: Siempre use calloc()
lugar de malloc()+memset()
. En la mayoría de los casos, serán lo mismo. En algunos casos, calloc()
hará menos trabajo porque puede omitir memset()
completo. En otros casos, calloc()
incluso puede hacer trampa y no asignar memoria. Sin embargo, malloc()+memset()
siempre hará la cantidad total de trabajo.
Comprender esto requiere un breve recorrido por el sistema de memoria.
Recorrido rápido de la memoria
Aquí hay cuatro partes principales: su programa, la biblioteca estándar, el kernel y las tablas de páginas. Ya conoces tu programa, así que ...
Los asignadores de memoria como malloc()
y calloc()
están allí principalmente para tomar asignaciones pequeñas (desde 1 byte hasta 100s de KB) y agruparlas en grupos de memoria más grandes. Por ejemplo, si asigna 16 bytes, malloc()
primero intentará obtener 16 bytes de uno de sus grupos, y luego solicitará más memoria del kernel cuando el grupo se agote. Sin embargo, dado que el programa que está preguntando está asignando una gran cantidad de memoria a la vez, malloc()
y calloc()
solo solicitarán esa memoria directamente del kernel. El umbral para este comportamiento depende de su sistema, pero he visto 1 MiB utilizado como umbral.
El kernel es responsable de asignar la RAM real a cada proceso y asegurarse de que los procesos no interfieran con la memoria de otros procesos. Esto se conoce como protección de memoria, ha sido muy común desde la década de 1990, y es la razón por la que un programa puede fallar sin destruir todo el sistema. Entonces, cuando un programa necesita más memoria, no puede simplemente tomar la memoria, sino que solicita la memoria del kernel utilizando una llamada al sistema como mmap()
o sbrk()
. El núcleo dará RAM a cada proceso modificando la tabla de páginas.
La tabla de páginas asigna las direcciones de memoria a la RAM física real. Las direcciones de su proceso, 0x00000000 a 0xFFFFFFFF en un sistema de 32 bits, no son memoria real, sino direcciones en memoria virtual. El procesador divide estas direcciones en 4 páginas KiB, y cada página puede asignarse a una memoria RAM física diferente modificando la tabla de páginas. Solo el kernel tiene permiso para modificar la tabla de páginas.
Como no funciona
Así es como la asignación de 256 MiB no funciona:
El proceso llama a
calloc()
y solicita 256 MiB.La biblioteca estándar llama a
mmap()
y solicita 256 MiB.El kernel encuentra 256 MiB de RAM no utilizada y lo entrega a su proceso modificando la tabla de páginas.
La biblioteca estándar pone a cero la memoria RAM con
memset()
y devuelve desdecalloc()
.Finalmente, su proceso se cierra y el kernel recupera la RAM para que pueda ser utilizado por otro proceso.
Cómo funciona realmente
El proceso anterior funcionaría, pero simplemente no sucede de esta manera. Hay tres grandes diferencias.
Cuando su proceso obtiene nueva memoria del kernel, esa memoria probablemente fue utilizada por algún otro proceso anterior. Este es un riesgo de seguridad. ¿Qué pasa si esa memoria tiene contraseñas, claves de cifrado o recetas secretas de salsa? Para evitar que se filtren datos confidenciales, el kernel siempre borra la memoria antes de entregarla a un proceso. Podríamos también limpiar la memoria poniendo a cero, y si la nueva memoria se pone a cero, también podríamos hacer que sea una garantía, por lo que
mmap()
garantiza que la nueva memoria que devuelve siempre esté en cero.Hay muchos programas por ahí que asignan memoria pero no la usan de inmediato. Algunas veces se asigna memoria pero nunca se usa. El kernel lo sabe y es perezoso. Cuando asigna una nueva memoria, el kernel no toca la tabla de páginas y no proporciona RAM a su proceso. En su lugar, encuentra algo de espacio de direcciones en su proceso, toma nota de lo que se supone que debe ir allí y promete que pondrá RAM allí si su programa alguna vez lo usa. Cuando su programa intenta leer o escribir desde esas direcciones, el procesador desencadena un fallo de página y el kernel pasa a asignar RAM a esas direcciones y reanuda su programa. Si nunca usa la memoria, la falla de la página nunca ocurre y su programa nunca obtiene la RAM.
Algunos procesos asignan memoria y luego leen sin modificarla. Esto significa que una gran cantidad de páginas en la memoria a través de diferentes procesos pueden llenarse con ceros prístinos devueltos desde
mmap()
. Como estas páginas son todas iguales, el kernel hace que todas estas direcciones virtuales apunten a una única página compartida de 4 KiB de memoria llena de ceros. Si intenta escribir en esa memoria, el procesador desencadena otra falla de página y el kernel interviene para darle una nueva página de ceros que no se comparte con ningún otro programa.
El proceso final se parece más a esto:
El proceso llama a
calloc()
y solicita 256 MiB.La biblioteca estándar llama a
mmap()
y solicita 256 MiB.El kernel encuentra 256 MiB de espacio de direcciones no utilizado , hace una nota sobre para qué se usa ese espacio de direcciones y regresa.
La biblioteca estándar sabe que el resultado de
mmap()
siempre se llena con ceros (o lo será una vez que realmente obtenga algo de RAM), por lo que no toca la memoria, por lo que no hay un error de página, y la RAM nunca se da a su proceso.Su proceso eventualmente se cierra, y el kernel no necesita recuperar la RAM porque nunca se asignó en primer lugar.
Si usa memset()
para memset()
a cero la página, memset()
activará la falla de la página, hará que la RAM se asigne y luego la pondrá a cero aunque ya esté llena de ceros. Esta es una enorme cantidad de trabajo adicional, y explica por qué calloc()
es más rápido que malloc()
y memset()
. Si terminas usando la memoria de todos modos, calloc()
sigue siendo más rápido que malloc()
y memset()
pero la diferencia no es tan ridícula.
Esto no siempre funciona
No todos los sistemas tienen memoria virtual paginada, por lo que no todos los sistemas pueden usar estas optimizaciones. Esto se aplica a procesadores muy antiguos como el 80286, así como a procesadores integrados que son demasiado pequeños para una unidad de gestión de memoria sofisticada.
Esto tampoco funcionará siempre con asignaciones más pequeñas. Con asignaciones más pequeñas, calloc()
obtiene memoria de un grupo compartido en lugar de ir directamente al kernel. En general, el grupo compartido puede tener datos no deseados almacenados en la memoria antigua que se usó y liberó con free()
, por lo que calloc()
podría tomar esa memoria y llamar a memset()
para eliminarla. Las implementaciones comunes rastrearán qué partes del grupo compartido son prístinas y aún están llenas de ceros, pero no todas las implementaciones hacen esto.
Disipando algunas respuestas equivocadas
Dependiendo del sistema operativo, el kernel puede o no poner a cero la memoria en su tiempo libre, en caso de que necesite obtener algo de memoria a cero más tarde. Linux no pone a cero la memoria antes de tiempo, y Dragonfly BSD recientemente también eliminó esta característica de su núcleo . Sin embargo, algunos otros núcleos no tienen memoria de antemano. Poner a cero las páginas durante el tiempo de inactividad no es suficiente para explicar las grandes diferencias de rendimiento de todos modos.
La función calloc()
no usa alguna versión especial de memset()
alineada con la memoria, y eso no lo haría mucho más rápido de todos modos. La mayoría de las implementaciones de memset()
para procesadores modernos se parecen a esto:
function memset(dest, c, len)
// one byte at a time, until the dest is aligned...
while (len > 0 && ((unsigned int)dest & 15))
*dest++ = c
len -= 1
// now write big chunks at a time (processor-specific)...
// block size might not be 16, it''s just pseudocode
while (len >= 16)
// some optimized vector code goes here
// glibc uses SSE2 when available
dest += 16
len -= 16
// the end is not aligned, so one byte at a time
while (len > 0)
*dest++ = c
len -= 1
memset()
puede ver, memset()
es muy rápido y realmente no va a obtener nada mejor para grandes bloques de memoria.
El hecho de que memset()
esté memset()
cero la memoria que ya está puesta a cero significa que la memoria se pone a cero dos veces, pero eso solo explica una diferencia de rendimiento de 2x. La diferencia de rendimiento aquí es mucho mayor (medí más de tres órdenes de magnitud en mi sistema entre malloc()+memset()
y calloc()
).
Truco de fiesta
En lugar de hacer un bucle 10 veces, escriba un programa que asigne memoria hasta que malloc()
o calloc()
devuelva NULL.
¿Qué pasa si añades memset()
?