2.3 进程栈内存的使用

栈是编程中使用内存最简单的方式。例如,下面的简单代码中的局部变量 n 就是在堆栈中分配内存的。

#include <stdio.h>
void main()
{
  int n = 0;
  printf("0x%x\n", &n);
}

同样我准备了能让大家实际动手实验的例子,源码位于 chapter-04/test-04 中。
另起一个控制台,执行程序输出中提示的命令,也可以在 brk 调用执行后查看到变化。
C 语言中的 malloc 就是依赖操作系统提供的 mmapbrk 几个系统调用来管理内存的。

# gcc main.c -o main
# ./main
这是一个brk/sbrk使用的例子!
当前Program Break位置是:0x55f4bae48000
也可以另起一个命令行查看虚拟地址空间中heap状态,命令是 cat /proc/2295984/maps
然后按任意键继续...
brk增加4096字节后,当前Program Break位置变成了:0x55f4bae49000
然后按任意键继续...
brk缩小4096字节后,当前Program Break位置回到了:0x55f4bae48000
然后按任意键退出程序...
# cat /proc/2295984/maps
55f4bae27000-55f4bae48000 rw-p 00000000 00:00 0                          [heap]
......
# cat /proc/2295984/maps
55f4bae27000-55f4bae49000 rw-p 00000000 00:00 0                          [heap]
......
# cat /proc/2295984/maps
55f4bae27000-55f4bae48000 rw-p 00000000 00:00 0                          [heap]
......

在这一节中,我们深入对栈进行了解。之后,也会对开篇中提到的两个问题有更加深刻的理解。

  • 堆栈的大小限制是多大?这个限制可以调整吗?
  • 当堆栈发生溢出后应用程序会发生什么?

2.3.1 进程堆栈的初始化

前面在第三章中我们介绍了进程的启动过程。其中在 3.4 节介绍了启动后调用 exec 加载可执行文件过程的时候,会给进程栈申请一个 4KB 的初始内存。我们再专门看下这段逻辑。

加载系统调用 execve 依次调用 do_execvedo_execve_common 来完成实际的可执行程序加载。在 do_execve_common 中调用 bprm_mm_init 申请一个全新的地址空间 mm_struct 对象,准备留着给新进程使用。
申请完地址空间后,就给新进程的栈申请一页大小的虚拟内存空间,作为给新进程准备的栈内存。申请完后把栈的指针保存到 bprm->p 中记录起来。

在 2.1 节中我们说到过,我们平时所说的进程虚拟地址空间在 Linux 是通过一个个的 vm_area_struct 对象来表示的。所以这里看到栈内存的申请其实只是一个表示一段地址范围的 vma 对象而已,并没有真正申请物理内存。

在上面 __bprm_mm_init 函数中通过 vm_area_alloc 申请了一个 vma 内核对象作为栈用。vm_end 指向了 STACK_TOP_MAX(地址空间的顶部附近的位置),vm_startvm_end 之间留了一个 Page 大小。也就是说默认给栈准备了 4KB 的大小。最后把栈的指针记录到 bprm->p 中。

// file:fs/exec.c
static int bprm_mm_init(struct linux_binprm *bprm)
{
  // 申请个全新的地址空间 mm_struct 对象
  bprm->mm = mm = mm_alloc();
  __bprm_mm_init(bprm);
  ...
}
 
// file:fs/exec.c
static int __bprm_mm_init(struct linux_binprm *bprm)
{
  bprm->vma = vma = vm_area_alloc(mm);
  vma->vm_end = STACK_TOP_MAX;
  vma->vm_start = vma->vm_end - PAGE_SIZE;
  ...
  bprm->p = vma->vm_end - sizeof(void *);
}

接下来进程加载过程会使用 load_elf_binary 真正开始加载可执行二进制程序。在加载时,会把前面准备的进程栈的地址空间指针设置到了新进程 mm 对象上。

// file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{ 
  // ELF 文件头解析
  // Program Header 读取
  // 清空父进程继承来的资源
  ...
  current->mm->start_stack = bprm->p;
}

这样新进程将来就可以使用栈进行函数调用,以及局部变量的申请了。

2.3.2 栈的自动增长

