探索Linux操作系统:利用malloc提升程序性能

开发 前端
在 C 语言的标准库中,malloc 函数如同一位神秘的工匠,为程序提供着动态内存分配的关键服务。它的定义简洁而有力:void *malloc(size_t size); ,这个函数接受一个参数 size,用于指定需要分配的内存字节数。

在 Linux 操作系统的广袤天地里,内存管理宛如一场精密的舞蹈,而 malloc 函数无疑是其中最为关键的舞者。当我们编写 C 或 C++ 程序时,常常会遇到需要在运行时动态分配内存的情况,比如创建一个大小不确定的数组,或者构建复杂的数据结构。

此时,malloc 就像一位神奇的工匠,为我们打造出所需的内存空间。它允许程序在运行过程中根据实际需求灵活地获取内存,大大增强了程序的适应性和灵活性。但你是否曾好奇,malloc 究竟是如何在幕后运作,精准地分配出我们所需的内存呢?接下来,就让我们一同揭开 malloc 的神秘面纱,深入探寻其内存分配的奥秘。

一、Malloc基础入门

1.1malloc 函数简介

在 C 语言的标准库中,malloc 函数如同一位神秘的工匠,为程序提供着动态内存分配的关键服务。它的定义简洁而有力:void *malloc(size_t size); ,这个函数接受一个参数 size,用于指定需要分配的内存字节数。其返回值是一个指向所分配内存起始地址的指针 ,类型为 void*,这意味着它可以被转换为任何类型的指针,以适应不同的数据存储需求。

malloc 函数在动态内存分配领域占据着核心地位。当我们在编写程序时,常常会遇到一些场景,比如需要处理用户输入的数据,但在程序编写阶段并不知道数据的具体大小;或者构建动态的数据结构,如链表、树等,这些结构的节点数量会随着程序的运行而动态变化。在这些情况下,静态内存分配就显得力不从心,而 malloc 函数则能够在程序运行时,根据实际需求灵活地分配内存,使得程序能够高效地处理各种复杂的情况。

1.2与操作系统的关系

虽然 malloc 函数在 C 库中扮演着重要角色,但它并非直接与操作系统的底层内存管理机制打交道。在 Linux 系统中,malloc 函数是通过 glibc(GNU C Library)来实现的,glibc 作为一个功能强大的 C 库,为程序员提供了丰富的函数和工具,malloc 便是其中之一。

当我们在程序中调用 malloc 函数时,它会首先在 glibc 维护的内存池中查找是否有足够的空闲内存来满足请求。如果内存池中有足够的空闲内存,malloc 函数会直接从内存池中分配内存,并返回相应的指针。这样做的好处是可以减少系统调用的开销,提高内存分配的效率。因为系统调用涉及到用户态和内核态的切换,这种切换会带来一定的性能损耗。

然而,如果内存池中的空闲内存不足以满足请求,malloc 函数就会借助系统调用与操作系统进行交互。在 Linux 系统中,主要涉及到两个系统调用:brk和mmap 。brk系统调用通过移动程序数据段的结束地址(即 “堆顶” 指针)来增加堆的大小,从而分配新的内存。而mmap系统调用则是通过在文件映射区域分配一块内存来满足请求。通常情况下,当请求的内存大小小于一定阈值(在大多数系统中,这个阈值通常为 128KB)时,malloc 函数会优先使用brk系统调用来分配内存;当请求的内存大小大于这个阈值时,则会使用mmap系统调用。通过这种方式,malloc 函数能够根据不同的内存需求,选择最合适的方式与操作系统进行交互,实现高效的内存分配。

二、Malloc函数的实现原理

2.1空闲链表机制

在malloc函数背后,有着依靠空闲链表来管理内存的一套机制。当我们调用malloc函数时,它会沿着空闲链表去查找满足用户请求大小的内存块。比如说,链表上有多个不同大小的空闲内存块,它就会依次遍历这些块来找到合适的那一个。

找到合适的内存块后,如果这个内存块比用户请求的大小要大,那么就会按需将其分割成两部分,一部分的大小刚好与用户请求的大小相等,这部分就会分配给用户使用,而剩下的那部分则会被放回空闲链表中,等待后续其他的内存分配请求再进行分配。例如,空闲链表中有一个 50 字节的空闲块,而用户请求分配 20 字节的内存,这时malloc就会把这个 50 字节的块分成 20 字节(分配给用户)和 30 字节(放回空闲链表)两块。

