为什么 malloc+memset 比 calloc 慢?

Why malloc+memset is slower than calloc?

提问人:kingkai 提问时间:4/22/2010 最后编辑:Philip Conradkingkai 更新时间:2/10/2022 访问量:65933

问:

众所周知,这与它初始化分配的内存不同。使用 ,内存设置为零。使用 ,内存不会被清除。callocmalloccallocmalloc

所以在日常工作中,我视作+。 顺便说一句,为了好玩,我为基准测试编写了以下代码。callocmallocmemset

结果令人困惑。

代码 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  

在代码 2 中替换为会产生相同的结果。memsetbzero(buf[i],BLOCK_SIZE)

我的问题是:为什么+比?如何做到这一点?mallocmemsetcalloccalloc

c 马洛克

评论

0赞 hanshenrik 11/2/2023
应该这样做 - 但无论如何,智能优化编译器都应该自动优化它buf[i] = (char*)memset(malloc(BLOCK_SIZE),0,BLOCK_SIZE);

答:

16赞 Chris Lutz 4/22/2010 #1

因为在许多系统上,在空闲的处理时间里,操作系统会自行将可用内存设置为零,并将其标记为安全,因此当您调用 时,它可能已经有空闲的零内存可以为您提供。calloc()calloc()

评论

2赞 Dietrich Epp 4/22/2010
是否确定?哪些系统可以做到这一点?我认为大多数操作系统只是在空闲时关闭处理器,并在写入内存后立即分配的进程按需将内存归零(但在分配内存时不分配)。
0赞 Chris Lutz 4/22/2010
@Dietrich - 不确定。我曾经听说过,这似乎是一种合理(且相当简单)的提高效率的方法。calloc()
0赞 Chris Lutz 4/22/2010
@Pierreten - 我找不到任何关于特定优化的好信息,我不想解释 OP 的 libc 源代码。你能查任何东西来证明这个优化不存在/不起作用吗?calloc()
17赞 Zan Lynx 3/8/2011
@Dietrich: FreeBSD 应该在空闲时间内将页面填零:请参阅其vm.idlezero_enable设置。
1赞 Andreas Grapentin 11/12/2014
@DietrichEpp很抱歉死灵,但例如 Windows 就是这样做的。
3赞 Stewart 4/22/2010 #2

在某些平台上,在某些模式下,malloc 在返回内存之前会将内存初始化为某个通常为非零的值,因此第二个版本可以很好地初始化内存两次

评论

0赞 Felix An 11/19/2022
malloc()不初始化 RAM。它为您提供了一个指向新分配的 RAM 的指针,其中包含之前的任何垃圾数据。 将其分配并初始化为 0。calloc()
557赞 Dietrich Epp 4/22/2010 #3

简短版本:始终使用而不是 .在大多数情况下,它们是相同的。在某些情况下,会做更少的工作,因为它可以完全跳过。在其他情况下,甚至可以作弊而不分配任何内存!但是,将始终完成全部工作。calloc()malloc()+memset()calloc()memset()calloc()malloc()+memset()

要了解这一点,需要对内存系统进行简短的浏览。

快速浏览内存

这里有四个主要部分:程序、标准库、内核和页表。你已经知道你的程序了,所以......

内存分配器喜欢并且主要用于进行小分配(从 1 字节到 100 KB 的任何内容)并将它们分组到更大的内存池中。例如,如果您分配了 16 个字节,将首先尝试从其中一个池中获取 16 个字节,然后在池干涸时从内核请求更多内存。但是,由于您询问的程序一次分配了大量内存,并且只会直接从内核请求该内存。此行为的阈值取决于您的系统,但我已经看到 1 MiB 用作阈值。malloc()calloc()malloc()malloc()calloc()

内核负责为每个进程分配实际的 RAM,并确保这些进程不会干扰其他进程的内存。这被称为内存保护,自 1990 年代以来它一直很常见,这就是为什么一个程序可以在不使整个系统瘫痪的情况下崩溃的原因。因此,当程序需要更多内存时,它不能只占用内存,而是使用系统调用(如 或)从内核请求内存。内核将通过修改页表为每个进程提供 RAM。mmap()sbrk()

页表将内存地址映射到实际的物理 RAM。进程的地址(0x00000000 32 位系统上的0xFFFFFFFF)不是实际内存,而是虚拟内存中的地址。处理器将这些地址划分为 4 KiB 页,通过修改页表,可以将每个页分配给不同的物理 RAM。只允许内核修改页表。

它如何不起作用

