为什么malloc + memset比calloc慢?

众所周知, callocmalloc是不同的,因为它初始化了分配的内存。 用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 

在代码2中用bzero(buf[i],BLOCK_SIZE)replacememset产生相同的结果。

我的问题是:为什么malloc + memsetcalloc慢得多? calloc如何做到这一点?

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

了解这一点需要对内存系统进行简短的介绍。

快速浏览记忆

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

malloc()calloc()这样的内存分配器大部分都是用来分配很less的内存(任何从1字节到100的KB),并将它们分组到更大的内存池中。 例如,如果分配16个字节, malloc()将首先尝试从其中一个池中获取16个字节,然后在池运行干燥时从内核请求更多的内存。 然而,因为你所问的程序是一次分配大量的内存,所以malloc()calloc()将直接从内核请求内存。 这种行为的门槛取决于你的系统,但我已经看到1 MiB作为门槛。

内核负责为每个进程分配实际的RAM,并确保进程不会干扰其他进程的内存。 这就是所谓的内存保护,自20世纪90年代以来就一直很普遍,这就是为什么一个程序可以在不closures整个系统的情况下崩溃的原因。 所以当一个程序需要更多的内存的时候,它不能只占用内存,而是使用像mmap()sbrk()这样的系统调用来从内核请求内存。 内核将通过修改页表为每个进程提供RAM。

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

如何不起作用

下面是如何分配256 MiB不起作用:

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

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

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

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

  5. 你的进程最终退出,内核回收RAM,以便其他进程可以使用它。

它是如何工作的

上面的过程会起作用,但是这不会发生。 有三个主要的区别。

  • 当你的进程从内核获得新的内存时,这个内存可能被其他进程使用过。 这是一个安全风险。 如果该内存有密码,encryption密钥或秘密莎莎食谱? 为了防止敏感数据泄露,内核在将内存交给进程之前总是擦洗内存。 我们也可以通过置零来清理内存,如果新的内存被清零,我们也可以做一个保证,所以mmap()保证它返回的新内存总是归零。

  • 有很多程序在那里分配内存,但不要马上使用内存。 内存有时分配但从未使用。 内核知道这一点,是懒惰的。 当你分配新的内存时,内核根本不会触及页表,也不会给你的进程提供任何内存。 相反,它会在您的进程中find一些地址空间,记下应该到达的地方,并承诺如果您的程序实际使用它,则会将RAM放在那里。 当你的程序试图从这些地址读取或写入时,处理器会触发页面错误 ,内核将把RAM分配给这些地址并恢复程序。 如果你从来没有使用内存,页面错误永远不会发生,你的程序永远不会得到内存。

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

最后的过程看起来更像这样:

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

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

  3. 内核find256M的未使用的地址空间,记下该地址空间现在用于什么,然后返回。

  4. 标准库知道mmap()的结果总是用零填充(或者一旦它实际上得到一些RAM),所以它不会触及内存,所以没有页面错误,RAM也不会被给出到你的过程。

  5. 你的进程最终会退出,内核不需要回收RAM,因为它从来没有分配过。

如果使用memset()将页面置零,则memset()将触发页面错误,导致RAM分配,然后将其归零,即使它已经填充了零。 这是一个额外的工作,并解释了为什么calloc()malloc()memset()更快。 如果最终使用内存, calloc()仍然比malloc()memset()更快,但是这种差异并不那么荒谬。


这并不总是奏效

并不是所有的系统都有分页的虚拟内存,所以并不是所有的系统都可以使用这些优化。 这适用于像80286这样的非常旧的处理器,以及对于复杂的内存pipe理单元而言太小的embedded式处理器。

这也不一定适用于较小的分配。 使用较小的分配时, calloc()从共享池获取内存,而不是直接进入内核。 一般而言,共享池可能会使用旧内存中存储的垃圾数据,并使用free()释放,所以calloc()可以使用该内存并调用memset()将其清除。 常见的实现将跟踪共享池的哪些部分是原始的,仍然填充零,但并不是所有的实现都这样做。

消除一些错误的答案

根据操作系统的不同,内核在空闲时间内可能会或不会零内存,以防您以后需要获取一些内存为零的内存。 Linux并没有提前将内存归零, 最近Dragonfly BSD也从内核中删除了这个特性 。 然而,其他一些内核提前没有记忆。 调零页面闲置是不足以解释大的性能差异无论如何。

calloc()函数没有使用memset()一些特殊的与内存alignment的版本,并且不会让它快得多。 现代处理器的大多数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() )之间测量了三个以上的数量级。

党的把戏

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

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

因为在许多系统上,在闲置的处理时间内,操作系统会自由地将空闲内存设置为零,并将其标记为calloc()安全,所以当您调用calloc() ,它可能已经有了自由的,归零的内存给你。

在某些平台上,在某些模式下,malloc在返回之前将内存初始化为一些非零值,所以第二个版本可以将内存初始化两次