Почему 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 МБ не работает:

  1. Ваш процесс вызывает calloc() и просит 256 МиБ.

  2. Стандартная библиотека звонков mmap() и просит 256 МиБ.

  3. Ядро находит 256 МБ неиспользуемой оперативной памяти и передает ее вашему процессу, изменяя таблицу страниц.

  4. Стандартная библиотека обнуляет ОЗУ memset() и возвращается из calloc(),

  5. Ваш процесс в конце концов завершается, и ядро ​​освобождает ОЗУ, чтобы его мог использовать другой процесс.

Как это на самом деле работает

Вышеописанный процесс будет работать, но так просто не происходит. Есть три основных различия.

  • Когда ваш процесс получает новую память от ядра, эта память, вероятно, использовалась каким-то другим процессом ранее. Это угроза безопасности. Что если в этой памяти есть пароли, ключи шифрования или секретные рецепты сальсы? Чтобы предотвратить утечку конфиденциальных данных, ядро ​​всегда очищает память, прежде чем передать ее процессу. Мы могли бы также очистить память, обнуляя ее, и если новая память обнуляется, мы могли бы также сделать это гарантией, так mmap() гарантирует, что новая память, которую он возвращает, всегда обнуляется.

  • Существует множество программ, которые выделяют память, но не используют ее сразу. Иногда память выделяется, но никогда не используется. Ядро это знает и ленится. Когда вы выделяете новую память, ядро ​​вообще не касается таблицы страниц и не отдает ОЗУ вашему процессу. Вместо этого он находит некоторое адресное пространство в вашем процессе, записывает, что должно быть там, и дает обещание, что он поместит туда оперативную память, если ваша программа когда-либо действительно ее использует. Когда ваша программа пытается прочитать или записать данные с этих адресов, процессор вызывает сбой страницы, и ядро ​​выполняет выделение ОЗУ для этих адресов и возобновляет работу вашей программы. Если вы никогда не используете память, сбой страницы никогда не происходит, и ваша программа фактически никогда не получает ОЗУ.

  • Некоторые процессы выделяют память и затем читают из нее, не изменяя ее. Это означает, что много страниц в памяти разных процессов могут быть заполнены нетронутыми нулями, возвращенными из mmap(), Поскольку все эти страницы одинаковы, ядро ​​заставляет все эти виртуальные адреса указывать на одну общую 4-килобайтную страницу памяти, заполненную нулями. Если вы попытаетесь записать в эту память, процессор вызовет другую ошибку страницы, и ядро ​​войдет, чтобы дать вам новую страницу с нулями, которая не используется другими программами.

Окончательный процесс выглядит примерно так:

  1. Ваш процесс вызывает calloc() и просит 256 МиБ.

  2. Стандартная библиотека звонков mmap() и просит 256 МиБ.

  3. Ядро находит 256 МБ неиспользуемого адресного пространства, записывает, для чего теперь используется это адресное пространство, и возвращает.

  4. Стандартная библиотека знает, что результат mmap() всегда заполнен нулями (или будет после того, как он действительно получит немного ОЗУ), поэтому он не касается памяти, поэтому нет ошибок страницы, и ОЗУ никогда не отдается вашему процессу.

  5. Ваш процесс в конечном итоге завершается, и ядру не нужно возвращать ОЗУ, потому что оно никогда не выделялось.

Если вы используете 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 инициализирует память до некоторого обычно ненулевого значения перед его возвратом, поэтому вторая версия может хорошо инициализировать память дважды

Другие вопросы по тегам