前面我们看到了,进程在被加载启动的时候,栈内存默认只分配了 4 KB 的空间。那么随着程序的运行,当栈中保存的调用链,局部变量越来越多的时候,必然会超过 4 KB。

我们回头看下缺页处理函数 __do_page_fault。如果栈内存 vma 的 start 比要访问的 address 大,则需要调用 expand_stack 对栈的虚拟地址空间进行扩张。

我们详细看下源码,在 __do_page_fault 源码中,扩充栈空间的是由 expand_stack 函数来完成的,如下。

// file:arch/x86/mm/fault.c
static inline
void do_user_addr_fault(..., unsigned long address)
{
  ...
  if (likely(vma->vm_start <= address))
    goto good_area;
  
  // 如果vma的开始地址比address大,则判断VM_GROWSDOWN看是否可以动态扩张
  if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
    bad_area(regs, hw_error_code, address);
    return;
  }
  // 对vma进行扩张
  if (unlikely(expand_stack(vma, address))) {
    bad_area(regs, error_code, address);
    return;
  }

do_user_addr_fault,先判断要访问的变量地址 address 是否落在 vma 内部,如果落在内部就调用 handle_mm_fault 进行实际物理页的分配,这段逻辑我们 2.1 小节讲过。

这里我们再讲下另外一段逻辑,就是如果 address 超过 vma 的范围后(栈一般是向下增加的,如果 vma->vm_start 大于 address 则表示栈不够用了)就调用 expand_stack 进行扩张。

其实在 Linux 栈地址空间增长是分两种方向的,一种是从高地址往低地址增长,一种是反过来。大部分情况都是由高往低增长的。本文只以向下增长为例。

我们来看下 expand_stack 的内部细节。

expand_downwards 中先进行了几个计算。

  1. 计算出新的堆栈大小。计算公式是 size = vma->vm_end - address
  2. 计算需要增长的页数。计算公式是 grow = (vma->vm_start - address) >> PAGE_SHIFT
  3. 然后会判断此次栈空间是否被允许扩充,判断是在 acct_stack_growth 中完成的。如果允许扩展,则简单修改一下 vma->vm_start 就可以了!扩充的具体操作就是这么简单,简单修改 vma->vm_start
good_area:
  handle_mm_fault(vma, address, flags, regs);
  ...
}
// file:mm/mmap.c
int expand_stack(struct vm_area_struct *vma, unsigned long address)
{
  ...
  return expand_downwards(vma, address);
}
 
int expand_downwards(struct vm_area_struct *vma, unsigned long address)
{
  ...
  // 计算栈扩大后的最后大小
  size = vma->vm_end - address;
  // 计算需要扩充几个页面
  grow = (vma->vm_start - address) >> PAGE_SHIFT;
  // 判断是否允许扩充
  acct_stack_growth(vma, size, grow);
  // 如果允许则开始扩充
  vma->vm_start = address;
  return ...
}

我们再来看 acct_stack_growth 都进行了哪些限制判断。

acct_stack_growth 中只是进行一系列的判断。may_expand_vm 判断的是增长完这几个页后是否超出整体虚拟地址空间大小的限制。rlim[RLIMIT_STACK].rlim_cur 中记录的是栈空间大小的限制。这些限制都可以通过 ulimit 命令查看到。

// file:mm/mmap.c
static int acct_stack_growth(struct vm_area_struct *vma,
            unsigned long size, unsigned long grow)
{
  ...
  // 检查地址空间是否超出限制
  if (!may_expand_vm(mm, grow))
    return -ENOMEM;
  // 检查是否超出栈的大小限制
  if (size > rlimit(RLIMIT_STACK))
    return -ENOMEM;
  ...
  return 0;
}

上面的这个输出表示虚拟地址空间大小没有限制,栈空间的限制是 8 MB。如果进程栈大小超过了这个限制,会返回 -ENOMEM。如果觉得系统默认的大小不合适可以通过 ulimit 命令修改。

# ulimit -a
......
max memory size         (kbytes, -m) unlimited
stack size              (kbytes, -s) 8192
virtual memory          (kbytes, -v) unlimited
# ulimit -s 10240
# ulimit -a
stack size              (kbytes, -s) 10240
# vi /etc/security/limits.conf
......
* soft stack 102400