而当我们使用free函数释放内存时,相应被释放的内存块又会被重新连接到空闲链上。这样,整个空闲链表就处于一个动态变化的过程,不断地有内存块被分配出去,也不断地有释放的内存块回归链表,以实现内存的循环利用,避免浪费。不过,随着程序不断地分配和释放内存,空闲链有可能会被切成很多的小内存片段,要是后续用户申请一个较大的内存片段时,空闲链上可能暂时没有可以满足要求的片段了,这时malloc函数可能就需要进行一些整理操作,比如对这些小的空闲块尝试合并等,以便能满足较大内存请求的情况。

虚拟内存地址和物理内存地址

为了简单,现代操作系统在处理物理内存地址时,普遍采用虚拟内存地址技术。即在汇编程序层面,当涉及内存地址时,都是使用的虚拟内存地址。采用这种技术时,每个进程仿佛自己独享一片2N字节的内存,其中N是机器位数。例如在64位CPU和64位操作系统下每个进程的虚拟地址空间为264Byte。

这种虚拟地址空间的作用主要是简化程序的编写及方便操作系统对进程间内存的隔离管理,真实中的进程不太可能如此大的空间,实际能用到的空间大小取决于物理内存的大小。由于在机器语言层面都是采用虚拟地址,当实际的机器码程序涉及到内存操作时,需要根据当前进程运行的实际上下文将虚拟地址转化为物理内存地址,才能实现对内存数据的操作。这个转换一般由一个叫MMU的硬件完成。

页与地址构成

在现代操作系统中,不论是虚拟内存还是物理内存,都不是以字节为单位进行管理的,而是以页为单位。一个内存页是一段固定大小的连续的连续内存地址的总称,具体到Linux中,典型的内存页大小为4096 Byte。所以内存地址可以分为页号和页内偏移量。下面以64位机器,4G物理内存,4K页大小为例,虚拟内存地址和物理内存地址的组成如下:

图片图片

上面是虚拟内存地址,下面是物理内存地址。由于页大小都是4k,所以页内偏移都是用低12位表示,而剩下的高地址表示页号 MMU映射单位并不是字节,而是页,这个映射通过差一个常驻内存的数据结构页表来实现。现在计算机具体的内存地址映射比较复杂,为了加快速度会引入一系列缓存和优化,例如TLB等机制,下面给出一个经过简化的内存地址翻译示意图:

图片图片

内存页与磁盘页

我们知道一般将内存看做磁盘的缓存,有时MMU在工作时,会发现页表表名某个内存页不在物理内存页不在物理内存中,此时会触发一个缺页异常,此时系统会到磁盘中相应的地方将磁盘页载入到内存中,然后重新执行由于缺页而失败的机器指令。关于这部分,因为可以看做对malloc实现是透明的,所以不再详述。

真实地址翻译流程:

图片图片

Linux进程级内存管理

内存排布:明白了虚拟内存和物理内存的关系及相关的映射机制,下面看一下具体在一个进程内是如何排布内存的。以Linux 64位系统为例。理论上,64bit内存地址空间为0x0000000000000000-0xFFFFFFFFFFFFFFF,这是个相当庞大的空间,Linux实际上只用了其中一小部分。具体分布如图所示:

图片图片

对用户来说主要关心的是User Space。将User Space放大后,可以看到里面主要分成如下几段:

  • Code:这是整个用户空间的最低地址部分,存放的是指令(也就是程序所编译成的可执行机器码) Data:这里存放的是初始化过的全局变量
  • BSS:这里存放的是未初始化的全局变量
  • Heap:堆,这是我们本文主要关注的地方,堆自底向上由低地址向高地址增长
  • Mapping Area:这里是与mmap系统调用相关区域。大多数实际的malloc实现会考虑通过mmap分配较大块的内存空间,本文不考虑这种情况,这个区域由高地址像低地址增长Stack:栈区域,自高地址像低地址增长 。
  • Heap内存模型:一般来说,malloc所申请的内存主要从Heap区域分配,来看看Heap的结构是怎样的。

图片图片

Linux维护一个break指针,这个指针执行堆空间的某个地址,从堆开始到break之间的地址空间为映射好的,可以供进程访问,而从break往上,是未映射的地址空间,如果访问这段空间则程序会报错。

2.2在操作系统中的实现

