编辑
2025-08-06
记录知识
0
请注意,本文编写于 36 天前,最后修改于 36 天前,其中某些信息可能已经过时。

目录

malloc造成的性能颠簸
增加mmap阈值
malloc代码分析
mallopt代码分析
实验和测试
得出结论
将内存不返还给操作系统
free代码分析
mallopt代码分析
得出结论
整体结论
测试验证程序
通过Rss判断free是否返还给操作系统
总结

Linux上绝大部分应用程序的内存代码申请通过malloc,根据之前的介绍,通过mlock调用的内存可以直接申请物理页面,并且避免被操作系统交换和回收,但是通过malloc和free的内存空间,还是默认会触发缺页异常进行页面申请,交由应用交还给操作系统。所以需要调整glibc的行为来增强实时程序的实时性。

malloc造成的性能颠簸

如果实时程序频繁的malloc和free,那么系统就会不断的调用sbrk/brk调用扩大内存空间和缩小内存空间,或调用mmap和munmap进行分配和释放内存空间。这种频繁的系统调用就会涉及到内核物理页面的分配和释放,而内存的分配是惰性的,也就是不具备确定性的,从而导致实时程序的确定性会受到影响,表现为实时应用程序产生性能颠簸。

那么有没有什么办法可以优化这类问题呢,使得应用程序在执行关键任务的时候,及时malloc和free,使得其运行性能几乎不受影响呢。

实际上是可以的,解决此问题的方法体现在如下两点: 1.增大mmap的阈值,使得系统尽可能使用brk调整堆,而不是mmap 2.当malloc的内存得到free之后,尽可能停留在glibc中,不返还给操作系统

下面根据上述两点进行分析

增加mmap阈值

我们知道mmap是分配独立的大块的内存区域,这种分配是需要和munmap成对使用的。

在glibc的实现中,通过mmap的内存,其实并没有提供保留这块内存的功能,也就意味着,当应用程序调用free释放程序时,其会直接使用mmap缩小范围或munmap释放堆。

也就是说我们无法通过减少mmap/munmap系统调用次数的方式来驻留内存(在不定制glibc的前提上),从而保证实时程序的确定性。

但是可以提供另一种方法:

  • 如果将原先由mmap提供内存的方案,转而使用brk系统调用去申请
  • 然后我们解决brk申请的内存,能够常驻在应用中的问题

通过避免过多的brk系统调用导致物理内存申请,从而出现内存颠簸,那么我们也是解决了mmap导致的实时程序确定性的问题。

malloc代码分析

根据上述思路,我们先跟踪代码。 对于malloc,其默认调用glibc的__libc_malloc,如下

strong_alias (__libc_malloc, __malloc) strong_alias (__libc_malloc, malloc)

函数__libc_malloc的核心是调用_int_malloc,这里因为我最后测试程序是单线程,所以忽略了一下逻辑判断,简化代码如下

void * __libc_malloc (size_t bytes) { mstate ar_ptr; void *victim; if (SINGLE_THREAD_P) { victim = _int_malloc (&main_arena, bytes); assert (!victim || chunk_is_mmapped (mem2chunk (victim)) || &main_arena == arena_for_chunk (mem2chunk (victim))); return victim; } ... }

简单说一下理解的malloc的流程

  1. 第一次分配直接通过sysmalloc分配堆
  2. 如果 tcache 中有一个合适的chunk,则从tcache取出
  3. 如果请求大于mmap的阈值M_MMAP_THRESHOLD ,则使用mmap
  4. 先从fastbin中找到合适的chunk,如果找到了则返回,并放入tcache
  5. 再从smallbin中找到合适的chunk,同理,如有,则放入tcache
  6. 如果比它们都大,则先合并fastbin内的chunk,将其放到unsorted bin
  7. 在unsorted bin中根据大小,移动到small/large bin中
  8. 如果刚好大小和申请大小一致,则使用这个chunk
  9. 如果大小比上述都大,则从large bin中找
  10. 执行内存规整,malloc_consolidate,合并空闲内存
  11. 从top chunk中划分空间给bins
  12. 如果top chunk没有空间,则sysmalloc去分配扩大heap