以下是分配 256 MiB 不起作用的原因:

  1. 您的进程调用并要求 256 MiB。calloc()

  2. 标准库调用并要求 256 MiB。mmap()

  3. 内核找到 256 MiB 未使用的 RAM,并通过修改页表将其提供给您的进程。

  4. 标准库将 RAM 归零,并从 返回。memset()calloc()

  5. 您的进程最终会退出,内核会回收 RAM,以便它可以被另一个进程使用。

它是如何工作的

上述过程会起作用,但它不会以这种方式发生。有三个主要区别。

  • 当您的进程从内核获取新内存时,该内存可能以前被其他进程使用过。这是一个安全风险。如果该内存有密码、加密密钥或秘密莎莎酱食谱怎么办?为了防止敏感数据泄露,内核总是在将内存提供给进程之前清理内存。我们不妨通过清零来擦除内存,如果新内存归零,我们不妨将其作为保证,这样可以保证它返回的新内存始终归零。mmap()

  • 有很多程序会分配内存,但不会立即使用内存。有时内存被分配,但从未使用过。内核知道这一点,并且是懒惰的。当您分配新内存时,内核根本不会触及页表,也不会为您的进程提供任何 RAM。相反,它会在您的进程中找到一些地址空间,记下应该去那里的内容,并承诺如果您的程序实际使用它,它将把 RAM 放在那里。当您的程序尝试从这些地址读取或写入时,处理器会触发页面错误,内核会介入为这些地址分配 RAM 并恢复您的程序。如果您从不使用内存,则页面错误永远不会发生,并且您的程序永远不会真正获得 RAM。

  • 某些进程分配内存,然后从中读取内存而不对其进行修改。这意味着,跨不同进程的内存中的许多页面可能会填充从 返回的原始零。由于这些页面都是相同的,因此内核使所有这些虚拟地址都指向一个充满零的共享 4 KiB 内存页面。如果您尝试写入该内存,处理器会触发另一个页面错误,内核会介入,为您提供一个不与任何其他程序共享的新零页。mmap()

最终过程看起来更像是这样:

  1. 您的进程调用并要求 256 MiB。calloc()

  2. 标准库调用并要求 256 MiB。mmap()

  3. 内核找到 256 MiB 未使用的地址空间,记下该地址空间现在的用途,然后返回。

  4. 标准库知道 的结果总是用零填充(或者一旦它实际获得一些 RAM 就会填满),所以它不会触及内存,所以没有页面错误,并且 RAM 永远不会提供给你的进程。mmap()

  5. 您的进程最终会退出,内核不需要回收 RAM,因为它从一开始就没有被分配过。

如果您使用将页面归零,将触发页面错误,导致 RAM 被分配,然后将其归零,即使它已经填充了零。这是一项巨大的额外工作,并解释了为什么比 和 快。如果你最终还是使用内存,仍然比 和 快,但差异并不那么荒谬。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()

将已归零的内存归零这一事实确实意味着内存归零两次,但这只能解释 2 倍的性能差异。这里的性能差异要大得多(我在系统上测量了 和 之间的三个数量级以上)。memset()malloc()+memset()calloc()

派对技巧

不要循环 10 次,而是编写一个程序来分配内存,直到或返回 NULL。malloc()calloc()

如果添加会发生什么?memset()

评论

7赞 kriss 4/22/2010
@Dietrich:Dietrich 关于操作系统为 calloc 多次分配相同的零填充页面的虚拟内存解释很容易检查。只需添加一些循环,在每个分配的内存页中写入垃圾数据(每 500 字节写入一个字节就足够了)。然后,总体结果应该会变得更加接近,因为在这两种情况下,系统将被迫真正分配不同的页面。
1赞 Dietrich Epp 4/22/2010
@kriss:确实,尽管在绝大多数系统上每 4096 个字节就足够了
1赞 Dietrich Epp 4/1/2014
@mirabilos:实际上,实现往往更加复杂。分配的内存是以大块分配的,因此 / 实现可以跟踪哪些块仍然是原始的并且充满了零。因此,即使它没有从中获取内存,也可以避免接触内存,即它已经是堆的一部分,但尚未使用。mmap()malloc()calloc()calloc()mmap()
1赞 Dietrich Epp 4/1/2014
@mirabilos:我还见过带有“高水位线”的实现,其中超过某个点的地址被归零。我不确定你说的“容易出错”是什么意思——如果你担心应用程序写入未分配的内存,那么除了用 mudflap 检测程序之外,你几乎无法防止阴险的错误。
10赞 Dunes 3/23/2018
虽然与速度无关,但也不太容易出错。也就是说,where 会导致溢出,返回 ,但这是未定义的行为,因为您不知道返回的内存块的实际大小。calloclarge_int * large_intcalloc(large_int, large_int)NULLmalloc(large_int * large_int)