以常见的操作系统为例,malloc函数需要通过系统调用来从内核申请内存,像brk(用于堆内存)或者mmap(用于内存映射)就是常用的手段。

对于brk系统调用,它主要的作用是调整堆顶的位置,使得堆内存可以从低地址向高地址增长,以此来扩大进程在运行时的堆大小。一般来说,如果分配的内存小于 128K 时,就常常会使用brk调用来获得虚拟内存。比如在一些小型的数据结构动态分配场景中,brk就能很好地满足需求。当使用brk分配了一段新的虚拟内存区域后,要注意这并不会立即分配物理内存哦,实际的物理内存分配通常是在访问新分配的虚拟内存区域时,如果发生了缺页异常,操作系统才会开始分配并映射相应的物理内存页面。

而mmap系统调用则是在进程的虚拟地址空间中寻找一块空闲的虚拟内存,从而获得一块可以操作的堆内存,当需要分配较大块的内存(通常大于 128K 时),就会更多地借助mmap来完成申请操作。一旦通过mmap建立了内存映射关系,进程就可以通过指针的方式来读写这块内存了,并且系统会自动将脏页(被修改的页)回写到相应的磁盘文件上。

在内存分配程序初始化时,要完成诸如将分配程序标识为已经初始化,找到系统中最后一个有效内存地址,然后建立起指向管理的内存的指针等操作,这些都是为了后续能更好地追踪要分配和回收哪些内存。在整个过程中,会不断地去记录内存的分配和回收情况,比如哪些内存块已经分配出去被使用了,哪些又被释放回到了可分配的状态等,通过这些精细的管理,才能让内存资源在程序运行过程中得到合理的调配。

⑴brk与sbrk

由上文知道,要增加一个进程实际上的可用堆大小,就需要将break指针向高地址移动。Linux通过brk和sbrk系统调用操作break指针。两个系统调用的原型如下:

int brk(void *addr);
void *sbrk(inptr_t increment);

brk将break指针直接设置为某个地址,而sbrk将break从当前位置移动increment所指定的增量。brk在执行成功时返回0,否则返回-1并设置为errno为ENOMEM,sbrk成功时返回break移动之前所指向的地址,否则返回(void*)-1;

⑵资源限制和rlimirt

系统为每一个进程所分配的资源不是无限的,包括可映射的空间,因此每个进程有一个rlimit表示当前进程可用的资源上限,这个限制可以通过getrlimit系统调用得到,下面代码获取当前进程虚拟内存空间的rlimit 其中rlimt是一个结构体

struct rlimit
{
    rlimt_t rlim_cur;
    rlim_t rlim_max;
};

每种资源有硬限制和软限制,并且可以通过setrlimit对rlimit进行有条件限制作为软限制的上限,非特权进程只能设置软限制,且不能超过硬限制

2.3ptmalloc 工作原理

在涉及ptmalloc模块的情况下,先来了解一下它的软件架构。ptmalloc中有几个关键概念,比如malloc_state、malloc_chunk等。

malloc_state结构用于统一管理内存分配相关的诸多信息,它里面包含了像fastbinsY(这是用于存储 16 - 160 字节chunk的空闲链表)、top(代表着顶部的内存块,也就是当其他空闲链表中没有匹配的chunk分配给用户程序时,会从这里裁剪出可用的chunk分配给用户)、bins(又可细分为unsortedbins、smallbins、largebins,unsortedbins是chunk缓存区,用于存储从fastbins合并的空闲chunk;smallbins用于存储 32 - 1024 字节的chunk;largebins则用于存储大于 1024 字节大小的空闲chunk)以及binmap(可用bins位图,方便快速查找可用的bin)等重要成员。

而malloc_chunk则是以其为单位来进行内存的申请和释放操作。每个malloc_chunk结构体中有记录前一个chunk大小的mchunk_prev_size成员、表示当前chunk大小的mchunk_size成员,还有像fd(链表后驱指针)、bk(链表前驱指针)等指针成员(当chunk处于空闲状态时,会借助内存区域前 16 个字节作为链表指针,将chunk插入到相应的空闲链表中)。

在内存管理方面,不同大小的内存块有着不同的管理方式。对于小于 160 字节的内存申请,malloc函数会从fastbins空闲链表中查找匹配的chunk进行分配;对于 32 - 1024 字节的内存请求,就会去smallbins中寻找合适的空闲chunk;大于 1024 字节的则在largebins里查找。

