众所周知 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
在代码2中用 bzero(buf[i],BLOCK_SIZE)
替换 memset
会产生相同的结果 .
My question is: 为什么 malloc
memset
比 calloc
慢得多? calloc
怎么做?
3 回答
简短版本:始终使用
calloc()
而不是malloc()+memset()
. 在大多数情况下,它们将是相同的 . 在某些情况下,calloc()
会减少工作量,因为它可以完全跳过memset()
. 在其他情况下,calloc()
甚至可以作弊而不分配任何内存!但是,malloc()+memset()
将始终完成全部工作 .理解这一点需要对内存系统进行简短的浏览 .
快速浏览内存
这里有四个主要部分:程序,标准库,内核和页表 . 你已经了解你的程序,所以......
像
malloc()
和calloc()
这样的内存分配器主要用于获取小的分配(从1字节到100的KB),并将它们分组到更大的内存池中 . 例如,如果您分配16个字节,malloc()
将首先尝试从其中一个池中获取16个字节,然后在池运行时从内核请求更多内存 . 但是,由于您要问的程序是一次分配大量内存,malloc()
和calloc()
将直接从内核请求该内存 . 此行为的阈值取决于您的系统,但我已经看到1 MiB用作阈值 .内核负责为每个进程分配实际的RAM,并确保进程不会干扰其他进程的内存 . 这称为内存保护,自20世纪90年代以来一直很常见,它只是占用内存,而是使用系统调用(如
mmap()
或sbrk()
)从内核请求内存 . 内核将通过修改页表为每个进程提供RAM .页表将内存地址映射到实际物理RAM . 您的进程's addresses, 0x00000000 to 0xFFFFFFFF on a 32-bit system, aren' t真实内存,而是虚拟内存中的地址 . 处理器将这些地址划分为4个KiB页面,并且可以通过修改页面表将每个页面分配给不同的物理RAM . 只允许内核修改页表 .
它怎么行不通
以下是分配256 MiB不起作用的方法:
您的进程调用
calloc()
并要求256 MiB .标准库调用
mmap()
并要求256 MiB .内核找到256 MiB未使用的RAM,并通过修改页表将其提供给您的进程 .
标准库使用
memset()
将RAM归零,并从calloc()
返回 .您的进程最终退出,内核回收RAM,以便其他进程可以使用它 .
它是如何运作的
上面的过程可行,但它不会以这种方式发生 . 有三个主要差异 .
当您的进程从内核获取新内存时,该内存可能以前被其他一些进程使用 . 这是一种安全风险 . 如果该内存有密码,加密密钥或秘密莎莎食谱怎么办?为了防止敏感数据泄漏,内核总是在将内存提供给进程之前擦除内存 . 我们不妨通过归零来擦除内存,如果新内存归零,我们也可以将其作为保证,因此
mmap()
保证它返回的新内存始终为零 .有很多程序可以分配内存,但根本不需要触摸页面表,也不会为你的进程提供任何内存 . 相反,它会在你的进程中找到一些地址空间,记下应该去的地方,并承诺如果你的程序实际使用它,它会把RAM放在那里 . 当程序尝试从这些地址读取或写入时,处理器会触发页面错误,内核会将RAM分配给这些地址并恢复程序 . 如果你从不使用内存,页面错误永远不会发生,程序永远不会真正获得内存 .
某些进程分配内存,然后从中读取而不进行修改 . 这意味着不同进程的内存中的很多页面可能会填充从
mmap()
返回的原始零 . 由于这些页面都是相同的,因此内核使所有这些虚拟地址指向一个用零填充的单个共享4 KiB内存页面 . 如果您尝试写入该内存,处理器会触发另一个页面错误,内核会介入,为您提供一个新的页面,这些零点不与任何其他内容共享程式 .最后的过程看起来更像是这样的:
您的进程调用
calloc()
并要求256 MiB .标准库调用
mmap()
并要求256 MiB .内核找到256 MiB的未使用地址空间,记下现在使用的地址空间,然后返回 .
标准库知道
mmap()
的结果总是用零填充(或者一旦它实际上得到一些RAM),所以它不会触及内存,因此没有页面错误,并且RAM永远不会被赋予你的过程 .您的进程最终退出,内核不需要回收RAM,因为它从未首先分配过 .
如果使用
memset()
将页面归零,memset()
将触发页面错误,导致RAM被分配,然后将其归零,即使它已经填充了零 . 这是一项巨大的额外工作,并解释了为什么calloc()
比malloc()
和memset()
更快 . 如果最终仍然使用内存,calloc()
仍然比malloc()
和memset()
更快,但差别并不是那么荒谬 .这并不总是有效
并非所有系统都有分页虚拟内存,因此并非所有系统都可以使用这些优化 . 这适用于非常古老的处理器,如80286以及嵌入式处理器,这些处理器对于复杂的内存管理单元来说太小了 .
这也不总是适用于较小的分配 . 使用较小的分配,
calloc()
从共享池获取内存而不是直接进入内核 . 通常,共享池可能有旧存储器中存储的垃圾数据,该存储器使用并通过free()
释放,因此calloc()
可以占用该存储器并调用memset()
清除它 . 常见的实现将跟踪共享池的哪些部分是原始的并且仍然用零填充,但并非所有实现都这样做 .消除一些错误的答案
根据操作系统的不同,内核在空闲时间内可能会或可能不会将内存归零,以防您以后需要获取一些归零内存 . Linux并没有提前将内存零和Dragonfly BSD recently also removed this feature from their kernel . 但是,其他一些内核会提前做零内存 . 无论如何,空闲的归零页面还不足以解释大的性能差异 .
calloc()
函数没有使用memset()
的某些特殊的内存对齐版本,但这无论如何都不会更快 . 现代处理器的大多数memset()
实现看起来像这样:所以你可以看到,
memset()
非常快,你不会真正为大块内存获得更好的东西 .事实上,
memset()
将已归零的内存归零,这意味着内存被归零两次,但这只能解释2倍的性能差异 . 这里的性能差异要大得多(我在malloc()+memset()
和calloc()
之间测量了我的系统超过三个数量级) .党的把戏
编写一个分配内存的程序,直到
malloc()
或calloc()
返回NULL,而不是循环10次 .如果你添加
memset()
会怎么样?因为在许多系统上,在备用处理时间内,操作系统会将自由内存设置为零,并将其标记为安全
calloc()
,因此当您调用calloc()
时,它可能已经有空闲的零内存给您 .在某些模式的某些平台上,malloc在返回之前将内存初始化为某些通常为非零的值,因此第二个版本可以很好地初始化内存两次