Почему malloc+memset медленнее, чем calloc?
Известно, что calloc
отличается от malloc
тем, что инициализирует выделенную память. С calloc
, память установлена на ноль. С malloc
, память не очищена.
Так что в повседневной работе я считаю calloc
как malloc
+memset
, Кстати, ради интереса я написал следующий код для теста.
Результат сбивает с толку.
Код 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++;
}
}
Вывод кода 1:
time ./a.out
**real 0m0.287s**
user 0m0.095s
sys 0m0.192s
Код 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++;
}
}
Вывод кода 2:
time ./a.out
**real 0m2.693s**
user 0m0.973s
sys 0m1.721s
Замена memset
с bzero(buf[i],BLOCK_SIZE)
в коде 2 дает тот же результат.
Мой вопрос: почему malloc
+memset
намного медленнее, чем calloc
? Как может calloc
сделай это?
3 ответа
Короткая версия: всегда использовать calloc()
вместо malloc()+memset()
, В большинстве случаев они будут одинаковыми. В некоторых случаях, calloc()
будет делать меньше работы, потому что он может пропустить memset()
полностью. В других случаях calloc()
Можно даже обмануть и не выделять память! Тем не мение, malloc()+memset()
всегда буду делать полный объем работы.
Понимание этого требует краткого обзора системы памяти.
Быстрый тур памяти
Здесь четыре основных части: ваша программа, стандартная библиотека, ядро и таблицы страниц. Вы уже знаете свою программу, так что...
Распределители памяти как malloc()
а также calloc()
В основном они занимают небольшие места (от 1 байта до 100 КБ) и группируют их в большие пулы памяти. Например, если вы выделите 16 байтов, malloc()
сначала попытается получить 16 байтов из одного из своих пулов, а затем запросит больше памяти у ядра, когда пул начнет работать без нагрузки. Тем не менее, поскольку программа, о которой вы спрашиваете, выделяет сразу большое количество памяти, malloc()
а также calloc()
просто запросит эту память прямо из ядра. Порог для этого поведения зависит от вашей системы, но я видел 1 МБ, использованный в качестве порога.
Ядро отвечает за выделение фактической оперативной памяти каждому процессу и следит за тем, чтобы процессы не мешали памяти других процессов. Это называется защитой памяти, это было обычным делом с 1990-х годов, и это причина, по которой одна программа может аварийно завершить работу, не разрушая всю систему. Поэтому, когда программе требуется больше памяти, она не может просто взять память, а вместо этого запрашивает память у ядра, используя системный вызов, такой как mmap()
или же sbrk()
, Ядро будет отдавать оперативную память каждому процессу, изменяя таблицу страниц.
Таблица страниц отображает адреса памяти в фактическую физическую память. Адреса вашего процесса, от 0x00000000 до 0xFFFFFFFF в 32-разрядной системе, не являются реальной памятью, а вместо этого являются адресами в виртуальной памяти. Процессор делит эти адреса на страницы по 4 КиБ, и каждая страница может быть назначена разной части физической памяти путем изменения таблицы страниц. Только ядру разрешено изменять таблицу страниц.
Как это не работает
Вот как распределение 256 МБ не работает:
Ваш процесс вызывает
calloc()
и просит 256 МиБ.Стандартная библиотека звонков
mmap()
и просит 256 МиБ.Ядро находит 256 МБ неиспользуемой оперативной памяти и передает ее вашему процессу, изменяя таблицу страниц.
Стандартная библиотека обнуляет ОЗУ
memset()
и возвращается изcalloc()
,Ваш процесс в конце концов завершается, и ядро освобождает ОЗУ, чтобы его мог использовать другой процесс.
Как это на самом деле работает
Вышеописанный процесс будет работать, но так просто не происходит. Есть три основных различия.
Когда ваш процесс получает новую память от ядра, эта память, вероятно, использовалась каким-то другим процессом ранее. Это угроза безопасности. Что если в этой памяти есть пароли, ключи шифрования или секретные рецепты сальсы? Чтобы предотвратить утечку конфиденциальных данных, ядро всегда очищает память, прежде чем передать ее процессу. Мы могли бы также очистить память, обнуляя ее, и если новая память обнуляется, мы могли бы также сделать это гарантией, так
mmap()
гарантирует, что новая память, которую он возвращает, всегда обнуляется.Существует множество программ, которые выделяют память, но не используют ее сразу. Иногда память выделяется, но никогда не используется. Ядро это знает и ленится. Когда вы выделяете новую память, ядро вообще не касается таблицы страниц и не отдает ОЗУ вашему процессу. Вместо этого он находит некоторое адресное пространство в вашем процессе, записывает, что должно быть там, и дает обещание, что он поместит туда оперативную память, если ваша программа когда-либо действительно ее использует. Когда ваша программа пытается прочитать или записать данные с этих адресов, процессор вызывает сбой страницы, и ядро выполняет выделение ОЗУ для этих адресов и возобновляет работу вашей программы. Если вы никогда не используете память, сбой страницы никогда не происходит, и ваша программа фактически никогда не получает ОЗУ.
Некоторые процессы выделяют память и затем читают из нее, не изменяя ее. Это означает, что много страниц в памяти разных процессов могут быть заполнены нетронутыми нулями, возвращенными из
mmap()
, Поскольку все эти страницы одинаковы, ядро заставляет все эти виртуальные адреса указывать на одну общую 4-килобайтную страницу памяти, заполненную нулями. Если вы попытаетесь записать в эту память, процессор вызовет другую ошибку страницы, и ядро войдет, чтобы дать вам новую страницу с нулями, которая не используется другими программами.
Окончательный процесс выглядит примерно так:
Ваш процесс вызывает
calloc()
и просит 256 МиБ.Стандартная библиотека звонков
mmap()
и просит 256 МиБ.Ядро находит 256 МБ неиспользуемого адресного пространства, записывает, для чего теперь используется это адресное пространство, и возвращает.
Стандартная библиотека знает, что результат
mmap()
всегда заполнен нулями (или будет после того, как он действительно получит немного ОЗУ), поэтому он не касается памяти, поэтому нет ошибок страницы, и ОЗУ никогда не отдается вашему процессу.Ваш процесс в конечном итоге завершается, и ядру не нужно возвращать ОЗУ, потому что оно никогда не выделялось.
Если вы используете memset()
обнулить страницу, memset()
вызовет ошибку страницы, заставит ОЗУ выделиться, а затем обнулит ее, даже если она уже заполнена нулями. Это огромный объем дополнительной работы, и объясняет, почему calloc()
быстрее чем malloc()
а также memset()
, Если в конечном итоге использовать память в любом случае, calloc()
все еще быстрее чем malloc()
а также memset()
но разница не так уж и смешна.
Это не всегда работает
Не все системы имеют выгружаемую виртуальную память, поэтому не все системы могут использовать эти оптимизации. Это относится к очень старым процессорам, таким как 80286, а также к встроенным процессорам, которые слишком малы для сложного блока управления памятью.
Это также не всегда будет работать с меньшими выделениями. С меньшими выделениями, calloc()
получает память из общего пула вместо того, чтобы идти прямо к ядру. В общем случае в общем пуле могут храниться ненужные данные из старой памяти, которая была использована и освобождена с помощью free()
, так calloc()
мог взять эту память и позвонить memset()
чтобы очистить это. Общие реализации будут отслеживать, какие части общего пула являются нетронутыми и по-прежнему заполнены нулями, но не все реализации делают это.
Рассеять некоторые неправильные ответы
В зависимости от операционной системы ядро может обнулять или не обнулять память в свободное время, если вам понадобится немного обнуленной памяти позже. Linux не обнуляет память раньше времени, и Dragonfly BSD недавно также удалила эту функцию из своего ядра. Однако некоторые другие ядра делают нулевую память раньше времени. В любом случае, нулевого простоя страниц недостаточно, чтобы объяснить большие различия в производительности.
calloc()
функция не использует какую-то специальную версию с выравниванием памяти memset()
и это не сделало бы это намного быстрее в любом случае. Наиболее memset()
реализации для современных процессоров выглядят примерно так:
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()
очень быстро, и вы не получите ничего лучше для больших блоков памяти.
Дело в том, что memset()
Обнуление памяти, которая уже обнулена, означает, что память обнуляется дважды, но это объясняет только двукратную разницу в производительности. Разница в производительности здесь намного больше (я измерял более трех порядков в моей системе между malloc()+memset()
а также calloc()
).
Трюк
Вместо 10 циклов, напишите программу, которая выделяет память до malloc()
или же calloc()
возвращает NULL.
Что произойдет, если вы добавите memset()
?
Поскольку во многих системах в свободное время обработки ОС пытается самостоятельно обнулить свободную память и пометить ее как безопасную для calloc()
поэтому, когда вы звоните calloc()
, он может уже иметь свободную, обнуленную память, чтобы дать вам.
На некоторых платформах в некоторых режимах malloc инициализирует память до некоторого обычно ненулевого значения перед его возвратом, поэтому вторая версия может хорошо инициализировать память дважды