当用户申请内存时,malloc会按照对应的大小范围去相应的链表中寻找可用的chunk,找到就分配给用户使用。而当用户释放内存时,释放的chunk会依据其大小等情况,被合理地放回fastbins、bins等相应的链表中,比如从smallbins中释放的chunk可能会先进入unsortedbins缓存,后续再根据具体情况进行合并或者重新分配等操作,以此来维持整个内存分配和回收体系的高效、有序运行。

三、内存分配方式

3.1brk 系统调用分配内存

brk 原理剖析:在 Linux 系统中,进程的内存空间布局包含多个段,其中堆(heap)是用于动态内存分配的重要区域。brk 系统调用的核心原理是通过移动堆顶指针(_edata)来扩大堆的空间。当程序调用 brk 系统调用并传入一个新的地址时,如果这个新地址大于当前堆顶指针的位置,内核会尝试将堆顶指针移动到新的地址,从而扩大堆的范围。

在这个过程中,有一个关键的概念需要理解,那就是虚拟内存与物理内存的映射关系。当 brk 系统调用扩大堆空间时,实际上只是分配了虚拟内存,并没有立即分配物理内存。这种映射是延迟的,直到程序第一次访问新分配的虚拟内存区域时,才会触发缺页中断(page fault) 。此时,操作系统会负责分配物理内存,并建立虚拟内存与物理内存之间的映射关系。这一机制有效地提高了内存使用效率,避免了过早分配物理内存造成的浪费。

小内存分配示例:假设我们在程序中调用malloc函数申请一块小于 128KB 的内存,例如申请 30KB 的内存。在这种情况下,malloc函数通常会调用 brk 系统调用来完成内存分配。

当程序执行到malloc(30 * 1024)时,malloc函数会向内核发起 brk 系统调用。内核接收到这个调用后,会检查当前堆顶指针的位置,假设当前堆顶指针为_edata,其值为 0x10000000。内核会将_edata指针向高地址方向移动 30KB,即移动到 0x10007800(30 * 1024 = 30720,十六进制表示为 0x7800)。此时,从 0x10000000 到 0x10007800 这一段虚拟内存空间就被分配给了程序。

需要注意的是,虽然虚拟内存已经分配,但在程序第一次访问这部分内存之前,并没有实际的物理内存与之对应。例如,如果程序执行*(int*)0x10000000 = 10; ,这是对新分配内存的第一次访问,此时会触发缺页中断。操作系统捕获到这个缺页中断后,会为该虚拟内存页分配物理内存页,并建立两者之间的映射关系,然后程序才能继续正常执行。

这种分配方式在处理小内存分配时具有一定的优势,它相对简单高效,减少了系统调用的开销。但也存在一些潜在的问题,例如随着内存的频繁分配和释放,可能会导致堆内存中出现大量的碎片,降低内存的利用率。

3.2mmap 系统调用分配内存

mmap 原理阐述:mmap 系统调用是另一种重要的内存分配方式,它与 brk 系统调用有着本质的区别。mmap 系统调用主要用于在文件映射区域分配内存,它通过在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存来满足内存分配请求。

具体来说,mmap 系统调用可以创建一个新的虚拟内存区域,并将一个文件或设备的内容映射到这个区域中。当用于内存分配时,通常会使用私有匿名映射(private anonymous mapping) ,即创建一个匿名的虚拟内存区域,这个区域与任何文件都没有关联,并且对该区域的写入操作不会影响到其他进程。在这种情况下,mmap 系统调用会在文件映射区域中找到一块合适的空闲虚拟内存,并将其分配给调用者。

与 brk 系统调用不同,mmap 系统调用分配的内存是独立的,它不会与堆内存连续,并且在释放时可以单独释放,不会受到其他内存块的影响。这使得 mmap 在处理大内存分配和需要频繁分配和释放内存的场景中具有明显的优势。

大内存分配示例:当程序调用malloc函数申请一块大于 128KB 的内存时,例如申请 200KB 的内存,malloc函数通常会使用 mmap 系统调用来完成分配。

当程序执行到malloc(200 * 1024)时,malloc函数会向内核发起 mmap 系统调用。内核接收到这个调用后,会在文件映射区域中查找一块大小为 200KB 的空闲虚拟内存。假设找到的空闲虚拟内存区域起始地址为 0x20000000,内核会将这块虚拟内存分配给程序。

