本文探索的就是对于堆内存优化的一种技术,动态栈分配(dynamic stack allocations),中心思想就是把在堆上申请内存的操作分配到栈上来完成

前言

在堆上分配内存的操作开销很大,对性能影响会比较明显。

本文探索的就是对于堆内存优化的一种技术,动态栈分配(dynamic stack allocations),中心思想就是把在堆上申请内存的操作分配到栈上来完成

主要参考文章在文末链接处附上了,一些翻译可能不太准确的术语可以直接看原文进行理解。

堆 & 栈

在探索动态栈分配之前,先简单描述一下基本知识 —— 堆和栈 的概念。

堆内存在运行时创建,没有固定的地址或大小,一般需要由开发者自行申请分配和释放。

在 Linux 和 Posix 上,一般 malloc 使用 mmap 来提前申请一大块内存,之后再分配使用这块内存中的部分内存,减少 CPU 的负担。

栈内存采用连续的内存地址来进行存放,在编译时就能知道它的大小,按函数调用来进行内存分配,属于临时内存,离开作用域即被释放。

由 SP (stack pointer,栈指针) 进行追踪栈内存,所以开发者不需要操心。

栈内存一般是在线程被创建出来的时候开辟的,通常有大小限制,1MB 左右,超出栈内存深度会引发异常。

动态栈分配 (dynamic stack allocations)

动态栈分配,中心思想就是把在堆上申请内存的操作分配到栈上来完成

一些值得注意的点是:

  • 栈内存同作用域生命周期一致

  • 栈内存通常很小,默认大小为 1MB,Linux 上是 8MB

  • 在函数参数中无法使用,因为参数也在入栈

  • 一般分配超过一页内存(通常 4KB),需要引入 stack probing(栈探针) 来确保栈内存的正常增加

如何使用?

Visual C++ 使用函数 _malloca 来进行栈内存分配,需要传入申请内存的大小,需要与 _freea 配合使用。

Debug 模式下,会分配内存到堆上。

1
2
3
4
5
6
7
8
9
#include<alloca.h>

void allocStackDynamic(size_t size)
{
    assert(size > 0);
    char *mem = static_cast<char *>(_malloca(size));
    memset(mem, 0, size);
    _freea(mem);
}

操作系统是如何操作栈内存的?

这里将描述的是 Windows 平台上的操作。

在进程当中,会有一片虚拟内存是给线程作为栈内存使用的。

一开始,会只有保留的栈内存(reserves)存在,线程根据需要去申请使用(commits),这样避免了虚拟内存的页浪费。

  • 保留的栈内存(reserves)是进程中的虚拟内存,是还没被映射到物理内存的,因此还没有相应的物理页内存。

  • 申请使用的(commits、已分配的内存)是已经映射到物理内存的,有相应的物理页内存。

这两种内存的大小可以在 PE 文件头进行设置,或者通过开辟线程调用 CreateThread 去申请,通常新线程会有 1MB 的保留内存(reserves) 和用这块内存初始申请的 4KB 的已分配内存(commits)。

为了知悉已分配的栈内存(commits)什么时候会增加,会在最后的已分配的栈内存的内存页会放入哨兵页(guard page)。

如果栈增长触碰到(touched)新的内存页,即哨兵页,会引发异常中断,然后申请使用更多的保留内存,哨兵页同时也挪到最后分配页的后面。

极端案例

哨兵页(guard page)大小也就 4 KB,如果我们申请的栈内存越过了 4KB,也即哨兵页没被触碰到(touched),会发生什么事?

1
2
3
4
5
void allocStackStatic()
{
    char mem[5000]; // stack grows downwards:
    mem[0] = 0;     // => &mem[0] < &mem[4999] < previous SP
}

一旦触碰到(touched)哨兵页后面的内存,那部分内存是未分配的,也即保留内存,这时候进程被终止,引发访问冲突(access violation)。

如何解决极端案例?

需要编译器去保证栈内存的正常增长,编译器引入了栈探针(stack probing)来对付超出 4KB 的栈内存申请。

由于栈内存是静态分配的,所以编译器是可以检测到这种超大的栈内存分配的,对于这种调用栈(stack frame),编译器会生成栈探针(stack probing)指令,实际是调用 _chkstk 函数。

当运用的是动态栈分配(dynamic stack allocations)时,调用的是 _alloca_probe16 函数。

题外:栈内存发生缺页中断会怎样?

当使用 push 指令(入栈操作),如果发生了缺页中断,那么返回地址要保存在哪里呢?

在 x86 架构下,TSS(task state segment,任务状态段) 保存有不同权限(privilege level)下的各自的栈指针(stack pointer),所以当发生用户态和内核态的切换时,系统会根据不同的权限(privilege level)来进入到不同的调用栈当中。

参考链接

https://geidav.wordpress.com/tag/stack-probing/

https://stackoverflow.com/questions/25222967/what-happens-when-a-page-fault-occurs-in-stack