2.4.5 线程栈小结

每个线程都需要有独立的栈,来保证并发调度时不冲突。进程地址空间的默认栈由于多个 task_struct 所共享,所以线程必须通过 mmap 来独立管理自己的栈。

Linux 中 glibc 的线程库其实是 nptl 线程。它包含了两部分的资源:

  • 第一部分:在用户态管理了用户态的线程对象 struct pthread,以及独立的线程栈。
  • 第二部分:内核中的 task_struct、地址空间等内核对象。

进程栈和线程栈的关系如下图所示。

说明

本文介绍线程栈地址空间时,仍围绕进程地址空间来介绍,并未涉及物理内存的申请和访问。真正的物理内存仍然是在真正访问时发生缺页中断,缺页时通过伙伴系统来申请的。

此外,glibc 中的每个线程在结束阶段都会做一个公共的操作:释放那些已结束线程的栈内存,从 stack_used 移除,放入 stack_cache 链表中。


2.5 进程堆内存管理

相比栈内存,开发者平时用得更多的是从堆中申请内存。程序运行过程中会涉及大量数据对象的申请和释放,而且对象的大小也是类型各异。所以内核提供的 mmapbrk 显然没有办法直接使用。如果每次分配内容都调用 mmapbrk,会导致大量的系统调用,碎片率也无法得到有效控制。

因此,在开发者和内核之间还需要一个内存分配器,允许开发者随时随意申请和释放各种大小的内存。比如大家都用过的 malloc 就是 GNU Libc 的内存分配器提供的接口。各种语言的运行时中,内存分配器都是一个很核心的组件。

业界目前有很多种优秀的内存分配器:

  • ptmalloc:GNU C 库 glibc 中的内存分配器
  • tcmalloc:Google 开发(目前 Golang 运行时采用的就是这个)
  • jemalloc:Facebook 开发

这些分配器的共同点是:自己使用 mmapbrk 等系统调用,采用较大的单位,批量地向内核申请当前进程虚拟地址空间的内存,然后高效地管理起来。当应用程序有不管任何大小的分配需求时,分配器都能以较低的碎片率高效地分配。

2.5.1 ptmalloc 内存分配器的工作原理

我们来看 glibc 中内置的 ptmalloc 内存分配器的大致工作原理,看看它是如何能够随时分配释放各种大小的内存块,还能做到极低的碎片率的。

ptmalloc 的内存分配的单位是 malloc_chunk,我们简称 chunk。它包含 headerbody 两部分。这是 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 部分的 fdbk 字段分别是指向上一个和下一个空闲的 chunk,用来当双向链表指针使用。

glibc 会将相似大小的空闲 chunk 都串起来,这样等下次用户再来分配时,找到链表就可以快速分配。这样的一个链表被称为一个 bin。PTmalloc 一共维护了 128 个 bin,并使用一个数组来存储这些 bin。

Bins 分类

根据不同的大小,bins 分为 small binslarge 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 进程使用内存的几种方式

在第二个小节中,我们介绍了进程使用内存的几种方式,这些方式都是从内核的视角出发的:

  1. 内核启动进程时会将可执行文件的代码段、数据段以及其关联的动态链接库都加载映射到虚拟地址空间中。
  2. 提供了 mmapbrk 等系统调用,允许进程在运行的过程中动态地向虚拟地址空间申请内存。

无论是 mmap 还是 brk,其实工作原理都非常的简单:申请或者修改一个 vm_area_struct 对象而已。真正的物理内存还是依赖缺页中断和伙伴系统来分配。

2.6.3 进程栈

在第三个小节中,我们深入地分析了进程栈的初始化过程以及动态增长原理。进程栈在初始化的时候,只有 4KB 的大小。随着程序的运行发现栈空间不够时,内核会自动对栈空间进行增加。当然这个增加是有上限的,可以通过 ulimit 中的 stack size 来查看和修改。

2.6.4 线程栈