与 brk 系统调用类似,此时分配的只是虚拟内存,在程序第一次访问这部分内存时才会触发缺页中断,操作系统会为其分配物理内存并建立映射关系。但与 brk 不同的是,mmap 分配的内存具有更好的独立性和可管理性。例如,当程序不再需要这块内存时,调用free函数会直接通过 munmap 系统调用将这块内存释放回操作系统,不会影响到其他内存块的使用,有效地避免了内存碎片的问题。在一些对内存管理要求较高的场景,如数据库系统、大型游戏开发等,mmap 系统调用的这种优势能够显著提高系统的性能和稳定性。

四、Malloc内存分配策略

4.1阈值设定与分配决策

在 malloc 的源码世界里,有一个至关重要的阈值设定,它如同一个精密的开关,决定着内存分配的走向。这个阈值通常被设定为 128KB ,成为了 brk 和 mmap 两种内存分配方式的分水岭。当我们在程序中调用 malloc 函数申请内存时,它会首先对申请的内存大小进行判断。

如果申请的内存大小小于 128KB,malloc 函数会倾向于选择 brk 系统调用来分配内存。这是因为 brk 系统调用在处理小内存分配时具有独特的优势,它通过简单地移动堆顶指针来扩大堆空间,这种方式相对高效,减少了系统调用的开销,能够快速地满足小内存的分配需求。

而当申请的内存大小大于或等于 128KB 时,malloc 函数则会启用 mmap 系统调用。mmap 系统调用在处理大内存分配时表现出色,它能够在文件映射区域为程序分配一块独立的内存空间。这样做的好处是可以避免在堆区产生大量的内存碎片,因为 mmap 分配的内存块在释放时可以单独释放,不会受到其他内存块的影响,从而提高了内存的利用率和管理效率。

需要注意的是,这个 128KB 的阈值并非一成不变,不同的 glibc 版本可能会根据实际情况对其进行调整。例如,在某些特定的系统环境或应用场景下,为了优化内存分配的性能,glibc 版本可能会将阈值设定为其他值。因此,在深入研究 malloc 的内存分配机制时,我们需要关注具体的 glibc 版本及其配置,以准确把握内存分配的策略和行为。

4.2内存池与预分配机制

在 malloc 的内存分配策略中,内存池与预分配机制起着至关重要的作用,以默认内存管理器 Ptmalloc2 为例,当我们调用 malloc 函数申请内存时,它并非仅仅按照我们请求的字节数来分配内存,而是会预分配更大的空间作为内存池。

以主进程(主线程)下的内存分配为例,当我们申请 1 字节的内存时,Ptmalloc2 会预分配 132KB 的内存,这个预分配的内存区域被称为 Main Arena。在这个 132KB 的内存池中,Ptmalloc2 会根据后续的内存申请请求,从其中切割出合适大小的内存块分配给用户。当用户释放内存时,Ptmalloc2 并不会立即将内存归还给操作系统,而是会根据一些策略来判断是否释放。如果不释放,这块内存会被重新放回内存池中,供下次申请时使用。这种机制大大提高了内存分配的效率,减少了频繁向操作系统申请内存的开销。

在子线程下,内存分配的策略又有所不同。每个子线程会预先分配 HEAP_MAX_SIZE 大小的内存(64 位系统下为 64MB,32 位系统下为 1MB),这被称为 Thread Arena。并且,单个子线程的内存池数量最大可以达到 8 倍的 CPU 数。虽然这种预分配机制会占用一定的内存空间,但它在多线程环境下能够显著加快内存分配的速度,减少线程之间的竞争和等待时间,提高程序的整体性能。

内存池的预分配机制并非完美无缺。在多线程环境下,如果线程数量众多,每个线程都预分配了大量的内存,可能会导致系统内存资源的紧张。例如,在一个拥有 100 个线程的程序中,每个线程预分配 64MB 的内存,那么仅仅内存池就会占用 6GB 的内存空间。这可能会对系统的其他进程产生影响,甚至导致系统性能下降。因此,在实际应用中,我们需要根据程序的特点和运行环境,合理地调整内存池的预分配策略,以平衡内存使用和性能之间的关系。

五、Free释放内存机制

5.1brk 方式申请内存的释放

当我们使用 free 函数释放通过 brk 方式申请的内存时,内存并不会立即归还给操作系统。这是因为 brk 系统调用分配的内存是在堆空间中,free 函数将内存释放后,这块内存会被缓存在 malloc 的内存池中。这样做的目的是为了提高内存的复用效率,当程序后续再次申请内存时,如果内存池中有合适大小的空闲内存块,就可以直接从内存池中分配,而无需再次向操作系统发起 brk 系统调用,从而减少了系统调用的开销和时间成本。

内存池复用机制在一定程度上提高了内存分配的效率,但也带来了潜在的内存碎片问题。随着程序中频繁地进行内存分配和释放操作,堆内存中可能会出现许多不连续的空闲内存块,这些小块内存由于大小和位置的限制,可能无法满足后续较大内存分配的需求,从而导致内存利用率降低。例如,假设程序先申请了 10KB、20KB 和 30KB 的三块内存,然后释放了 10KB 和 20KB 的内存块。此时,堆内存中会出现两个空闲的内存块,但如果后续需要申请 40KB 的内存,由于这两个空闲内存块不连续,无法合并成一个足够大的内存块,就会导致虽然堆内存中有空闲空间,但仍然无法满足分配需求的情况,这就是内存碎片问题的体现。

5.2mmap 方式申请内存的释放

与 brk 方式不同,当使用 free 函数释放通过 mmap 方式申请的内存时,内存会立即归还给操作系统。这是因为 mmap 系统调用分配的内存是在文件映射区域,与堆内存相互独立。当调用 free 函数时,实际上是通过 munmap 系统调用来取消内存映射,将内存从进程的虚拟地址空间中移除,并将其归还给操作系统,使得这部分内存可以被其他进程使用。

mmap 方式申请内存的释放机制使得内存的管理更加灵活和高效,尤其是在处理大内存块的分配和释放时,能够有效地避免内存碎片的产生。例如,在一个需要频繁分配和释放大内存块的程序中,如果使用 brk 方式,随着内存的不断分配和释放,堆内存中很容易产生大量的碎片,导致内存利用率下降。而使用 mmap 方式,每次释放内存时都能将其完整地归还给操作系统,不会产生碎片问题,保证了内存的高效利用和系统的稳定性。

六、为何不全部使用 brk 或 mmap

6.1不全部使用 brk 的原因

在内存分配的世界里,brk 系统调用虽然在处理小内存分配时展现出一定的优势,但如果全部使用 brk 来分配内存,会带来一系列严重的问题。其中最突出的问题便是内存碎片的产生。

让我们通过一个具体的场景来深入理解这个问题。假设我们有一个程序,它需要频繁地进行小内存的分配和释放操作。程序首先通过 brk 系统调用申请了一块 10KB 的内存,用于存储一些临时数据;接着又申请了一块 20KB 的内存,用于其他任务;随后再申请一块 30KB 的内存。此时,堆内存的布局是连续的三块内存区域,分别为 10KB、20KB 和 30KB。

随着程序的运行,当不再需要第一块 10KB 和第二块 20KB 的内存时,我们调用 free 函数将它们释放。由于 brk 系统调用分配的内存是在堆空间中,释放后的内存并不会立即归还给操作系统,而是缓存在 malloc 的内存池中。这就导致堆内存中出现了两块空闲的内存区域,它们分别是 10KB 和 20KB,并且这两块空闲内存区域之间被一块正在使用的 30KB 内存隔开。

当程序后续需要申请一块 40KB 的内存时,尽管堆内存中总的空闲内存大小是足够的(10KB + 20KB = 30KB),但由于这两块空闲内存不连续,无法合并成一个足够大的内存块来满足 40KB 的分配需求。这样,就会出现虽然堆内存中有空闲空间,但仍然无法满足分配需求的情况,这就是典型的内存碎片问题。

随着程序中这种频繁的内存分配和释放操作不断进行,堆内存中会逐渐产生越来越多这样不连续的小空闲内存块,这些内存碎片会占据大量的内存空间,却无法被有效地利用,导致内存利用率急剧下降。这种情况在一些长时间运行且需要频繁进行小内存分配和释放的程序中尤为明显,例如数据库管理系统中的缓存模块、图形渲染引擎中的资源分配模块等。如果这些系统全部使用 brk 系统调用进行内存分配,随着时间的推移,内存碎片问题会越来越严重,最终可能导致系统性能大幅下降,甚至出现内存耗尽的错误。

6.2不全部使用 mmap 的原因

虽然 mmap 系统调用在处理大内存分配时具有明显的优势,能够有效避免内存碎片问题,但如果全部使用 mmap 来分配内存,同样会面临一些严重的问题,主要体现在系统调用开销和缺页中断方面。

