Go语言的栈空间管理

翻译原文连接 转帖/转载请注明出处程序员

英文原文连接 发表于2014/09/15golang

在CloudFlare,咱们使用Go语言搭建各类服务和应用。在这篇博文里,咱们将对Go语言的技术特色进行深度分析。Go语言里最重要的一个特性就是goroutine。它们的开销比较小,相互协做地调度线程来运行。它们有普遍的用途,好比实现超时控制(timeouts),生成器(generators),以及在多个后台应用之间实现相互竞争(racing)。为了使goroutine可以适应更多的任务,咱们必须保证每一个goroutine占用不多的内存。同时,人们应该能够很方便地建立goroutine。安全

为了达到这些目标,Go语言管理的栈的方式看起来和其它不少语言同样,可是它的实现确实很是不一样。数据结构

线程栈介绍

在咱们开始讨论Go语言的栈以前,让咱们来看看C语言是怎么管理栈的。less

当你在C语言里启动一个线程的时候,标准库(standard library)会负责分配一块内存来用做线程的栈空间。它首先分配一块内存,告诉内核它的地址,而后让内核来控制线程的运行。若是这块分配的内存空间不够大的话,问题就变得复杂起来了。函数

咱们来看看下面这个函数:post

int a(int m, int n) {
    if (m == 0) {
        return n + 1;
    } else if (m > 0 && n == 0) {
        return a(m - 1, 1);
    } else {
        return a(m - 1, a(m, n - 1));
    }
}

这是个递归函数。调用a(4,5)会耗尽全部的栈内存。为了不这个问题,咱们能够调整标准库分配给栈的内存空间的大小。可是增大这个参数会致使全部的线程都占用那么多的栈空间,即便这些函数并不须要递归调用。在这种状况下,虽然你的程序没有用到分配的栈,它仍是会耗尽全部的内存。google

另一个解决办法是给每一个线程分配不一样大小的栈。这样你就须要给每一个线程配置栈的大小,从而使得建立线程变得更加麻烦。想要决定一个线程会使用多少内存一般是很是困难的。操作系统

Go语言的解决办法

Go语言的运行环境(runtime)尝试在goroutine须要的时候动态地分配栈空间,而不是给每一个goroutine分配固定大小的内存空间。这样就避免了须要程序员来决定栈的大小。Go的开发小组正尝试从一种解决方案切换到另一种解决方案。接下来将会讨论老的解决方案和它的缺点,而后介绍新的方案以及选择它的缘由。线程

分块式的栈(Segmented stacks)

分块式的栈是最初Go语言组织栈的方式。当建立一个goroutine的时候,它会分配一个8KB的内存空间来给goroutine的栈使用。

咱们最感兴趣的是当这8KB的栈空间被用完的时候。为了处理这种状况,每一个Go函数的开头都有一小段检测代码。这段代码会检查咱们是否已经用完了分配的栈空间。若是是的话,它会调用morestack函数。morestack函数分配一块新的内存做为栈空间,而且在这块栈空间的底部填入各类信息(包括以前的那块栈地址)。在分配了这块新的栈空间以后,它会重试刚才形成栈空间不足的函数。这个过程叫作栈分裂(stack split)。当通过栈分裂以后,栈结构以下图所示。

在新分配的栈底部,还插入了一个叫作lessstack的函数指针。这个函数尚未被调用。这样设置是为了从刚才形成栈空间不足的那个函数返回时作准备的。当咱们从那个函数返回时,它会跳转到lessstacklessstack函数会查看在栈底部存放的数据结构里的信息,而后调整栈指针(stack pointer)。这样就完成了重新的栈块到老的栈块的跳转。接下来,新分配的这个块栈空间就能够被释放掉了。

分块式的栈的问题

分块式的栈让咱们可以按照需求来扩展和收缩栈的大小。程序员不须要花精力去估计goroutine会用到多大的栈。建立一个新的goroutine的开销也不大。当程序员不知道栈会扩展到多少大时,它也能很好的处理这种状况。

