为什么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],' ',BLOCK_SIZE);
i++;
}
}
代码2的输出:
time ./a.out
**real 0m2.693s**
user 0m0.973s
sys 0m1.721s
用代码2中的bzero(buf[i],BLOCK_SIZE)
替换memset
产生相同的结果。
我的问题是:为什么malloc
+ memset
比calloc
慢得多? calloc
如何做到这一点?
简短版本:始终使用calloc()
而不是malloc()+memset()
。 在大多数情况下,它们将是相同的。 在某些情况下, calloc()
将完成更少的工作,因为它可以完全跳过memset()
。 在其他情况下, calloc()
甚至可以作弊并且不分配任何内存! 但是, malloc()+memset()
将始终执行全部工作。
了解这一点需要对内存系统进行简短的介绍。
快速浏览记忆
这里有四个主要部分:程序,标准库,内核和页表。 你已经知道你的程序了,所以......
像malloc()
和calloc()
这样的内存分配器主要用于分配较少的malloc()
从1字节到100 KB),并将它们分组到更大的内存池中。 例如,如果分配16个字节, malloc()
将首先尝试从其中一个池中获取16个字节,然后在池运行干燥时从内核请求更多内存。 但是,由于您所询问的程序是一次分配大量内存, malloc()
和calloc()
将直接从内核请求该内存。 此行为的阈值取决于您的系统,但我已经看到1 MiB用作阈值。
内核负责为每个进程分配实际的RAM,并确保进程不会干扰其他进程的内存。 这就是所谓的内存保护,自从20世纪90年代以来一直是灰烬,这就是为什么一个程序可以在不关闭整个系统的情况下崩溃的原因。 所以当一个程序需要更多的内存时,它不能只占用内存,而是会使用像mmap()
或sbrk()
这样的系统调用从内核请求内存。 内核将通过修改页表为每个进程提供RAM。
页面表将内存地址映射到实际的物理RAM。 在32位系统上,您的进程地址0x00000000到0xFFFFFFFF不是实际内存,而是虚拟内存中的地址。 处理器将这些地址分成4个KiB页面,并且可以通过修改页面表将每个页面分配给不同的物理RAM。 只有内核被允许修改页表。
它如何不起作用
下面是如何分配256 MiB不起作用:
你的进程调用calloc()
并要求256 MiB。
标准库调用mmap()
并要求256 MiB。
内核找到256MB未使用的RAM,并通过修改页表将其提供给您的进程。
标准库使用memset()
将RAM memset()
并从calloc()
返回。
你的进程最终退出,内核回收RAM,以便其他进程可以使用它。
它是如何工作的
上述过程可行,但它不会以这种方式发生。 有三个主要区别。
当你的进程从内核中获得新的内存时,该内存可能被以前的其他进程使用。 这是一个安全风险。 如果该内存有密码,加密密钥或秘密莎莎食谱? 为了防止敏感数据泄露,内核在将内存交给进程之前总是擦洗内存。 我们也可以通过置零来清理内存,并且如果新内存被清零,我们也可以做出保证,所以mmap()
保证它返回的新内存始终为零。
有很多程序分配内存但不立即使用内存。 有些时候内存已分配但从未使用过。 内核知道这一点,并且是懒惰的。 当你分配新的内存时,内核根本不会触及页表,并且不会为你的进程提供任何RAM。 相反,它会在您的进程中找到一些地址空间,记下应该到达的地方,并承诺在您的程序实际使用它时会将RAM放在那里。 当您的程序试图从这些地址读取或写入时,处理器会触发页面错误,内核会将RAM分配给这些地址并恢复您的程序。 如果你从不使用内存,那么页面错误就不会发生,你的程序也不会真的获得内存。
一些进程分配内存,然后从中进行读取而不进行修改。 这意味着跨不同进程的大量页面可能会被从mmap()
返回的原始零填充。 由于这些页面都是相同的,所以内核使得所有这些虚拟地址指向一个共享的4 KiB页面,内存填充为零。 如果您尝试写入该内存,处理器会触发另一个页面错误,并且内核会为您提供一个不与任何其他程序共享的零页面。
最后的过程看起来更像这样:
你的进程调用calloc()
并要求256 MiB。
标准库调用mmap()
并要求256 MiB。
内核找到256MB未使用的地址空间,记下该地址空间现在用于什么,然后返回。
标准库知道mmap()
的结果总是用零填充(或者一旦它实际上得到一些RAM),所以它不会触及内存,所以没有页面错误,并且从未给出RAM到你的过程。
你的进程最终会退出,内核不需要回收RAM,因为它从来没有分配过。
如果使用memset()
将页面置零,则memset()
将触发页面错误,导致RAM分配,然后将其归零,即使它已经填充了零。 这是大量的额外工作,并解释了为什么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在返回之前将内存初始化为一些非零值,所以第二个版本可以很好地初始化内存两次
链接地址: http://www.djcxy.com/p/14115.html