从系统调用开销的角度来看,mmap 系统调用涉及到用户态和内核态的切换,这种切换会带来一定的性能损耗。当我们在程序中调用 mmap 系统调用时,CPU 需要保存当前用户态的上下文信息,然后切换到内核态执行 mmap 的相关操作。在内核态完成内存映射等操作后,又需要将上下文信息恢复,切换回用户态。这个过程需要消耗一定的时间和 CPU 资源,如果频繁地进行 mmap 系统调用,系统调用的开销将会显著增加,导致程序的整体性能下降。

缺页中断也是一个不可忽视的问题。当使用 mmap 系统调用分配内存时,虽然虚拟内存会被立即分配,但在程序第一次访问这些虚拟内存时,会触发缺页中断。这是因为此时物理内存尚未分配,操作系统需要捕获这个缺页中断,为该虚拟内存页分配物理内存,并建立虚拟内存与物理内存之间的映射关系。如果全部使用 mmap 来分配内存,并且程序频繁地进行内存分配和访问操作,那么就会频繁地触发缺页中断。每一次缺页中断都需要操作系统进行一系列的处理操作,这会消耗大量的 CPU 资源,导致 CPU 的利用率升高,程序的执行效率降低。

在一个需要频繁进行内存分配和释放的高性能计算程序中,如果全部使用 mmap 系统调用,系统调用的开销和频繁的缺页中断会使得 CPU 忙于处理这些中断和上下文切换,而无法专注于程序的核心计算任务,从而导致程序的运行速度大幅减慢,无法满足高性能计算的需求。因此,在实际的内存分配中,不能全部使用 mmap 系统调用,而是需要根据内存分配的大小和具体的应用场景,合理地选择 brk 和 mmap 系统调用,以实现高效的内存管理。

七、Malloc使用中的常见问题与注意事项

7.1内存泄漏风险

在使用 malloc 进行内存分配时,内存泄漏是一个需要特别关注的问题。内存泄漏就像是程序中的一个隐藏漏洞,它会逐渐吞噬系统的内存资源,导致程序性能下降,甚至可能引发系统崩溃。在实际的编程中,尤其是在一些复杂的代码逻辑中,内存泄漏的问题很容易被忽视。

假设我们有一个处理用户数据的函数,函数内部使用 malloc 分配了一块内存来存储用户输入的数据。代码如下:

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

void processUserData() {
    char *userInput = (char *)malloc(100);
    if (userInput == NULL) {
        // 处理内存分配失败的情况
        printf("Memory allocation failed\n");
        return;
    }

    // 模拟获取用户输入
    strcpy(userInput, "Some user data");

    // 假设这里有一些复杂的逻辑处理用户数据

    // 错误示范:忘记释放内存
    // free(userInput);
}

在这个例子中,processUserData函数使用 malloc 分配了 100 字节的内存来存储用户输入。在函数执行过程中,我们对这块内存进行了一些操作,比如模拟获取用户输入并存储到这块内存中。但是,在函数结束时,我们忘记了调用 free 函数来释放这块内存。随着程序的不断运行,如果这个函数被频繁调用,每次调用都会分配新的内存而不释放旧的内存,那么系统的内存资源会逐渐被耗尽,最终导致内存泄漏。

为了检测内存泄漏问题,我们可以借助一些工具,如 Valgrind 和 AddressSanitizer。Valgrind 是一款功能强大的内存调试工具,它可以详细地检测出程序中的内存泄漏情况,并给出具体的泄漏位置和相关的调用栈信息。使用 Valgrind 非常简单,只需要在命令行中运行valgrind --leak-check=full your_program,其中your_program是你要检测的可执行程序。AddressSanitizer 则是一个由 LLVM 和 GCC 支持的内存错误检测工具,它可以在编译时启用,通过在代码中插入一些检测代码来实时检测内存泄漏和其他内存相关的错误。在 GCC 中,可以使用-fsanitize=address选项来启用 AddressSanitizer 进行编译。

为了避免内存泄漏,我们需要养成良好的编程习惯。在使用 malloc 分配内存后,一定要记得在不再需要这块内存时调用 free 函数进行释放。可以在分配内存的地方添加注释,提醒自己在适当的时候释放内存。同时,对于一些复杂的函数逻辑,要仔细检查是否存在分支路径导致内存没有被释放的情况。例如,在上面的processUserData函数中,如果在获取用户输入时发生错误,导致函数提前返回,那么之前分配的内存也会泄漏。因此,在可能提前返回的地方,也需要确保释放已经分配的内存。

