11

C语言程序开发中的内存分配究竟是如何进行的?为什么calloc() 函数的效率比 malloc()+...

 3 years ago
source link: https://blog.popkx.com/c%E8%AF%AD%E8%A8%80%E7%A8%8B%E5%BA%8F%E5%BC%80%E5%8F%91%E4%B8%AD%E7%9A%84%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E7%A9%B6%E7%AB%9F%E6%98%AF%E5%A6%82%E4%BD%95%E8%BF%9B%E8%A1%8C%E7%9A%84-%E4%B8%BA/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在C语言程序开发中,提到动态内存分配时,基本上每个程序员都明白 calloc() 和 malloc() 库函数的区别——calloc() 函数不仅分配内存,还会将分配后的内存清零,而 malloc() 函数则对分配好的内存不做任何操作。

calloc() 函数的效率比 malloc()+memset() 函数更高?

很多C语言程序员常把 calloc() 函数看作是 malloc() + memset() 函数的组合。不过,今天我在一个很偶然的测试中发现 calloc() 函数和 malloc() + memset() 组合函数的效率差异还是很大的。请看:

#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++;
        }
    return 0;
}

这段C语言代码调用了 calloc() 函数分配了一段内存,并且重复 10 次,编译并执行之(time 命令可以查看C语言程序运行消耗的时间),得到如下结果:

# gcc t.c
# time ./a.out  
**real 0m0.287s**  
user 0m0.095s  
sys 0m0.192s 

现在将 calloc() 函数改为 malloc() + memset() 函数,修改后的C语言代码如下,请看:

#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++;
        }.
        return 0;
}

编译并执行这段C语言代码,同样使用 time 命令查看程序运行消耗时间,得到如下结果,请看:

# gcc t.c
# time ./a.out   
**real 0m2.693s**  
user 0m0.973s  
sys 0m1.721s 

应该清楚,这两段C语言代码的工作是一致的,都是分配一段长度为 BLOCK_SIZE 的内存并且清零,但是二者消耗的时间却相差非常大,这就有一个值得深思的问题:calloc() 函数做了相同的工作,但是效率却高得多,这是怎么回事呢?

弄清楚这一点,对于我们以后开发更高效率的C语言程序肯定有所帮助。

在展开讨论之前,应该明白的是以后如果希望申请一段内容为 0 的内存,则应该使用效率更高的 calloc() 函数,而不是 malloc() + memset() 函数的组合。

因为 calloc() 函数在内部实现中,会自行判断分配后的内存是否需要清零,如果某段分配好的内存原本就是零,那么清零动作就免去了。而 malloc() + memset() 函数的组合则全额做了“分配+清零”的动作,效率自然是有所差异的。

一般来说,C语言程序员应该明白四大点:程序,标准库,内核以及页表

像 malloc() 和 calloc() 这样的内存分配函数主要用于分配数百字 KB 以下的内存分配,这样的分配一般是直接从内存池(memory pool)中分配的。当内存池被用完后,或者某段C语言代码一次性请求分配的内存超过剩余内存池容量时,malloc() 和 calloc() 将直接向内核请求内存。

内核管理每个进程的实际 RAM,并确保不同进程不会干扰彼此的内存,这就是所谓的操作系统“内存保护”机制。有了这样的机制,一个进程的崩溃不会导致其他进程跟着崩溃,系统的稳定性会得到保障。

因此,在操作系统内核的管理下,当某段C语言代码需要使用一段内存时,它不能直接使用物理内存,而只能通过 mmap() 以及 sbrk() 等系统调用向内核申请,由内核修改页表为每个进程提供 RAM。

页表将内存地址映射到实际的物理 RAM,在 32 位系统上,进程地址(0x00000000到0xffffffff)不是实际的内存地址,而是虚拟内存地址,处理器将这些地址分为 4KiB 个页,通过页表,可以将每个内存页对应到不同的物理 RAM 上。

一些C语言程序员认为,calloc() 等内存分配函数是这样工作的

C语言程序调用 calloc() 申请 256KB 内存,于是标准库调用系统调用 mmap() 函数向内核申请,内核找到 256KB 未被使用的 RAM,并通过修改页表的方式将其提供给C语言程序,接着标准库调用 memset() 函数将申请到的内存清零,然后从 calloc() 函数将这段内存返回。

之后,当这段C语言程序退出后,内核会回收分配给它的内存,以便给其他进程使用。

上述过程在理论上是可行的,但是实际上并不会这样。因为内存总是有限的,内核分配给我们的C语言程序使用的内存可能是之前其他进程使用过的,如果这段内存里有密码,密钥,等其他敏感信息呢?

为了避免出现上述安全隐患,内核总是在将内存交给进程之前将其清理掉。当然了,我们也可以自己调用清零函数将使用过的内存清零,但是不管如何,mmap() 函数保证其返回的新内存是清零后的总是安全的选择。

有一些C语言程序可能很早就向内核申请了一段内存,但是却不会立刻使用它,甚至可能根本不会使用它。因此在设计操作系统内核时,为了效率的最大化,可能内核在收到内存分配请求时,根本不修改页表,也不向我们的程序提供任何实际的 RAM。

内核可能仅会将一些地址空间标记给我们的程序使用,但是却不做实际的分配工作。这样就避免了“分配了内存,却没被使用”带来的不必要的开销了。当然,一旦C语言程序需要读写这些地址空间,就会触发一个缺页异常,内核再将 RAN 真正的分配给这些地址,并恢复程序运行。

简而言之,内核为了避免不必要的开销,实际的内存分配只有在确保真的有C语言代码使用时(有写入动作时)才会进行。

也有些C语言程序分配内存后,可能(不做任何修改)直接就去读这些内存,这时,内核甚至会让这些C语言程序申请的内存指向同一个 4KiB 页表,因为 mmap() 返回的零填充内存都一样。如果某个C语言程序尝试对申请到的内存执行写入操作,那么将触发另一种缺页异常,内核将为该C语言程序分配一个新的内存页使用,该内存页不与其他任何进程共享。

在C语言程序开发中,一次内存分配的实际过程是这样的

C语言程序调用 calloc() 申请 256KB 内存,于是标准库调用系统调用 mmap() 函数向内核申请,内核找到 256KB 未被使用的地址空间,记下该地址空闲现在用于什么,然后返回。

现在标准库知道 mmap() 返回的结果总是用零填充,所以它不需要写入内存,因此不会出现缺页异常,内核不必直接实际分配内存。

最后C语言程序退出,内核不需要回收内存,因为内核根本就没有分配过内存。这样的效率显然很高。

如果使用 memset() 将页面清零,那么 memset() 的写入动作将触发缺页异常,内核将不得不执行分配动作,并执行写入零动作。这是一项巨大的工作,这也解释了为什么 calloc() 比 malloc() + memset() 快的原因。

现在知道原理了,我们就可以预言:如果最后使用了库函数分配的内存,那么 calloc() 函数可能仍然比 malloc()+memset() 快,但是二者之前的区别将不会再那么大。

并非所有的操作系统内核都具有分页虚拟内存,因此并非在所有平台上编译C语言代码都会得到相同的结果。calloc() 函数可能并不从内核申请内存,而是从共享内存池里申请,而共享内存池中可能存储了上一次被使用时残留的垃圾数据,calloc() 可以获取到这些内存,并且调用 memset() 将其清零。

不同的操作系统管理内存很可能是不一样的,有些操作系统内核会在空闲时将内存归零,已备以后需要获得归零内存时使用,而有些则不会,例如 Linux 就不会提前将内存清零。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK