linux 动态内存管理:brk/mmap/madvise 等

1. brk/sbrk syscall

要介绍 brk,需要先引入 program break 这一概念。它可以直接理解为是虚存中的一个指针,指向堆的终点。进程初始化时,由于堆大小为 0,故 program break 直接指向 .bss 段的终点。

brk/sbrk syscall 的作用就是移动 program break 的位置。如果向高地址移动,则堆扩大,同时 OS 会向进程分配物理内存(事实上先只分配虚存,等到第一次访问时再通过缺页中断来分配物理内存);反之,若向低地址移动,则堆缩小,同时进程会释放对应的物理内存。

2. brk 的问题

堆使用的虚存地址必须是连续的。如果我们只使用 brk 这一种方式来实现动态分配内存,则会在内存释放上遇到问题。

举个例子:
假设堆的内存依次存放了动态分配的数据 A、B、C 。program break 则应当指向 C 的结尾。
此时,即使我们调用 free(A)free(B),只要 C 依然在使用堆末尾的这块内存,我们就无法将 program break 减少,也就是无法缩小堆的大小。这就导致前面由 A、B 占用的物理内存也无法释放,留下了无法被释放给 OS 的内存碎片。

3. mmap:作为 brk 的补充

为了减少内存碎片,现代的 glibc malloc 实现是混合使用 brkmmap 的。设置了一个阈值 MMAP_THRESHOLD,申请的内存大小超过此阈值时,则会换用 mmap 分配内存。
mmap 会让内核在堆和栈之间的虚拟内存寻找一段可用的空间(TODO: 检查源码,mmap 调用的 addr 是否为 NULL?),然后创建一个匿名的内存映射。这些内存映射之间不必保持相邻和连续,因此在使用完后可以立刻释放(munmap),从而避免了 brk 的内存碎片问题。

参考 https://man7.org/linux/man-pages/man3/malloc.3.html

4. glibc 的 free 行为与 malloc_trim

前面提到,只要堆顶空间不释放,则堆就不能缩减,进程也不能将占用内存还给操作系统。所以,在 free 的时候,应当适时地检查堆顶是否有可以释放的内存空间。

glibc 的 free 在释放了足够大的内存时,会尝试通过 brk或者 madvise 来缩减堆。这部分可以参考代码,调用栈是__libc_free -> _int_free -> _int_free_chunk-> _int_free_merge_chunk -> _int_free_maybe_consolidate -> heap_trim -> shrink_heap

这个过程牵扯到两个 const:

  • FASTBIN_CONSOLIDATION_THRESHOLDfree 的内存超过这个大小时,触发堆顶空间的检查与释放。默认 64k
  • M_TRIM_THRESHOLD:检查堆顶的空闲空间,超过这个值时,会缩减堆以释放内存。默认 128k

当然,glibc 也对外暴露了函数 malloc_trim,允许用户自行调用,用以在堆顶释放内存。

参考:

5. madvise

syscall,告知内核某块内存的预期使用方式,让内核可以选择合适的内存优化策略,例如预读、缓存或是释放。

当我们讨论内存释放时,常用的 madvise 参数是 MADV_DONTNEED。它表示某块内存在短期内不再需要使用,内核会在合适的时机释放这块虚存所绑定的物理内存。虚存地址本身还是会得以保留的,下次访问的对应地址时会通过缺页中断重新加载。

注意这里强调了是合适的时机,因为 mdvise 只是一种建议,内核有权利推迟甚至是拒绝做出相应的操作。

6. buddy 算法

在 linux 内核的物理内存管理中的算法,其设计目的是减少碎片。这个算法本身可以就由三条规则说明:

  1. 将物理内存划分为大小为 2 的幂个页(4KB)的块。
  2. 当进程申请内存时,为其分配大小最接近的 2 的幂的块。若当前并不存在满足需求的块,则递归向上查找更大的块,将更大的块拆成同样大小的两份,这两份互为 buddy。
  3. 当进程释放内存时,检查这块内存的 buddy 是否也是空闲的。如果是,则将两个 buddy 合并为一个较大的块。并向上递归。

可以配合 wiki 上的例子说明来更直观地理解这个算法运行的规则。

7. slab 内存分配器

仅使用 buddy 算法有着一些明显的问题:

  1. 从 buddy 算法的描述可以发现,这种算法每次分配内存的最小值是 4KB。而进程实际申请的内存又往往是非常小的,例如一个结构体,通常也就几十字节。可见,仅采用 buddy 算法会造成比较显著的浪费。
  2. 这些稀疏的页还会占据宝贵的 CPU 数据缓存与 TLB,把实际存放了较多数据的页给抢占出去,影响性能。
  3. 再次,调用 buddy 算法分配内存的链路本身也是相当长的,相关的代码段和数据段本身也会抢占 CPU 的指令缓存和数据缓存。

在系统实际运行中,内核需要频繁地分配与释放一些小的对象,例如 fd、进程控制块(PCB,即 task_struct )等,这会导致上面几个问题的负面影响被严重放大。

所以怎么办的?经典的池化思想就派上用场了。slab 内存分配器就是一个基于池化思想的小内存分配器。slab 会向伙伴系统一次性申请一个或者多个物理内存页面,组成内存池。内核常用的小对象都走 slab 申请 (kmalloc, k for kernel),释放也是归还到 slab 中。

同样的思想也被运用了一些用户空间的内存分配器,例如 jemalloc 上。

关于 slab 非常好的文章参考: https://www.cnblogs.com/binlovetech/p/17288990.html

知识共享许可协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