这一直是以前Go语言管理栈的的方法。但这个方法有一个问题。缩减栈空间是一个开销相对较大的操做。若是在一个循环里有栈分裂,那么它的开销就变得不可忽略了。一个函数会扩展,而后分裂栈。当它返回的时候又会释放以前分配的内存块。若是这些都发生在一个循环里的话,代价是至关大的。

这就是所谓的热分裂问题(hot split problem)。它是Go语言开发者选择新的栈管理方法的主要缘由。新的方法叫作栈复制法(stack copying)

栈复制法(stack copying)

栈复制法一开始和分块式的栈很像。当goroutine运行并用完栈空间的时候,与以前的方法同样,栈溢出检查会被触发。可是,不像以前的方法那样分配一个新的内存块并连接到老的栈内存块,新的方法会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里。这样作意味着当栈缩减回以前大小时,咱们不须要作任何事情。栈的缩减没有任何代价。并且,当栈再次扩展时,运行环境也不须要再作任何事。它能够重用以前分配的空间。

栈是如何被复制的?

栈的复制听起来很容易,但实际操做并不是那么简单。存储在栈上的变量的地址可能已经被使用到。也就是说程序使用到了一些指向栈的指针。当移动栈的时候,全部指向栈里内容的指针都会变得无效。幸运的是,指向栈内容的指针自身也一定是保存在栈上的。这是为了保证内存安全的必要条件。不然一个程序就有可能访问一段已经无效的栈空间了。

由于垃圾回收的须要,咱们必须知道栈的哪些部分是被用做指针了。当咱们移动栈的时候,咱们能够更新栈里的指针让它们指向新的地址。全部相关的指针都会被更新。咱们使用了垃圾回收的信息来复制栈,但并非任何使用栈的函数都有这些信息。由于很大一部分运行环境是用C语言写的,不少被调用的运行环境里的函数并无指针的信息,因此也就不可以被复制了。当遇到这种状况时,咱们只能退回到分块式的栈并支付相应的开销。(注:这部分信息有点过期了,但仍是值得一读!)

这也是为何如今运行环境的开发者正在用Go语言重写运行环境的大部分代码。没法用Go语言重写的部分(好比调度器的核心代码和垃圾回收器)会在特殊的栈上运行。这个特殊栈的大小由运行环境的开发者设置。

这些改变除了使栈复制成为可能,它也容许咱们在未来实现并行垃圾回收。

再说一下虚拟内存

还有一种处理栈空间的办法是分配很大一块虚拟内存。由于只有在内存地址被访问到的时候才会真正分配物理内存,彷佛咱们能够简单地分配一块很大的虚拟内存而后让操做系统来完成剩下的工做。可是这个方法有几个问题。

首先,32位的系统只有4GB的虚拟内存,而一般只有其中的3GB能够被应用程序使用。建立上百万的goroutine也不是不常见,这时你极可能会用完全部的虚拟内存(即便咱们假设栈只用到8KB的空间)。

其次,即便咱们能够在64位的系统里分配大量的虚拟内存,它依赖过量使用(overcommitting)内存。过量使用是指咱们分配比实际物理内存空间更多的虚拟内存,而且依赖操做系统来确保可以分配到须要的物理内存。可是过量使用虚拟内存是存在必定风险的。由于一个进程真的使用了比实际物理内存更大的内存空间时,它须要开始为新的需求腾出可用的物理空间。它一般会把一块内存里的内容保存到磁盘上。这样会致使延迟不可预测。由于这个缘由,咱们一般不在系统里过量使用内存。

结束语

为了让goroutine轻量化,快速,而且适用于大部分任务,开发者们作了不少努力。栈的管理只是其中很小的一部分。若是你想了解更多关于栈复制的技术,这份设计文档提供了更多的细节。

若是你想了解更多关于重写Go语言运行环境的细节,能够读如下这个邮件列表里的文章