为什么calloc存在

为什么calloc存在?

读了一篇英文博客,觉得写的比较好,于是翻译成该文。——原文:Why does calloc exi

进行C语言编写程序时,有两种标准方法可以在堆上分配一些新内存:

void* buffer1 = malloc(size);
void* buffer2 = calloc(count, size);

malloc 分配一个给定字节数的未初始化内存,buffer1可以包含任何东西。同为public API,calloc 有两方面的不同:

  1. 它需要两个而不是一个参数
  2. 它返回预初始化全为0的内存

所以大量的教科书和网页声称calloc 调用等价于,先调用malloc ,然后再调用memset去填充0到申请的内存。

/* 等价于calloc()调用,或者是别的? */
void* buffer3 = malloc(count * size);
memset(buffer3, 0, count * size);

因此,为什么calloc还会存在呢?如果它等同于上面的两行。c库并不以过度地专注于提供方便的使用而出名。

事实证明,答案并没有我想象的那么广为人知。如果我现在是Julia Evans,我会制作一部简短的小漫画😊。但是我不是,因此这里是大量的文本。

实际上,调用calloc与调用malloc + memset之间有两个区别。

区别#1:电脑不擅长算术

calloc乘以count * size 时,它会检查溢出,如果返回返回的值无法放入32位或64位整数(无论你是哪一个平台),则会出错。这很好。如果你像我上面那样简单地做乘法运算,只写count * size ,那么如果值太大,那么乘法运算将无声地进行,malloc将很高兴地分配一个比我们预期更小的缓冲区。那就糟糕了。“这部分代码认为缓冲区有这么长,但是另一部代码认为它没有那么长”,这是每年一百亿条安全警告的开端。(案例

我写了一个小程序来演示。它尝试分配一个容纳263 x 263 = 2126 字节,先使用malloc,然后使用calloc

#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <stdint.h>

int main(int argc, char** argv)
{
    size_t huge = INTPTR_MAX;

    void* buf = malloc(huge * huge);
    if (!buf) perror("malloc failed");
    printf("malloc(huge * huge) returned: %p\n", buf);
    free(buf);

    buf = calloc(huge, huge);
    if (!buf) perror("calloc failed");
    printf("calloc(huge, huge) returned: %p\n", buf);
    free(buf);
}

在我电脑上,我得到:

~$ gcc calloc-overflow-demo.c -o calloc-overflow-demo
~$ ./calloc-overflow-demo
malloc(huge * huge) returned: 0x5583a2163260
calloc failed: Cannot allocate memory
calloc(huge, huge) returned: (nil)

所以,显然malloc成功分配了一个73786976294838206464EiB(1EiB=1024PiB,1PB=1024TiB,1TB=1024GiB)内存?我相信这样会很好。这是关于calloc的一个好处:它有助于避免可怕的安全漏洞。

但是。它没有那么兴奋。(我的意思是,诚实地说:如果我们真的关心安全性,我们就不会用c语言编写)它只在特定情况下有用,即通过将两个数字相乘来决定分配多少内存。这是一个很重要的情况,但还要很多其它的情况,我们要么根本不做任何算术运算,要吗我们做一些更复杂的算术运算,需要一个更一般的解。

另外如果我们愿意,我们当然可以为malloc编写自己的包装器,使用两个参数并将它们相乘,并进行溢出检查。

事实上,如果我们想要一个溢出安全的realloc,或者我们不希望内存初始化为0,那么…我们还是要这么做。但是这并不能证明calloc的存在是合理的。

另一个区别是非常非常重要的。

区别#2:谎言,该死的谎言,还要虚拟内存

下面是一个小的基准测试程序,用于测量calloc一个1GiB的缓冲区,与malloc+memset一个1GiB的缓冲区所花费的时间。(确保编译时不进行优化,因为现代编译器足够聪明,知道free(calloc(…)))是一个无操作,并对其进行优化。

#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <string.h>

const int LOOPS = 100;

float now()
{
    struct timespec t;
    if (clock_gettime(CLOCK_MONOTONIC, &t) < 0) {
        perror("clock_gettime");
        exit(1);
    }
    return t.tv_sec + (t.tv_nsec / 1e9);
}

int main(int argc, char** argv)
{
    float start = now();
    for (int i = 0; i < LOOPS; ++i) {
        free(calloc(1, 1 << 30));
    }
    float stop = now();
    printf("calloc+free 1 GiB: %0.2f ms\n", (stop - start) / LOOPS * 1000);

    start = now();
    for (int i = 0; i < LOOPS; ++i) {
        void* buf = malloc(1 << 30);
        memset(buf, 0, 1 << 30);
        free(buf);
    }
    stop = now();
    printf("malloc+memset+free 1 GiB: %0.2f ms\n", (stop - start) / LOOPS * 1000);
}

在我的pc上执行得到:

~$ gcc calloc-1GiB-demo.c -o calloc-1GiB-demo
~$ ./calloc-1GiB-demo 
calloc+free 1 GiB: 3.75 ms
malloc+memset+free 1 GiB: 393.12 ms

calloc超过100倍的快。我们的教科书和手册说它们是相等的,这到底是怎么回事?

答案当然是calloc在作弊。

对于小的分配,calloc会直接调用malloc+memset,所以速度是一样的。

但是对于较大的分配,大多数内存分配器会出于各种原因向操作系统发出特殊请求,以便仅为此分配获取更多内存。(这里的“小”和“大”是由内存分配器中的一些启发式方法决定的,对于glibc“大”是任何东西>128KiB,至少在其默认配置中)。

当操作系统将内存分配给一个进程时,它总是首先将其清零,因为否则我们的进程将能够窥视最后一个使用它的进程在该内存中留下的任何碎屑,其中可能包括加密密钥或令人尴尬的同人小说。所以这是calloc作弊的第一种方式:当你调用malloc来分配一个大的缓冲区时,那么内存可能来自操作操作系统并且已经被清零,所以没必要调用memset。但你不确定!内存分配器是非常难以理解。所以你必须每次都调用memset以防万一。但是calloc位于在内存分配器中,因此它知道它返回的内存是来自操作系统。如果是的话,则会跳过调用memset。这就是为什么calloc必须内置到标准库中,并且您无法有效地将其伪造为malloc之上的一层。

但这只能解释部分加速:memset+malloc实际上清除了2次内存,而calloc清除了1次内存,因此我们可以预期calloc最多快2倍。而不是…快了100倍。什么鬼?

原来内核也在作弊!当我们要求它提供 1 GiB 的内存时,它实际上并没有出去找到那么多 RAM 并向其写入零,然后将其交给我们的进程。相反,它使用虚拟内存来伪造:它只占用了一页4KiB的内存,而且已经全都是0(它是为了这个目的而存在的),并将它的1GiB / 4KiB = 262144个写拷贝映射到进程的地址空间 。因此,当我们第一次真正写入这262144页面的每一个时,此时内核必须去找到一个真实的RAM页面,给它写零,然后快速交换它来代替以前的“虚拟”页面。但这是在一页一页的基础上缓慢地发生的。

因此,在现实生活中,差异不会像我们上面的基准测试中看起来那么明显——部分诀窍是 calloc 正在将一些将页面归零的成本转移到以后,而 malloc+memset 正在预先支付全价。但是,至少我们没有将它们归零两次。至少我们没有预先破坏缓存层次结构——如果我们延迟归零,直到我们无论如何都要写入页面,那么这意味着两次写入同时发生,所以我们只需要支付一组 TLB / L2 缓存 / 等等。而且,最重要的是,我们可能永远无法写入所有这些页面,在这种情况下,calloc + 内核的鬼鬼祟祟的诡计是一个巨大的胜利!

当然,calloc 使用的确定优化集将因您的环境而异。过去流行的一个巧妙技巧是,当系统空闲时,内核会四处走动并推测性地将页面清零,以便它们在需要时保持新鲜并准备就绪——但这在当前系统上已经过时了。没有虚拟内存的微型嵌入式系统显然不会使用虚拟内存技巧。但总的来说,calloc永远不会比malloc+memset差,在主流系统上它可以做得更好。

一个现实生活中的例子是最近的一个关于requests的Bug,其中通过具有较大接收块大小的 HTTPS 进行流式下载会占用 100% 的 CPU。事实证明,问题在于,当用户说他们愿意一次处理多达 100 MiB 的块时,请求将其传递给 pyopenssl,然后 pyopenssl 使用cffi.new分配一个 100 MiB 的缓冲区来保存传入的数据。但大多数时候,实际上并没有 100 MiB 准备好在连接上读取;因此,Pyopenssl 将分配这个大的缓冲区,但随后只会使用其中的一小部分。除了… 事实证明,cffi.new 通过执行 malloc+memset 来模拟 calloc,因此他们无论如何都要付费分配和归零整个缓冲区。如果cffi.new使用calloc代替,那么这个错误就不会发生!希望他们能尽快解决这个问题。

或者这由另一个在numpy中出现的示例:假设你想做一个有着16384行和16384列的大单位矩阵。这需要分配一个缓冲区来保存16384*16384个浮点数,每个浮点数是8个字节,因此总共由2GiB的内存。

在我们创建矩阵之前,我们的进程使用了92MiB的内存。

In [1]: import numpy as np

In [2]: import resource

In [3]: # 这种获取内存使用情况的方法可能只适用于linux

In [4]: def mebibytes_used():
   ...:     return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
   ...:

In [5]: mebibytes_used()
Out[5]: 92.94140625

然后我们分配一个2GiB密度的矩阵:

In [6]: big_identity_matrix = np.eye(16384)

In [7]: big_identity_matrix
Out[7]:
array([[1., 0., 0., ..., 0., 0., 0.],
       [0., 1., 0., ..., 0., 0., 0.],
       [0., 0., 1., ..., 0., 0., 0.],
       ...,
       [0., 0., 0., ..., 1., 0., 0.],
       [0., 0., 0., ..., 0., 1., 0.],
       [0., 0., 0., ..., 0., 0., 1.]])

In [8]: big_identity_matrix.shape
Out[8]: (16384, 16384)

我们的进程现在使用了多少内存?答案可能会让你大吃一惊(现在就学习这个奇怪的技巧来精简你的流程):

In [9]: mebibytes_used()
Out[9]: 156.87890625

Numpy 使用 calloc 分配了数组,然后在对角线中写入了 1s…但大多数数组仍然是零,所以它实际上并没有占用任何内存,我们的 2 GiB 矩阵准备了约60 MiB 的实际 RAM。当然还有其他方法可以完成同样的事情,比如使用真正的稀疏矩阵库,但这不是重点。关键是,如果你做这样的事情,calloc会神奇地让一切变得更有效率——而且它总是至少和替代方案一样快。

所以基本上,calloc 之所以存在,是因为它让内存分配器和内核参与一个偷偷摸摸的阴谋,让你的代码更快,使用更少的内存。你应该使用它!不要使用 malloc+memset

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值