回顾开篇其中一个问题,堆栈的大小限制是多大?这个限制可以调整吗?
进程堆栈大小的限制在每个机器上都是不一样的,可以通过 ulimit 命令来查看。修改的话,临时修改可以使用 ulimit -s 命令。如果要长期修改,建议通过修改 /etc/security/limits.conf 文件的方式。

对于开篇中的另外一个问题,当堆栈发生溢出后应用程序会发生什么?写个简单的无限递归调用就知道了。如果不想写的话,可以把书中配套源码中的 chapter04/test01 编译运行一下。这个错误估计你也遇到过,报错结果就是:

Segmentation fault (core dumped)

2.3.3 进程栈总结

本节讨论了进程栈内存的工作原理:

  1. 第一:进程在加载的时候给进程栈申请了一块虚拟地址空间 vma 内核对象。vm_startvm_end 之间留了一个 Page,也就是说默认给栈准备了 4KB 的空间。
  2. 第二:当进程在运行的过程中在栈上开始分配和访问变量的时候,如果物理页还没有分配,会触发缺页中断。在缺页中断中调用内核的伙伴系统真正地分配物理内存。
  3. 第三:当栈中的存储超过 4KB 的时候会自动进行扩大。不过大小要受到限制,其大小限制可以通过 ulimit -s 来查看和设置。

注意,今天我们讨论的都是进程栈。线程栈和进程栈有些不一样。等后面有空我们再单独看线程栈。


2.4 线程栈是如何使用内存的

这个小节我们再来聊聊线程栈。为什么要单独把线程栈拿出来说,这是因为线程栈和进程栈在实现上区别还是挺大的。回顾 Linux 的进程栈,它是一块在地址空间中专门的栈区域来表示的。进程在加载的时候就会调用将这块区域创建出来。

我们通过 cat /proc/{pid}/maps 可以查看到这块特殊的虚拟地址空间。

在第二章中我们了解了线程的创建过程。线程和创建它的进程是复用同一个地址空间的,也就是说同一个进程下的所有线程使用的是同一块的内存。

对于多线程程序而言,代码段、数据段、堆内存等资源共享没问题。但是各个线程栈区必须要独立。每个线程在并行调用的时候会在栈上独立地执行进栈和出栈,如果都使用进程地址空间中默认的 stack 区域,多线程跑起来就乱套了。所以,线程栈必须有自己独特的实现方法。

在开始介绍Linux上的线程之前,我们先来了解下 NPTL 的故事。在Linux内核中,其实并没有线程的概念。内核原生的 clone 系统调用仅仅只是支持生成一个和父进程共享地址空间等资源的轻量级进程而已。

多线程在Linux中最早是由 LinuxThreads 这个工程带进来的。LinuxThreads 项目希望在用户空间模拟对线程的支持。但不幸的是,这种方法有许多缺点,特别是在信号处理、调度和进程间同步原语等方面。另外,这种线程模型也没有符合 POSIX 标准。

为了改进 LinuxThreads,需要做两方面的工作,一是在内核上提供支持,二是重写线程库。后来有两个改进 Linux 线程的工程被发起,一是IBM的 NGPT - Next-Generation POSIX Threads。另一个是Red Hat的 NPTL - Native POSIX Thread Library。

IBM 在 2003 年的时候放弃了 NGPT,于是在改进 LinuxThreads 的路上就只剩下了 NPTL。现在我们在Linux使用 pthread_create 来创建线程,其实就是使用的 NPTL。所以,在我们后面给大家展示线程源码时,会看到很多源文件所在的目录都是 nptl

要想彻底理解清楚 Linux 线程(NPTL线程),首先要明确的是Linux中线程是包含了两部分的实现。

  • 第一部分是用户态的 glibc 库。我们创建线程调用的 pthread_create 就是在 glibc 库实现的。注意,glibc 库完全是在用户态运行的,并非内核源码。
  • 第二部分是内核态的 clone 系统调用。内核通过 clone 系统调用可以创建出和父进程共享内存地址空间的轻量级用户进程。这一部分我们在第二章中介绍过。