简单理解就是:先通过sysmalloc来分配初始块,如果不满足fastbin/smallbin/unsortedbin/largebin,且当前的topchunk不足以申请内存空间,且所有的bin都不能合并,那么也是通过sysmalloc来分配新的内存。所以我们重点关注sysmalloc。

void *p = sysmalloc (nb, av); if (p != NULL) alloc_perturb (p, bytes); return p;

函数sysmalloc中,会判断mp_.mmap_threshold,如果申请的内存大小大于此变量则通过mmap调用,否则通过brk调用。代码简要如下

static void * sysmalloc (INTERNAL_SIZE_T nb, mstate av) { /* If have mmap, and the request size meets the mmap threshold, and the system supports mmap, and there are few enough currently allocated mmapped regions, try to directly map this request rather than expanding top. */ if (av == NULL || ((unsigned long) (nb) >= (unsigned long) (mp_.mmap_threshold) && (mp_.n_mmaps < mp_.n_mmaps_max))) { char *mm; /* return value from mmap call*/ try_mmap: if (MALLOC_ALIGNMENT == 2 * SIZE_SZ) size = ALIGN_UP (nb + SIZE_SZ, pagesize); else size = ALIGN_UP (nb + SIZE_SZ + MALLOC_ALIGN_MASK, pagesize); tried_mmap = true; /* Don't try if size wraps around 0 */ if ((unsigned long) (size) > (unsigned long) (nb)) { mm = (char *) (MMAP (0, size, PROT_READ | PROT_WRITE, 0)); } ...... else { brk = (char *) (MORECORE (size)); } }

可以看到,如果malloc申请的内存足够大,大于mp_.mmap_threshold,则直接通过mmap系统调用来申请,否则走sbrk来申请内存。

mallopt代码分析

通过上面分析,我们知道可以控制mp_.mmap_threshold来控制mmap/brk的行为,所以需要查看mp_.mmap_threshold的值如何设置。代码如下

此时我们留意mallopt函数,其代码对于glibc的原型为__libc_mallopt,如下

strong_alias (__libc_mallopt, __mallopt) weak_alias (__libc_mallopt, mallopt)

函数__libc_mallopt接收M_MMAP_THRESHOLD的调用,用来调整mmap的阈值,如下

int __libc_mallopt (int param_number, int value) { ...... switch (param_number) { case M_MMAP_THRESHOLD: res = do_set_mmap_threshold (value); break; ...... }

函数do_set_mmap_threshold的行为会直接将mallopt的值传递给mp_.mmap_threshold,代码如下

static __always_inline int do_set_mmap_threshold (size_t value) { if (value <= HEAP_MAX_SIZE / 2) { mp_.mmap_threshold = value; mp_.no_dyn_threshold = 1; return 1; } return 0; }

可以看到,如果设置的值小于HEAP_MAX_SIZE / 2,则可以调整到mp_.mmap_threshold,否则设置无效,所以我们得查看HEAP_MAX_SIZE在arm64机器上的定义,代码如下:

#ifndef HEAP_MAX_SIZE # ifdef DEFAULT_MMAP_THRESHOLD_MAX # define HEAP_MAX_SIZE (2 * DEFAULT_MMAP_THRESHOLD_MAX) # else # define HEAP_MAX_SIZE (1024 * 1024) /* must be a power of two */ # endif #endif

通过上述宏定义可以知道其值为2 * DEFAULT_MMAP_THRESHOLD_MAX,我们查看DEFAULT_MMAP_THRESHOLD_MAX在64位平台上的定义信息,如下

define DEFAULT_MMAP_THRESHOLD_MAX (4 * 1024 * 1024 * sizeof(long))

这里64位的sizeof(long)的大小是8,可以计算得出,默认情况下,mp_.mmap_threshold最大值可以设置为32M。如果mallopt设置成功,则返回1,设置失败则返回0。

实验和测试

下面我们做实验验证此问题:

首先,我们先尝试申请0字节,查看此时glibc需要额外多少字节管理。代码如下

#include <malloc.h> #include <string.h> int main() { int ret = -1; void* ptr = NULL; #define BLOCK_SIZE 0 printf("malloc: %d bytes ret=%d\n", BLOCK_SIZE, ret); ptr = malloc(BLOCK_SIZE); malloc_stats(); free(ptr); return 0; }

编译运行后,日志如下

malloc: 0 bytes Arena 0: system bytes = 135168 in use bytes = 1728 Total (incl. mmap): system bytes = 135168 in use bytes = 1728 max mmap regions = 0 max mmap bytes = 0

可以看到,默认情况下,系统分配了128k+4k 的内存给程序使用,但是glibc在内存管理时,数据结构需要占用1728个字节。而在申请每一个内存页面时,chunk的结构体大小是8字节。参考文章 "glibc内存malloc简要解析"

也就是说,如果申请在132k - 1728 - 8 = 133432 字节内,那么可以由第一次申请的132k覆盖住,如果超过此字节数,则重新mmap一个大块内存出来。

值得注意的是,这里mmap的并不是128k,而是128k+4k=132k字节内存。下面调整BLOCK_SIZE的值为133432 验证此问题

#include <malloc.h> #include <string.h> int main() { int ret = -1; void* ptr = NULL; #define BLOCK_SIZE 133432 printf("malloc: %d bytes ret=%d\n", BLOCK_SIZE, ret); ptr = malloc(BLOCK_SIZE); malloc_stats(); free(ptr); return 0; }

运行日志如下

malloc: 133432 bytes Arena 0: system bytes = 135168 in use bytes = 135136 Total (incl. mmap): system bytes = 135168 in use bytes = 135136 max mmap regions = 0 max mmap bytes = 0

可以看到,当申请字节在133432字节时,默认还是使用的系统第一个mmap页面,这是来自_int_malloc通过sysmalloc来分配第一个初始块。

当申请字节数量为133432 + 1字节时,则通过sysmalloc内判断mp_.mmap_threshold来申请字节。现在调整 BLOCK_SIZE的值为133432 + 1来验证此问题

#include <malloc.h> #include <string.h> int main() { int ret = -1; void* ptr = NULL; #define BLOCK_SIZE 133433 printf("malloc: %d bytes ret=%d\n", BLOCK_SIZE, ret); ptr = malloc(BLOCK_SIZE); malloc_stats(); free(ptr); return 0; }

此时运行日志如下

malloc: 133433 bytes Arena 0: system bytes = 135168 in use bytes = 1696 Total (incl. mmap): system bytes = 270336 in use bytes = 136864 max mmap regions = 1 max mmap bytes = 135168

可以看到,当第一个132k无法满足时,通过malloc分配133433个字节时,其明显大于128kB,也就是默认mp_.mmap_threshold,那么会通过再mmap申请128k+4k提供给用户使用,这里额外的4k是用来管理mmap页面的数据结构的。

此时回到我们的问题: 将mmap调用迁移成brk

如果我想要通过修改mp_.mmap_threshold的值,从而影响malloc申请时的行为,将其在0-32M内都由brk分配,从而减少内存颠簸。那么可以通过 mallopt实现。故其代码如下

int main() { int ret = -1; void* ptr = NULL; ret = mallopt(M_MMAP_THRESHOLD, 4 * 1024 * 1024 * sizeof(long)); #define BLOCK_SIZE 133433 printf("malloc: %d bytes ret=%d\n", BLOCK_SIZE, ret); ptr = malloc(BLOCK_SIZE); malloc_stats(); free(ptr); return 0; }

此时运行后,日志如下

malloc: 133433 bytes ret=1 Arena 0: system bytes = 270336 in use bytes = 135152 Total (incl. mmap): system bytes = 270336 in use bytes = 135152 max mmap regions = 0 max mmap bytes = 0

可以发现,此时申请133433 字节的内存,mallopt返回值为1,代码也没有使用mmap的方式申请内存。符合上述代码分析流程。

但是,当我们传给M_MMAP_THRESHOLD的值大于32M时,那么mallopt失效,其返回值为0,代码仍通过mmap申请下一个块。代码如下

int main() { int ret = -1; void* ptr = NULL; ret = mallopt(M_MMAP_THRESHOLD, 4 * 1024 * 1024 * sizeof(long) + 1); #define BLOCK_SIZE 133433 printf("malloc: %d bytes ret=%d\n", BLOCK_SIZE, ret); ptr = malloc(BLOCK_SIZE); malloc_stats(); free(ptr); return 0; }

此时运行结果如下

malloc: 133433 bytes ret=0 Arena 0: system bytes = 135168 in use bytes = 1696 Total (incl. mmap): system bytes = 270336 in use bytes = 136864 max mmap regions = 1 max mmap bytes = 135168

可以看到,即使我们通过mallopt设置M_MMAP_THRESHOLD了,但mallopt返回值为0,所以系统的max mmap regions为1。

这个原因是其值大于了HEAP_MAX_SIZE / 2。

最后,需要提醒的一个小细节是:glibc的第一个mmap区域,并不计算在max mmap regions中。也无需去优化。

得出结论

综上,我们可以通过设置M_MMAP_THRESHOLD在32M,可以让系统32M内的内存,全部通过brk/sbrk系统调用来管理。

接下里,我们需要调整brk的行为,让其不再返还给操作系统,减少内存的颠簸,从而提高实时程序的确定性。

将内存不返还给操作系统

在glibc的代码中,我们可以调整mp_.trim_threshold的值,这个值可以不会主动调用systrim来返还给操作系统。这样下一次调用malloc时,直接从bins上取出内存,而不会通过brk扩大内存空间,这样无需通过缺页异常先入内核去内存分配了。下面进行代码的解释

free代码分析

当系统调用free时,默认情况是调用的__libc_free,代码如下

strong_alias (__libc_free, __free) strong_alias (__libc_free, free)

这里__libc_free的实现在代码malloc/malloc.c处,其主要会判断是否来自mmap还是brk,如果来自mmap,则调用munmap释放,如果来自brk,则调用_int_free,如下

void __libc_free (void *mem) { ... if (chunk_is_mmapped (p)) munmap_chunk (p); } _int_free (ar_ptr, p, 0); ... }

一般情况下,只有大块内存的申请才使用mmap,一般超过128k的内存(由mp_.mmap_threshold决定或glibc动态修改)。

代码中malloc更多是使用brk调用。

函数_int_free按照如下步骤进行

  1. 如果 tcache 中有空间,就将free的空间放到tcache中,这样下次申请时速度更快。
  2. 如果free的size在fastbin范围内(<128)则放到fastbin 链表中
  3. 如果是mmap的地址访问,则调用munmap释放,然后退出
  4. 根据free的地址,判断是否可以和相邻的空间合并
  5. 合并完成之后,将其放到unsorted bin 中
  6. 最后判断整个chunk是否大于FASTBIN_CONSOLIDATION_THRESHOLD,并当前的chunksize大于mp_.trim_threshold。如果都成立,那么将空间返还给操作系统

这里如果下面代码的条件都成立,那么调用systrim来返还给操作系统,代码如下

if (av == &main_arena) { if ((unsigned long)(chunksize(av->top)) >= (unsigned long)(mp_.trim_threshold)) systrim(mp_.top_pad, av); } else { heap_info *heap = heap_for_ptr(top(av)); assert(heap->ar_ptr == av); heap_trim(heap, mp_.top_pad); }

可以看到,如果是 main arena 的内存,则直接调用systrim来返还,如果不是,则调用heap_trim返还.

这里有一个细节,对于glibc而言,只有main arena的内存属于应用程序堆,而非 main arena 的内存都是来自mmap ,所以 heap_trim 调用的必定是 munmap 来释放,如下代码

#define delete_heap(heap) \ do { \ if ((char *) (heap) + HEAP_MAX_SIZE == aligned_heap_area) \ aligned_heap_area = NULL; \ __munmap ((char *) (heap), HEAP_MAX_SIZE); \ } while (0)

所以这里我们更应该关注systrim的实现步骤。

在systrim中,其调用的是sbrk来缩小内存范围,代码做了大量简化如下

static int systrim (size_t pad, mstate av) { ...... MORECORE (-extra); ...... }

这里MORECORE其实就是sbrk的封装

MORECORE sbrk

这里它的目的是扩大heap的空间大小和缩小heap的空间大小。这种扩大和缩小只是显式的告诉程序堆被缩减了,但是实际上内核需要在内存回收流程中处理这块已缩小的空间,也就是说物理页面并不是立即回收的。 关于内存的回收流程,上面文章《使用mlock优化实时程序》已经提到了。

根据上面代码的分析,我们可以留意到,如果是mmap的页面,那么其缩小均通过munmap,我们没办法直接干预其行为。但是处于主heap,也就是main arena 的 heap,它会根据mp_.trim_threshold的值来决定是否调调用sbrk缩小范围。

mallopt代码分析

mp_.trim_threshold 的值可以通过mallopt函数来调整。此时传入的参数为M_TRIM_THRESHOLD。代码如下

case M_TRIM_THRESHOLD: res = do_set_trim_threshold (value); break;

函数mallopt调用do_set_trim_threshold。其代码如下

static __always_inline int do_set_trim_threshold (size_t value) { LIBC_PROBE (memory_mallopt_trim_threshold, 3, value, mp_.trim_threshold, mp_.no_dyn_threshold); mp_.trim_threshold = value; mp_.no_dyn_threshold = 1; return 1; }

可以看到mp_.trim_threshold会直接受到mallopt函数影响。

得出结论

再结合之前代码的分析,当chunksize(av->top)的值大于mp_.trim_threshold时,就会调用systrim,然后针对top区域调用sbrk缩小heap的范围。所以我们可以设置mp_.trim_threshold为最大。

trim_threshold对程序影响的测试代码文章后面会给出来。

整体结论

当mp_.trim_threshold和mp_.mmap_threshold都调整到可调整的最大值时,那么对于一个程序,我们就可以完成如下几点特性:

  1. malloc小于32M的内存,统一使用sbrk/brk调整heap
  2. 所有malloc的内存,在free的时候,并不会返还给操作系统
  3. 因为glibc设计的分配内存的快速路径,所以下次malloc直接从bins链表中分配给应用

当然,使用上述调整办法,也会带来其他负面影响

  1. 会导致实时程序的常驻内存过大: 但是可以在合适的时机主动调用malloc_trim(0),返还内存给操作系统
  2. 会导致内存泄漏不易排查(因为brk的内存不易被常规内存检测工具察觉):但是可以用更专业的工具如asan排查

而带来的有益影响如下:

  1. 完全解决了实时程序在完成实时任务期间,不会被malloc导致性能颠簸,从而影响实时程序的确定性

测试验证程序

我们可以通过一个测试实例来整体验证此方法对实时程序的确定性的优化情况。 首先我们创建一个普通的程序,其调用test_malloc申请十万次4096字节并释放,然后在malloc之前统计时间,free之后统计时间,然后计算两次时间的差值,判断代码内存的申请性能。代码如下:

#include <stdio.h> #include <stdlib.h> #include <sys/mman.h> #include <unistd.h> #include <malloc.h> #include <time.h> #include <string.h> #define ITERATIONS 100000 #define BLOCK_SIZE 4096 static long long get_time() { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ((long long)ts.tv_sec * 1000000000LL + (long long)(ts.tv_nsec)) / 1000; } double test_malloc() { void **ptrs = malloc(ITERATIONS * sizeof(void*)); double start = (double)get_time(); for (int i = 0; i < ITERATIONS; i++) { ptrs[i] = malloc(BLOCK_SIZE); memset(ptrs[i], i & 0xFF, 1); } for (int i = 0; i < ITERATIONS; i++) { free(ptrs[i]); } double elapsed = (double)get_time() - start; free(ptrs); return elapsed; } int main() { printf("Block size: %d bytes, Iterations: %d\n", BLOCK_SIZE, ITERATIONS); double time1 = test_malloc(); printf("Time: %.2f us\n", time1); double time2 = test_malloc(); printf("Time: %.2f us\n", time2); printf("\n"); printf("Performance Improvement: %.2fx faster\n", time1 / time2); while(1) { time2 = test_malloc(); printf("Time: %.2f us\n", time2); sleep(1); } return 0; }

我们将该代码运行在内存压力较大的机器上,此时日志如下

Block size: 4096 bytes, Iterations: 100000 Time: 198139.00 us Time: 193639.00 us Performance Improvement: 1.02x faster Time: 193598.00 us Time: 489654.00 us Time: 313799.00 us Time: 448870.00 us Time: 197855.00 us Time: 197861.00 us Time: 328848.00 us Time: 367480.00 us Time: 432114.00 us Time: 193867.00 us

可以看到,每次申请内存时,因为执行过free,对于4k大小的页申请,都是通过brk缩小heap的范围,每次都在200000 us 左右,但是当系统内存紧张时,缺页异常直接影响到malloc的耗时,最高可以出现489654 us,已经是正常情况下内存申请效率的一倍多的耗时了。

下面基于上面的代码,在main函数内新增如下代码

mallopt(M_TRIM_THRESHOLD, -1); int ret = mallopt(M_MMAP_THRESHOLD, 4 * 1024 * 1024 * sizeof(long));

此时main函数如下

int main() { mallopt(M_TRIM_THRESHOLD, -1); int ret = mallopt(M_MMAP_THRESHOLD, 4 * 1024 * 1024 * sizeof(long)); printf("Block size: %d bytes, Iterations: %d\n", BLOCK_SIZE, ITERATIONS); double time1 = test_malloc(); printf("Time: %.2f us\n", time1); double time2 = test_malloc(); printf("Time: %.2f us\n", time2); printf("\n"); printf("Performance Improvement: %.2fx faster\n", time1 / time2); while(1) { time2 = test_malloc(); printf("Time: %.2f us\n", time2); sleep(1); } return 0; }

重新开始测试,此时日志如下

Block size: 4096 bytes, Iterations: 100000 Time: 179563.00 us Time: 22010.00 us Performance Improvement: 8.16x faster Time: 21733.00 us Time: 22371.00 us Time: 21769.00 us Time: 21439.00 us Time: 21912.00 us Time: 22193.00 us Time: 21712.00 us Time: 21678.00 us Time: 22402.00 us Time: 22180.00 us

可以看到,第一次申请时,因为malloc需要调用brk扩展堆,其性能总体比mmap要优秀,大概耗时在180000 us。
后续每次malloc时,无需向系统下发brk/mmap,避免了缺页异常,故其耗时稳定在22000作用,即使内存紧张情况下,耗时最高也在22402 us。

通过对比,我们可以发现,通过此方法,能够有效的保证实时程序在处理关键实时任务时,内存颠簸不会成为影响实时任务确定性的一个因素。
并且,每次malloc都不会从操作系统中触发缺页异常,从而显著的提升了malloc和free的性能耗时,按照上述例子中,提高了将近8倍的运行耗时。

可以得出结论,包含此方法的程序,在执行过程中,显然比未优化的程序确定性更高,更适合实时应用场景。

通过Rss判断free是否返还给操作系统

我们借助此程序来观察未加上优化的程序内存是否真的返回给系统了,而加上优化的程序,内存不会返还给系统

对于未优化的程序,运行后,我们获取其heap的RSS如下

# while ((1)) ; do cat /proc/$(pidof program)/smaps | grep heap -A 22 | grep Rss; sleep 0.5; done Rss: 228020 kB Rss: 920 kB Rss: 260764 kB Rss: 920 kB Rss: 128860 kB Rss: 920 kB Rss: 402352 kB Rss: 920 kB Rss: 165568 kB Rss: 920 kB

可以看到,系统中program的heap有借有还,体现在Rss的占用被正常释放了,这个值是来源于内核的物理内存使用的统计。

而优化过的程序,运行后,我们获取其heap的RSS如下

# while ((1)) ; do cat /proc/$(pidof real_program)/smaps | grep heap -A 22 | grep Rss; sleep 0.5; done Rss: 402352 kB Rss: 402352 kB Rss: 402352 kB

可以看到,Rss一直不变,也就意味着物理页面一直被程序使用者,即使程序没有真正使用了402352 kB大小内存,但glibc也为它保留了。

总结

这里讨论了通过配置glibc来优化实时程序,这是操作系统上提高一些实时程序确定性的必要操作,当然,如果对实时性不敏感的程序,其实并不需要增加这些操作。