2.4.5 线程栈小结
每个线程都需要有独立的栈,来保证并发调度时不冲突。进程地址空间的默认栈由于多个 task_struct 所共享,所以线程必须通过 mmap 来独立管理自己的栈。
Linux 中 glibc 的线程库其实是 nptl 线程。它包含了两部分的资源:
- 第一部分:在用户态管理了用户态的线程对象
struct pthread,以及独立的线程栈。 - 第二部分:内核中的
task_struct、地址空间等内核对象。
进程栈和线程栈的关系如下图所示。
说明
本文介绍线程栈地址空间时,仍围绕进程地址空间来介绍,并未涉及物理内存的申请和访问。真正的物理内存仍然是在真正访问时发生缺页中断,缺页时通过伙伴系统来申请的。
此外,glibc 中的每个线程在结束阶段都会做一个公共的操作:释放那些已结束线程的栈内存,从 stack_used 移除,放入 stack_cache 链表中。
2.5 进程堆内存管理
相比栈内存,开发者平时用得更多的是从堆中申请内存。程序运行过程中会涉及大量数据对象的申请和释放,而且对象的大小也是类型各异。所以内核提供的 mmap、brk 显然没有办法直接使用。如果每次分配内容都调用 mmap 或 brk,会导致大量的系统调用,碎片率也无法得到有效控制。
因此,在开发者和内核之间还需要一个内存分配器,允许开发者随时随意申请和释放各种大小的内存。比如大家都用过的 malloc 就是 GNU Libc 的内存分配器提供的接口。各种语言的运行时中,内存分配器都是一个很核心的组件。
业界目前有很多种优秀的内存分配器:
- ptmalloc:GNU C 库 glibc 中的内存分配器
- tcmalloc:Google 开发(目前 Golang 运行时采用的就是这个)
- jemalloc:Facebook 开发
这些分配器的共同点是:自己使用 mmap 或 brk 等系统调用,采用较大的单位,批量地向内核申请当前进程虚拟地址空间的内存,然后高效地管理起来。当应用程序有不管任何大小的分配需求时,分配器都能以较低的碎片率高效地分配。
2.5.1 ptmalloc 内存分配器的工作原理
我们来看 glibc 中内置的 ptmalloc 内存分配器的大致工作原理,看看它是如何能够随时分配释放各种大小的内存块,还能做到极低的碎片率的。
ptmalloc 的内存分配的单位是 malloc_chunk,我们简称 chunk。它包含 header 和 body 两部分。这是 chunk 在 glibc 中的定义:
// file: malloc/malloc.c
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;
};每次调用 malloc 申请内存时,分配器都会分配一个 chunk 出来,把 body 部分的 user data 的地址返回给我们。
如果 chunk 尚未被分配出去,或者被 free 释放掉的话,内存其实并不会归还给内核,而是由 glibc 又组织管理了起来。其 body 部分的 fd、bk 字段分别是指向上一个和下一个空闲的 chunk,用来当双向链表指针使用。
glibc 会将相似大小的空闲 chunk 都串起来,这样等下次用户再来分配时,找到链表就可以快速分配。这样的一个链表被称为一个 bin。PTmalloc 一共维护了 128 个 bin,并使用一个数组来存储这些 bin。
Bins 分类
根据不同的大小,bins 分为 small bins 和 large bins:
- small bins:同一个 small bin 中的 chunk 具有相同的大小。在
SIZE_SZ为 4B 的平台上,两个相邻的 small bin 中的 chunk 大小相差 8 bytes。管理的 chunk 字节范围从 16 到 512 字节。 - large bins:用于存储大于等于 512 字节或 1024 字节的空闲 chunk。
当要分配小块内存时,从合适的 bins 中查找合适的 chunk。假如用户要申请 16 字节的内存,就直接找到 16 字节这个链表,从链表头部摘下来一个 chunk 直接使用即可。这样不管如何申请和释放,都不会导致严重的碎片问题发生。
从地址空间的视角来看,堆中的内存基本上都是由一个一个的 chunk 来组成的。其中有个特殊的 chunk 叫 Top chunk。
如果没有空闲的 chunk 可用,或者需要分配的 chunk 足够大,当各种 bins 都不满足需求时,会从 Top chunk 中尝试分配。
如果 Top chunk 中也没有足够内存时:
- 主线程的分配区:glibc 会尝试调用
brk来增加 Top chunk 的大小。 - 子线程的分配区:可能调用
mmap来分配到 Top chunk 中供后续分配,也有可能会直接使用mmap来给本次用户调用申请内存。
多线程优化
为了在多线程情况下尽量减少申请内存时对锁的竞争,glibc 并不是只有进程默认的堆这一个分配区,还会使用 mmap 为其它线程申请新的分配区出来。通过这种方式减少锁开销,提高效率。这就是 glibc 中的 ptmalloc 管理堆内存的基本原理。
2.6 本章总结
2.6.1 虚拟内存与物理内存
本章第一个小节中我们先介绍了虚拟内存和物理内存的概念。进程中的虚拟内存只是内核中的几个对象而已,使用一个个的 vm_area_struct 来声明一段段地址范围,表示这段范围已申请。真正的物理内存是由内核的伙伴系统管理的。当虚拟内存中的虚拟页被访问时,如果对应的物理页还没有加载到内存中,就会触发缺页中断,通过调用伙伴系统的 alloc_pages 来分配物理内存。
2.6.2 进程使用内存的几种方式
在第二个小节中,我们介绍了进程使用内存的几种方式,这些方式都是从内核的视角出发的:
- 内核启动进程时会将可执行文件的代码段、数据段以及其关联的动态链接库都加载映射到虚拟地址空间中。
- 提供了
mmap、brk等系统调用,允许进程在运行的过程中动态地向虚拟地址空间申请内存。
无论是 mmap 还是 brk,其实工作原理都非常的简单:申请或者修改一个 vm_area_struct 对象而已。真正的物理内存还是依赖缺页中断和伙伴系统来分配。
2.6.3 进程栈
在第三个小节中,我们深入地分析了进程栈的初始化过程以及动态增长原理。进程栈在初始化的时候,只有 4KB 的大小。随着程序的运行发现栈空间不够时,内核会自动对栈空间进行增加。当然这个增加是有上限的,可以通过 ulimit 中的 stack size 来查看和修改。
2.6.4 线程栈
在第四个小节中,我们深入地分析了线程栈。我们发现虽然进程栈和线程栈都叫栈,但其实在实现原理上是完全不同的东西。线程栈内核根本不管,是 glibc 提前调用 mmap 申请好,给新线程传递进来的。而且线程栈不可以自动扩展,所以申请的时候就是申请的栈的最大上限。
2.6.5 malloc 的工作原理
在第五个小节中,我们介绍了 malloc 的工作原理。内核只提供 mmap、brk 这种基础的内存分配方式,但我们开发者可能会频繁地需要申请各种尺寸的小对象。如果直接使用 mmap、brk,会导致严重的碎片问题,频繁的系统调用也会拉低进程运行性能。glibc 中的内存分配器通过链表的方式管理各种大小的 chunk,每一个链表中都是相同大小的 chunk。当进程需要对象时,分配器根据其大小找到链表,从链表头摘一个直接用。当释放时,还会放到相应大小的 chunk 中,等下次再分配,并不会立即还给内核。
2.6.6 回顾开篇问题
1)申请内存申请到的真的是物理内存吗?
我们应用层开发平时接触到的内存,不管是栈也好,堆也罢,甚至是直接用 mmap,其实看到的都是虚拟内存,只是代表进程地址空间里的一段地址范围的 vm_area_struct 而已。
2)对虚拟内存的申请如何转化为对物理内存的访问?
真正的物理内存是在访问时,如果发现虚拟内存页对应的物理页还没有申请的话,触发缺页中断。在缺页中断中调用伙伴系统的 alloc_pages 来申请的。这是以页为单位的!
3)top 命令输出中 VIRT 和 RES 分别是什么含义?
我们在使用 top 查看内存指标时,往往会看到两个内存相关的指标,分别是 VIRT 和 RES。例如下图中是一个典型的输出。
典型 top 输出
(此处应有图片,描述 top 命令输出的 VIRT 和 RES 列。实际内容如下:)
VIRT 指的是进程的代码段、数据段、动态链接库、栈、堆等所有加起来,总共对虚拟地址空间申请了多大。而另一列 RES 指的是实际占用的物理页有多大。
除了 top 命令以外,还有其它的命令在查看内存时也会输出两个指标,比如 ps 命令输出时会包含 VSZ 和 RSS 两列:
# ps -aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 859793 0.0 0.0 13740 1296 ? Ss 2022 0:00 nginx: master
process ...
root 2131602 0.0 0.2 438796 43524 ? Ss 2022 0:00 nginx: master
process ...
nobody 859794 0.0 0.0 14152 2972 ? S 2022 0:00 nginx: worker
process
kong 3211445 0.0 0.8 657192 133252 ? S Jan11 29:27 nginx: worker
process其中 VSZ 和 top 命令输出的 VIRT 是同一个含义,也是指虚拟地址空间占用。RSS 和 top 中的 RES 一样,是实际物理内存页的消耗。
4)栈的大小限制是多大?
为了避免无限递归之类的问题对系统造成的冲击,栈都是有大小限制的。进程堆栈大小的限制在每个机器上都是不一样的。栈的大小可以通过 ulimit 命令查看和修改。一般大概是 8MB 左右。
5)当堆栈发生溢出后应用程序会发生什么?
报错结果一般是 Segmentation fault (core dumped)。
6)进程栈和线程栈是相同的东西吗?
虽然进程栈和线程栈都叫栈,但其实在实现原理上是完全不同的东西。进程栈是 Linux 内核实现的,线程栈是 glibc 在用户态申请的。进程线程的区别与联系汇总后的对比表如下:
| 特性 | 进程栈 | 线程栈 |
|---|---|---|
| 创建时机 | 内核创建进程时 | 在内核创建前,由 glib 使用 mmap 申请 |
| 初始大小 | 4KB | ulimit 中 stack size(即最大栈大小) |
| 是否可增长 | 可以 | 不可以 |
| 最大限制 | ulimit 中 stack size | ulimit 中 stack size |
7)你知道 malloc 大概是如何工作的吗?
内存分配是一件挺复杂的工作,不过好在各种语言的内存分配器都替我们解决好了。我们常用的 malloc 是在 glibc 内置的 PTMalloc 分配器实现的。glibc 维护了各种大小的空闲链表,每个链表中的元素都是一个 chunk。每次调用 malloc 申请内存时,都是先找到合适大小的空闲链表,然后从上面摘一个 chunk 下来就行了。chunk 有一点控制 header,其中的 body 部分就是返回给我们使用的内存地址。