我们今天要讨论的线程栈,其实是在第一部分 - 用户态的 glibc 库中执行的。我们再次翻开 glibc 库中创建线程的 pthread_create 函数的源码(注意是 glibc 源码,不是 Linux 内核),从源码中把线程栈内存原理展示给大家。

glibc源码下载地址:http://ftp.gnu.org/gnu/glibc/

pthread_create 函数会调用到 __pthread_create_2_1。在这个函数中的工作包括如下几个部分:

  1. 定义一个线程对象指针
  2. 确定栈空间大小
  3. 调用 ALLOCATE_STACK 为用户申请用户栈内存
  4. 通过 create_thread 来调用内核 clone 系统调用来创建线程

没错,是先申请的内存,然后才调用系统调用创建的线程。也就是说,Linux内核并没有处理线程栈内存。而是由 glibc 库在用户态申请后传给 clone 系统调用的。

接下来我们分小节来单独看一下上面每一步工作。

2.4.1 glibc线程对象

前面我们说过线程资源分为两部分,一部分是内核资源,例如代表轻量级进程的内核对象 task_struct。另一部分是用户态内存资源,包含线程栈。用户资源这一部分的核心数据结构是 struct pthread。它存储了线程的相关信息,包括线程栈。每个 pthread 对象都唯一对应一个线程。

// file:nptl/pthread_create.c
int __pthread_create_2_1 (...)
{
  ...
  // 2.4.1 定义线程对象
  struct pthread *pd;
  // 2.4.2 确定栈空间大小
  // 2.4.3 申请用户栈内存
  err = ALLOCATE_STACK (iattr, &pd);
  ...
  // 2.4.4 创建用户进程
  err = create_thread (pd, iattr, STACK_VARIABLES_ARGS);
}

pthread 结构体的 tid 对象中存储了线程的 ID 值。在 stackblock 中指向了线程栈内存,stackblock_size 表明栈内存区域的大小。

// file:nptl/descr.h
struct pthread
{
  pid_t tid;
  ......
  // 线程栈内存
  void *stackblock;
  size_t stackblock_size;
}

2.4.2 确定栈空间大小

了解了 glibc 中的线程对象 pthread 后,接着调用 ALLOCATE_STACKALLOCATE_STACK 是一个宏,最终会调用到 allocate_stack 函数。

在这个函数中的功能包括两块:一是确定栈内存的大小,二是申请内存。

其中 attr->stacksize 是创建线程时传入的参数。也就是说如果用户指定了栈的大小,则使用用户指定的值。如果没有指定就使用缺省的大小 __default_stacksize。其中在 init.c 文件中,我搜到了 __default_stacksize 设置值的过程。

// file:nptl/allocatestack.c
static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
    ALLOCATE_STACK_PARMS)
{
  // 确定栈空间大小
  size = attr->stacksize ?: __default_stacksize;
  // 申请栈内存
  ...
}

和进程栈空间大小一样,getrlimit 读取的配置也是从 ulimit 命令的配置中读取栈大小限制的。

在读取到当前系统的配置后,开始真正决定栈的实际大小。首先处理的是 ulimit 没有配置或者配置不合理的情况。

  • 如果没有 ulimit 没有配置或者配置的是无限大,那么就配置的大小是 ARCH_STACK_DEFAULT_SIZE(32 MB)。
  • 如果用户手残配置的太小了,可能会导致程序无法正常运行,所以 glibc 库最小也会给一个 PTHREAD_STACK_MIN 大小(16384 字节)。
  • ulimit 配置合理的情况下,将取到的配置值对齐一下就直接用了。
// file:nptl/init.c
void __pthread_initialize_minimal_internal (void){
  // 确定缺省栈内存空间大小
  if (getrlimit (RLIMIT_STACK, &limit) != 0
      || limit.rlim_cur == RLIM_INFINITY){
    __default_stacksize = ARCH_STACK_DEFAULT_SIZE;
  } else if (limit.rlim_cur < PTHREAD_STACK_MIN){
    __default_stacksize = PTHREAD_STACK_MIN;
  } else {
    __default_stacksize = (limit.rlim_cur + pagesz - 1) & -pagesz;
  }
}
# ulimit -a
......
max memory size         (kbytes, -m) unlimited
stack size              (kbytes, -s) 8192
virtual memory          (kbytes, -v) unlimited