7.2访问已释放内存

访问已释放内存(UAF,Use - After - Free)是一种在内存管理中非常危险的错误行为。它指的是程序在使用 free 函数释放了一块内存之后,仍然通过指向这块内存的指针来访问它。这种错误就像是在拆除了一座房子后,还试图进入房子里寻找东西,其结果是不可预测的,可能会导致程序崩溃、数据损坏,甚至引发安全漏洞,让攻击者有机会执行恶意代码。

当我们使用 brk 系统调用分配内存时,假设我们有以下代码:

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

int main() {
    char *ptr = (char *)malloc(100);
    if (ptr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }

    // 使用ptr指向的内存
    strcpy(ptr, "Some data");
    printf("Data: %s\n", ptr);

    // 释放内存
    free(ptr);

    // 错误示范:访问已释放的内存
    printf("Data after free: %s\n", ptr);

    return 0;
}

在这段代码中,我们首先使用 malloc 分配了 100 字节的内存,并将其赋值给指针ptr。然后,我们在这块内存中存储了一些数据并打印出来。接着,我们调用 free 函数释放了这块内存。然而,在释放内存之后,我们又试图通过ptr指针来访问这块已经被释放的内存,并打印其中的数据。由于内存已经被释放,ptr指向的内存区域已经不再属于我们的程序,此时访问这块内存会导致未定义行为。在某些情况下,程序可能会崩溃,提示段错误(Segmentation fault);而在另一些情况下,可能会打印出一些随机的数据,因为这块内存可能已经被操作系统重新分配给其他程序使用,其内容已经被修改。

如果是通过 mmap 系统调用分配内存,同样会出现类似的问题。例如:

#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>

int main() {
    int fd = open("test.txt", O_RDWR | O_CREAT, 0666);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 扩展文件大小
    if (lseek(fd, 100 - 1, SEEK_SET) == -1) {
        perror("lseek");
        close(fd);
        return 1;
    }
    if (write(fd, "", 1) == -1) {
        perror("write");
        close(fd);
        return 1;
    }

    char *ptr = (char *)mmap(0, 100, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return 1;
    }

    // 使用ptr指向的内存
    strcpy(ptr, "Some data");
    printf("Data: %s\n", ptr);

    // 释放内存
    if (munmap(ptr, 100) == -1) {
        perror("munmap");
        close(fd);
        return 1;
    }

    // 错误示范:访问已释放的内存
    printf("Data after free: %s\n", ptr);

    close(fd);
    return 0;
}

在这个例子中,我们通过 mmap 系统调用将文件映射到内存中,并获取了一个指向映射内存区域的指针ptr。在使用完内存后,我们调用 munmap 函数释放了这块内存。但之后又试图访问已经释放的内存,这同样会导致未定义行为。与 brk 方式不同的是,mmap 分配的内存通常与文件映射相关,访问已释放的 mmap 内存可能会导致更复杂的问题,比如影响文件系统的一致性,因为文件映射的内存与文件的内容是关联的。如果在释放后还继续访问,可能会导致文件内容被错误地修改,从而引发数据损坏的问题。

责任编辑:武晓燕 来源: 深度Linux
相关推荐

2019-02-01 09:50:00

提升Python程序性能

2011-01-05 13:48:55

Linux提高性能

2009-06-10 09:26:58

2023-06-12 00:22:50

操作系统应用程序内核锁

2010-05-25 09:44:03

2018-07-06 16:26:11

编程语言Python程序性能

2024-05-16 11:04:06

C#异步编程编程

2011-09-20 10:41:45

Web

2022-10-08 13:13:14

Python程序性能

2020-12-03 08:00:00

SQL数据库MySQL

2009-12-09 17:25:19

Linux操作系统

2024-12-09 09:50:00

JVM逃逸逃逸分析

2024-12-05 15:33:50

Python列表元组

2012-01-06 13:48:59

flash

2010-04-20 15:25:12

Unix操作系统

2010-04-14 14:30:28

Unix操作系统

2010-04-14 18:39:24

Unix操作系统

2020-12-29 16:39:01

Linux代码命令

2010-11-15 16:20:33

Oracle系统优化

2020-10-27 08:24:01

Java
点赞
收藏

51CTO技术栈公众号