在第四个小节中,我们深入地分析了线程栈。我们发现虽然进程栈和线程栈都叫栈,但其实在实现原理上是完全不同的东西。线程栈内核根本不管,是 glibc 提前调用 mmap 申请好,给新线程传递进来的。而且线程栈不可以自动扩展,所以申请的时候就是申请的栈的最大上限。

2.6.5 malloc 的工作原理

在第五个小节中,我们介绍了 malloc 的工作原理。内核只提供 mmapbrk 这种基础的内存分配方式,但我们开发者可能会频繁地需要申请各种尺寸的小对象。如果直接使用 mmapbrk,会导致严重的碎片问题,频繁的系统调用也会拉低进程运行性能。glibc 中的内存分配器通过链表的方式管理各种大小的 chunk,每一个链表中都是相同大小的 chunk。当进程需要对象时,分配器根据其大小找到链表,从链表头摘一个直接用。当释放时,还会放到相应大小的 chunk 中,等下次再分配,并不会立即还给内核。

2.6.6 回顾开篇问题

1)申请内存申请到的真的是物理内存吗?

我们应用层开发平时接触到的内存,不管是栈也好,堆也罢,甚至是直接用 mmap,其实看到的都是虚拟内存,只是代表进程地址空间里的一段地址范围的 vm_area_struct 而已。

2)对虚拟内存的申请如何转化为对物理内存的访问?

真正的物理内存是在访问时,如果发现虚拟内存页对应的物理页还没有申请的话,触发缺页中断。在缺页中断中调用伙伴系统的 alloc_pages 来申请的。这是以为单位的!

3)top 命令输出中 VIRT 和 RES 分别是什么含义?

我们在使用 top 查看内存指标时,往往会看到两个内存相关的指标,分别是 VIRTRES。例如下图中是一个典型的输出。

典型 top 输出

(此处应有图片,描述 top 命令输出的 VIRT 和 RES 列。实际内容如下:)

VIRT 指的是进程的代码段、数据段、动态链接库、栈、堆等所有加起来,总共对虚拟地址空间申请了多大。而另一列 RES 指的是实际占用的物理页有多大。

除了 top 命令以外,还有其它的命令在查看内存时也会输出两个指标,比如 ps 命令输出时会包含 VSZRSS 两列:

# 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

其中 VSZtop 命令输出的 VIRT 是同一个含义,也是指虚拟地址空间占用。RSStop 中的 RES 一样,是实际物理内存页的消耗。

4)栈的大小限制是多大?

为了避免无限递归之类的问题对系统造成的冲击,栈都是有大小限制的。进程堆栈大小的限制在每个机器上都是不一样的。栈的大小可以通过 ulimit 命令查看和修改。一般大概是 8MB 左右。

5)当堆栈发生溢出后应用程序会发生什么?

报错结果一般是 Segmentation fault (core dumped)

6)进程栈和线程栈是相同的东西吗?

虽然进程栈和线程栈都叫栈,但其实在实现原理上是完全不同的东西。进程栈是 Linux 内核实现的,线程栈是 glibc 在用户态申请的。进程线程的区别与联系汇总后的对比表如下:

特性进程栈线程栈
创建时机内核创建进程时在内核创建前,由 glib 使用 mmap 申请
初始大小4KBulimitstack size(即最大栈大小)
是否可增长可以不可以
最大限制ulimitstack sizeulimitstack size

7)你知道 malloc 大概是如何工作的吗?

内存分配是一件挺复杂的工作,不过好在各种语言的内存分配器都替我们解决好了。我们常用的 malloc 是在 glibc 内置的 PTMalloc 分配器实现的。glibc 维护了各种大小的空闲链表,每个链表中的元素都是一个 chunk。每次调用 malloc 申请内存时,都是先找到合适大小的空闲链表,然后从上面摘一个 chunk 下来就行了。chunk 有一点控制 header,其中的 body 部分就是返回给我们使用的内存地址。