上述代码中涉及到的这两个宏的定义如下,一个是 16KB 多点,一个是 32MB。

// file:/usr/include/limits.h
#define PTHREAD_STACK_MIN     16384
 
// file:nptl/sysdeps/ia64/pthreaddef.h
#define ARCH_STACK_DEFAULT_SIZE (32 * 1024 * 1024)

无论你给栈设置的多大,NPTL 都会强行把你的线程栈大小限制到 32M 以内。

2.4.3 申请用户栈

在确定了栈空间大小后,就可以开始申请内存了。

// file:nptl/allocatestack.c
static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
    ALLOCATE_STACK_PARMS)
{
  size = attr->stacksize ?: __default_stacksize;
  ......
  struct pthread *pd;
  pd = get_cached_stack (&size, &mem);
  if (pd == NULL){
    mem = mmap (NULL, size, prot,
                MAP_PRIVATE | MAP_ANONYMOUS | ARCH_MAP_FLAGS, -1, 0);
    pd = (struct pthread *) ((((uintptr_t) mem + size - coloring
                - __static_tls_size)
                & ~__static_tls_align_m1)
                - TLS_PRE_TCB_SIZE);
    pd->stackblock = mem;
    pd->stackblock_size = size;
    ......
  }
  // 添加到全局在用栈的链表中
  list_add (&pd->list, &stack_used);
  ...
}

在这个函数中做了这么几件事情,都是和线程栈相关的:

  1. 首先尝试通过 get_cached_stack 获取一块缓存直接用,以避免频繁地对内存申请和释放。
  2. 假设没有取到缓存,就使用 mmap 系统调用直接申请一块匿名页内存空间。
  3. 将 pthread 对象先放到栈上了。
  4. 将栈添加到链表中管理起来。

可见,进程栈和线程栈的区别还是挺大的。我们总结下进程栈和线程栈的所有区别。

特性进程栈线程栈
创建时机内核创建进程时在内核创建前,由 glibc 使用 mmap 申请
初始大小4KBulimit 中 stack size
是否可增长可以不可以
最大限制ulimit 中 stack sizeulimit 中 stack size

很多同学看到线程栈一次性把栈都申请了可能会有疑惑,不用的话那不是浪费了么。不过大家也不用惊慌,这里申请到的还只是一段地址范围而已。物理内存得真正用到的时候才会分配。

mmap 系统调用被执行后,内核会再生成一个 vm_area_struct,通过它来表示这一段地址范围被分配出去了。

现在线程栈的内存是申请出来了。在申请到内存后,mem 指针指向是新内存的低地址。通过 memsize 算出高地址空间后,经过复杂的地址预留策略,例如对齐等,先把 struct pthread 给放了上去。

所以线程栈内存是由两个用途的,一个是存储线程栈对象 struct pthread,另一个是真正当做线程栈内存来用。

2.4.4 创建线程

在栈申请好后,在 create_thread 中调用 do_clone 系统调用开始创建。这个过程在 2.3 节进行了详细的介绍。这里就不展开赘述了。

就是再提一点。创建出来的进程和线程,内核中都是生成了一个 task_struct 对象,而且内核的创建过程调用同一套函数完成的。只不过区别是对于线程来讲,因为创建时使用了一些特殊 flag,所以内核在创建 task_struct 时不再申请地址空间 mm_struct、目录信息 fs_struct、打开文件列表 files_struct。新线程的这些成员都是和创建它的任务共享。

// file:nptl/sysdeps/pthread/createthread.c
static int
create_thread (struct pthread *pd, const struct pthread_attr *attr,
               STACK_VARIABLES_PARMS)
{
  ...
  int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGNAL
                     | CLONE_SETTLS | CLONE_PARENT_SETTID
                     | CLONE_CHILD_CLEARTID | CLONE_SYSVSEM
                     | 0);
  do_clone (pd, attr, clone_flags, start_thread,
            STACK_VARIABLES_ARGS, 1);
  ...
}

所以,如果从内核的视角看,是没有进程和线程的区分的,都是一个 task_struct 而已。